generated from pricelees/issue-pr-template
Compare commits
37 Commits
main
...
refactor/#
| Author | SHA1 | Date | |
|---|---|---|---|
| b0b0eb6fcf | |||
| 9aaa988f76 | |||
| 86a8451bc3 | |||
| 110d91a31e | |||
| ba324464e9 | |||
| fb3be3bcaf | |||
| 6bbb35e55f | |||
| 1f3f57ba1c | |||
| b38a5242ce | |||
| 0cb7f3e234 | |||
| 477415b3ba | |||
| 2e2b71743f | |||
| fb459c4c5b | |||
| 817dc9f761 | |||
| 4c82ad80c0 | |||
| 4d98b18016 | |||
| 5fdb69ac70 | |||
| d19973978f | |||
| 0fc537da93 | |||
| 515853c20b | |||
| 349e0372f6 | |||
| 4108dcd01a | |||
| 79527cb708 | |||
| 0fc19530d1 | |||
| 112836c51c | |||
| 63d4f93d31 | |||
| c2906ee430 | |||
| 0dd50e2d99 | |||
| 457cc0947f | |||
| 6095813891 | |||
| e4611c76b3 | |||
| af81260355 | |||
| d0f6e0fe0c | |||
| ed0b81ff45 | |||
| 6cc7eb680c | |||
| 56ada7730c | |||
| b05c61a65a |
@ -21,6 +21,10 @@ java {
|
||||
}
|
||||
}
|
||||
|
||||
tasks.jar {
|
||||
enabled = false
|
||||
}
|
||||
|
||||
kapt {
|
||||
keepJavacAnnotationProcessors = true
|
||||
}
|
||||
|
||||
@ -33,10 +33,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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>
|
||||
} />
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
};
|
||||
0
frontend/src/api/reservation/reservationAPIV2.ts
Normal file
0
frontend/src/api/reservation/reservationAPIV2.ts
Normal 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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
288
frontend/src/css/my-reservation-v2.css
Normal file
288
frontend/src/css/my-reservation-v2.css
Normal 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);
|
||||
}
|
||||
175
frontend/src/css/reservation-v2.css
Normal file
175
frontend/src/css/reservation-v2.css
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -49,7 +49,7 @@ const AdminThemePage: React.FC = () => {
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
const deleteTheme = async (id: number) => {
|
||||
const deleteTheme = async (id: string) => {
|
||||
if (!window.confirm('정말 삭제하시겠어요?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -62,7 +62,7 @@ const AdminTimePage: React.FC = () => {
|
||||
.catch(handleError);
|
||||
};
|
||||
|
||||
const deleteTime = async (id: number) => {
|
||||
const deleteTime = async (id: string) => {
|
||||
if (!window.confirm('정말 삭제하시겠어요?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -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('대기 중인 예약을 거절했어요.');
|
||||
|
||||
337
frontend/src/pages/v2/MyReservationPageV2.tsx
Normal file
337
frontend/src/pages/v2/MyReservationPageV2.tsx
Normal 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;
|
||||
156
frontend/src/pages/v2/ReservationStep1Page.tsx
Normal file
156
frontend/src/pages/v2/ReservationStep1Page.tsx
Normal 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;
|
||||
118
frontend/src/pages/v2/ReservationStep2Page.tsx
Normal file
118
frontend/src/pages/v2/ReservationStep2Page.tsx
Normal 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;
|
||||
44
frontend/src/pages/v2/ReservationSuccessPage.tsx
Normal file
44
frontend/src/pages/v2/ReservationSuccessPage.tsx
Normal 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;
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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", "시스템에 일시적인 오류가 발생했어요. 잠시 후 다시 시도해주세요."),
|
||||
}
|
||||
|
||||
@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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?
|
||||
}
|
||||
@ -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)
|
||||
@ -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?
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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?
|
||||
}
|
||||
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>>
|
||||
}
|
||||
@ -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>>
|
||||
}
|
||||
@ -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", "결제 대기 중인 예약이 아니에요."),
|
||||
;
|
||||
}
|
||||
|
||||
@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
)
|
||||
@ -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" }
|
||||
|
||||
@ -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', '서울특별시', '종로구', '청운동'),
|
||||
|
||||
@ -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)
|
||||
);
|
||||
|
||||
@ -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)
|
||||
);
|
||||
|
||||
4
src/main/resources/test.http
Normal file
4
src/main/resources/test.http
Normal file
@ -0,0 +1,4 @@
|
||||
### GET request to example server
|
||||
POST localhost:8080/savetest
|
||||
|
||||
###
|
||||
@ -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\""
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user