[#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 {
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 { AuthProvider } from './context/AuthContext';
import AdminRoute from './components/AdminRoute';
import ReservationStep1Page from './pages/v2/ReservationStep1Page';
import ReservationStep2Page from './pages/v2/ReservationStep2Page';
import ReservationSuccessPage from './pages/v2/ReservationSuccessPage';
import MyReservationPageV2 from './pages/v2/MyReservationPageV2';
const AdminRoutes = () => (
<AdminLayout>
@ -43,7 +47,13 @@ function App() {
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
<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>
</Layout>
} />

View File

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

View File

@ -2,10 +2,16 @@ import apiClient from "@_api/apiClient";
import type {
AdminReservationCreateRequest,
MyReservationRetrieveListResponse,
ReservationCreateRequest,
ReservationCreateResponse,
ReservationCreateWithPaymentRequest,
ReservationDetailV2,
ReservationPaymentRequest,
ReservationPaymentResponse,
ReservationRetrieveListResponse,
ReservationRetrieveResponse,
ReservationSearchQuery,
ReservationSummaryListV2,
WaitingCreateRequest
} from "./reservationTypes";
@ -30,7 +36,7 @@ export const searchReservations = async (params: ReservationSearchQuery): Promis
};
// 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);
};
@ -55,16 +61,41 @@ export const createWaiting = async (data: WaitingCreateRequest): Promise<Reserva
};
// 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);
};
// 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);
};
// 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);
};
// 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';
export const ReservationStatus = {
PENDING: 'PENDING',
CONFIRMED: 'CONFIRMED',
CONFIRMED_PAYMENT_REQUIRED: 'CONFIRMED_PAYMENT_REQUIRED',
WAITING: 'WAITING',
CANCELED_BY_USER: 'CANCELED_BY_USER',
AUTOMATICALLY_CANCELED: 'AUTOMATICALLY_CANCELED'
} as const;
export type ReservationStatus =
| typeof ReservationStatus.PENDING
| typeof ReservationStatus.CONFIRMED
| typeof ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
| typeof ReservationStatus.WAITING;
| typeof ReservationStatus.WAITING
| typeof ReservationStatus.CANCELED_BY_USER
| typeof ReservationStatus.AUTOMATICALLY_CANCELED;
export interface MyReservationRetrieveResponse {
id: number;
id: string;
themeName: string;
date: string;
time: string;
@ -29,7 +35,7 @@ export interface MyReservationRetrieveListResponse {
}
export interface ReservationRetrieveResponse {
id: number;
id: string;
date: string;
member: MemberRetrieveResponse;
time: TimeRetrieveResponse;
@ -43,15 +49,15 @@ export interface ReservationRetrieveListResponse {
export interface AdminReservationCreateRequest {
date: string;
timeId: number;
themeId: number;
memberId: number;
timeId: string;
themeId: string;
memberId: string;
}
export interface ReservationCreateWithPaymentRequest {
date: string;
timeId: number;
themeId: number;
timeId: string;
themeId: string;
paymentKey: string;
orderId: string;
amount: number;
@ -60,13 +66,142 @@ export interface ReservationCreateWithPaymentRequest {
export interface WaitingCreateRequest {
date: string;
timeId: number;
themeId: number;
timeId: string;
themeId: string;
}
export interface ReservationSearchQuery {
themeId?: number;
memberId?: number;
themeId?: string;
memberId?: string;
dateFrom?: string;
dateTo?: string;
}
// 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);
};
export const delTheme = async (id: number): Promise<void> => {
export const delTheme = async (id: string): Promise<void> => {
return await apiClient.del(`/themes/${id}`, true);
};

View File

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

View File

@ -9,10 +9,10 @@ export const fetchTimes = async (): Promise<TimeRetrieveListResponse> => {
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);
};
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);
};

View File

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

View File

@ -27,7 +27,7 @@ const Navbar: React.FC = () => {
<div className="collapse navbar-collapse" id="navbarSupportedContent">
<ul className="navbar-nav ms-auto">
<li className="nav-item">
<Link className="nav-link" to="/reservation">Reservation</Link>
<Link className="nav-link" to="/v2/reservation">Reservation</Link>
</li>
{!loggedIn ? (
<li className="nav-item">
@ -40,7 +40,7 @@ const Navbar: React.FC = () => {
<span id="profile-name">{userName}</span>
</a>
<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><a className="dropdown-item" href="#" onClick={handleLogout}>Logout</a></li>
</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;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
color: #213547;
background-color: #ffffff;
font-synthesis: none;
text-rendering: optimizeLegibility;
@ -53,15 +52,4 @@ button:focus-visible {
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);
}, []);
const _cancelWaiting = (id: number) => {
const _cancelWaiting = (id: string) => {
cancelWaiting(id)
.then(() => {
alert('예약 대기가 취소되었습니다.');
setReservations(reservations.filter(r => r.id !== id));
setReservations(reservations.filter(r => r.id.toString() !== id));
})
.catch(handleError);
};
@ -74,7 +74,7 @@ const MyReservationPage: React.FC = () => {
<td>{getStatusText(r.status, r.rank)}</td>
<td>
{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>{r.paymentKey}</td>
<td>{r.amount}</td>

View File

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

View File

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

View File

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

View File

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

View File

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

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
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.*
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer
@ -9,17 +12,26 @@ import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer
import com.fasterxml.jackson.module.kotlin.kotlinModule
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.LocalDate
import java.time.LocalTime
import roomescape.common.exception.CommonErrorCode
import roomescape.common.exception.RoomescapeException
import java.time.*
import java.time.format.DateTimeFormatter
@Configuration
class JacksonConfig {
companion object {
private val ISO_OFFSET_DATE_TIME_FORMATTER: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX")
}
@Bean
fun objectMapper(): ObjectMapper = ObjectMapper()
.registerModule(javaTimeModule())
.registerModule(dateTimeModule())
.registerModule(kotlinModule())
.registerModule(longIdModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
private fun javaTimeModule(): JavaTimeModule = JavaTimeModule()
.addSerializer(
@ -38,4 +50,66 @@ class JacksonConfig {
LocalTime::class.java,
LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm"))
) as JavaTimeModule
private fun longIdModule(): SimpleModule {
val simpleModule = SimpleModule()
simpleModule.addSerializer(Long::class.java, LongToStringSerializer())
simpleModule.addDeserializer(Long::class.java, StringToLongDeserializer())
return simpleModule
}
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?
}
@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) {
val responseEntity = result as ResponseEntity<*>
val logMessage = messageConverter.convertToResponseMessage(
ConvertResponseMessageRequest(
type = LogType.CONTROLLER_SUCCESS,
httpStatus = responseEntity.statusCode.value(),
startTime = startTime,
var convertResponseMessageRequest = ConvertResponseMessageRequest(
type = LogType.CONTROLLER_SUCCESS,
httpStatus = responseEntity.statusCode.value(),
startTime = startTime,
)
if (log.isDebugEnabled()) {
convertResponseMessageRequest = convertResponseMessageRequest.copy(
body = responseEntity.body
)
)
}
val logMessage = messageConverter.convertToResponseMessage(convertResponseMessageRequest)
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
) : ErrorCode {
PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "P001", "결제 정보를 찾을 수 없어요."),
CANCELED_PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "P002", "취소된 결제 정보를 찾을 수 없어요."),
PAYMENT_CLIENT_ERROR(HttpStatus.BAD_REQUEST, "P003", "결제에 실패했어요. 결제 수단을 확인한 후 다시 시도해주세요."),
PAYMENT_DETAIL_NOT_FOUND(HttpStatus.NOT_FOUND, "P002", "결제 상세 정보를 찾을 수 없어요."),
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
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import java.time.OffsetDateTime
data class TossPaymentErrorResponse(
@ -15,7 +14,6 @@ data class PaymentApproveRequest(
val paymentType: String
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class PaymentApproveResponse(
val paymentKey: String,
val orderId: String,

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", "타인의 예약은 취소할 수 없어요."),
INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R007", "종료 날짜는 시작 날짜 이후여야 해요."),
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}" } }
}
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> {
log.debug { "[ReservationFinder.findAllByMemberId] 시작: memberId=${memberId}" }
@ -91,4 +100,14 @@ class ReservationFinder(
return reservationRepository.existsByTime(time)
.also { log.debug { "[ReservationFinder.isTimeReserved] 완료: isExist=$it, timeId=${time.id}, startAt=${time.startAt}" } }
}
fun findPendingReservation(reservationId: Long, memberId: Long): ReservationEntity {
log.debug { "[ReservationFinder.findPendingReservationIfExists] 시작: reservationId=$reservationId, memberId=$memberId" }
return findById(reservationId).also {
reservationValidator.validateIsReservedByMemberAndPending(it, memberId)
}.also {
log.debug { "[ReservationFinder.findPendingReservationIfExists] 완료: reservationId=${it.id}, status=${it.status}" }
}
}
}

View File

@ -10,6 +10,7 @@ import roomescape.reservation.exception.ReservationException
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationRepository
import roomescape.reservation.infrastructure.persistence.ReservationSearchSpecification
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalDate
@ -141,4 +142,37 @@ class ReservationValidator(
log.debug { "[ReservationValidator.validateAlreadyConfirmed] 완료: reservationId=$reservationId" }
}
fun validateIsReservedByMemberAndPending(reservation: ReservationEntity, requesterId: Long) {
if (reservation.member.id != requesterId) {
log.error { "[ReservationValidator.validateIsReservedByMemberAndPending] 예약자 본인이 아님: reservationId=${reservation.id}, reservation.memberId=${reservation.member.id} requesterId=$requesterId" }
throw ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER)
}
if (reservation.status != ReservationStatus.PENDING) {
log.warn { "[ReservationValidator.validateIsReservedByMemberAndPending] 예약 상태가 대기 중이 아님: reservationId=${reservation.id}, status=${reservation.status}" }
throw ReservationException(ReservationErrorCode.RESERVATION_NOT_PENDING)
}
}
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 io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Component
import roomescape.common.config.next
import roomescape.member.implement.MemberFinder
@ -101,4 +102,30 @@ class ReservationWriter(
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 {
return this.member.id == memberId
}
fun cancelByUser() {
this.status = ReservationStatus.CANCELED_BY_USER
}
fun confirm() {
this.status = ReservationStatus.CONFIRMED
}
}
enum class ReservationStatus {
CONFIRMED,
CONFIRMED_PAYMENT_REQUIRED,
PENDING,
WAITING,
CANCELED_BY_USER,
AUTOMATICALLY_CANCELED,
;
companion object {

View File

@ -70,4 +70,6 @@ interface ReservationRepository
)
fun findAllByMemberId(memberId: Long): List<MyReservationRetrieveResponse>
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()
return allTimes.map { time ->
val isReservable: Boolean = reservations.any { reservation -> time.id == reservation.id }
val isReservable: Boolean = reservations.none { reservation -> time.id == reservation.time.id }
TimeWithAvailability(time.id!!, time.startAt, date, themeId, isReservable)
}.also {
log.debug { "[TimeFinder.findAllWithAvailabilityByDateAndThemeId] ${it.size}개 조회 완료: date:$date, themeId=$themeId" }

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)
VALUES ('1111010100', '11', '110', '10100', '서울특별시', '종로구', '청운동'),

View File

@ -14,8 +14,8 @@ create table if not exists members (
name varchar(255) not null,
password varchar(255) not null,
role varchar(20) not null,
created_at timestamp null,
last_modified_at timestamp null
created_at timestamp,
last_modified_at timestamp
);
create table if not exists themes (
@ -23,8 +23,8 @@ create table if not exists themes (
description varchar(255) not null,
name varchar(255) not null,
thumbnail varchar(255) not null,
created_at timestamp null,
last_modified_at timestamp null
created_at timestamp,
last_modified_at timestamp
);
create table if not exists times (
@ -41,8 +41,9 @@ create table if not exists reservations (
theme_id bigint not null,
time_id bigint not null,
status varchar(30) not null,
created_at timestamp null,
last_modified_at timestamp null,
created_at timestamp,
last_modified_at timestamp,
constraint fk_reservations__themeId foreign key (theme_id) references themes (theme_id),
constraint fk_reservations__memberId foreign key (member_id) references members (member_id),
constraint fk_reservations__timeId foreign key (time_id) references times (time_id)
@ -55,8 +56,9 @@ create table if not exists payments (
total_amount bigint not null,
order_id varchar(255) not null,
payment_key varchar(255) not null,
created_at timestamp null,
last_modified_at timestamp null,
created_at timestamp,
last_modified_at timestamp,
constraint uk_payments__reservationId unique (reservation_id),
constraint fk_payments__reservationId foreign key (reservation_id) references reservations (reservation_id)
);
@ -68,6 +70,82 @@ create table if not exists canceled_payments (
cancel_amount bigint not null,
approved_at timestamp not null,
canceled_at timestamp not null,
created_at timestamp null,
last_modified_at timestamp null
created_at timestamp,
last_modified_at timestamp
);
create table if not exists payment1 (
id bigint primary key,
reservation_id bigint not null,
type varchar(20) not null,
method varchar(30) not null,
payment_key varchar(255) not null unique,
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,
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.string.shouldContain
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
class JacksonConfigTest(
private val objectMapper: ObjectMapper = JacksonConfig().objectMapper()
@ -52,4 +55,38 @@ class JacksonConfigTest(
}.message shouldContain "Text '$hour:$minute:$sec' could not be parsed"
}
}
context("Long 타입은 문자열로 (역)직렬화된다.") {
val number = 1234567890L
val serialized: String = objectMapper.writeValueAsString(number)
val deserialized: Long = objectMapper.readValue(serialized, Long::class.java)
test("Long 직렬화") {
serialized shouldBe "$number"
}
test("Long 역직렬화") {
deserialized shouldBe number
}
}
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")
}.Then {
statusCode(201)
body("data.member.id", equalTo(member.id!!))
body("data.member.id", equalTo(member.id!!.toString()))
body("data.status", equalTo(ReservationStatus.WAITING.name))
}
}

View File

@ -10,6 +10,7 @@ import io.mockk.mockk
import io.mockk.verify
import org.springframework.data.repository.findByIdOrNull
import roomescape.reservation.implement.ReservationFinder
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.time.business.domain.TimeWithAvailability
import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException
@ -105,5 +106,39 @@ class TimeFinderTest : FunSpec({
it.all { time -> time.isReservable }
}
}
test("날짜, 테마에 맞는 예약이 있으면 예약할 수 없다.") {
val times = listOf(
TimeFixture.create(startAt = LocalTime.now()),
TimeFixture.create(startAt = LocalTime.now().plusMinutes(30))
)
every {
themeFinder.findById(themeId)
} returns mockk()
every {
timeRepository.findAll()
} returns times
every {
reservationFinder.findAllByDateAndTheme(date, any())
} returns listOf(
mockk<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
}
}
}
})