diff --git a/.env.vault b/.env.vault deleted file mode 100644 index 16c833b..0000000 --- a/.env.vault +++ /dev/null @@ -1,25 +0,0 @@ -#/-------------------.env.vault---------------------/ -#/ cloud-agnostic vaulting standard / -#/ [how it works](https://dotenv.org/env-vault) / -#/--------------------------------------------------/ - -# development -DOTENV_VAULT_DEVELOPMENT="016ecrqOTXTf2xp5G3UeOMaqnKYqYFoxt//YZHQSTOybSMpHft7uxfMRO706U/rr5gWfAy4oZcAVvO25H3IeRBlBYiGrQcb5/clx8vJR/M1H/QKGu30yynNQ0mxbiI7m9qn0YZ6J3iUcPuohKbR4FQTJdQiRwDOVOmyz0hZWHyNLqW03BpCR8aokt8Ogy7/1kYi3WBM45irewaYcCH51bi374bUzJsp9tOlxPZZmDBkvMmkG07JBKkxI/pGGX1eBiFBsYQRkfryVqSh2x3yLhK7UtE521iW/P/IAexThwLoHPmjtm0pcMnKyG3s7sUDyvDGs6Uz6ncz0ki2cs84SHIW6BSyrKmO7j2GRmo5CM/0oWU0xyJ1aA4fW1c3+VTgMY1t86q+mkRhOAcWrrNshRJytaEyvz1JTEZXDYqW6NADoeuinW4qP1sD11+tYslkooNZriaWnCbmRHNo/LjRWQW1KbKkQW+HiDRIDR5LPSx3w4Bm82ssBBTml2TfYo4/eL8kSq8MQVhGQ5du587J+efcDmNO7ZpkFZKhXXelv7ojh1shjzqomlg9lLcdt2RnSV+vEvViKGzwlNxiv1epYHMReR15HefCcLvvFeSWhOYxdGrfRqgaqaYRLV163eXD2Ux/4Kh+jRj/Asu83t8ARL9nC7W1gl8Xn+VvwboxUtm8sg/Rzb1EO6rzjXWoRZLehD3FGoH8jFcQu3D1WO3vqJpbbJgFeSRRqXzJLTQ207PMM3ABcZvNS0JDn+ia6Xo+GS2HBjUcJCYGJ+4OpBvfrIzSqKSKPWJ6GSeEd/4rA6JgScaaDFTbVHfPLdI7arAcjzdBArMqm8SiPFtKWgx6ZNW1cAnvYhd0Siwi1PhWrwRxTTTEJ8RNuQqZGSELJ46LAnTp3DCYIMTos0diJxVia/zHq4T2K1q9MsFdFPnbiciQcL5+/aDHOcvRu/t7xWVrjFAEBN9iQnVPy6DT3i+jpeCkTCxEfFX+k0k+CPi432zHVI2dtL5LWOwcSYrKTam75IBWPi2bx3sn0plJUfefE8N9KWHFenFWE2QGM/zKQOr7Hhc4R9j5XGYRgcUnDQIjzPIvpwJAKFOYo8K0+BHq9wU5GKj2rXb6eM/6mDefjEf/N1OkvMr+OzRNWVAO+0hqKfaEs063hCpUEQuimjw2SSXCXLK7wme4Pee6AQoJX2JmgKLkIcw7i7+qxBD8ySJS5gZllTwXekSoTIkSr9I2ChnVudYbbFQ+BdtAjW+YcMthmsjdloCTMQHFsq2P8E3TkrwaNuEyQ3Y/IUoH5gfeiiP8pWcvWbU+g11xFiYaoumiuhjG/0xTsqElzsQx/sNqz6PvEoHptbLog6uULsjFVQ4aIzBMzn1H0ECODrAIaEmq0MixNSWoj9U8TzaotzIfDtsWS+gXKSXXy9eK0IXSMz58umrDgEzSLdYBPlDibnMPPdsddnxDQgYqL5SmL6M4n3imsHi/TZSexfFhYXGPlN/m5Pql/L52O" -DOTENV_VAULT_DEVELOPMENT_VERSION=2 - -# ci -DOTENV_VAULT_CI="v0ml09YaCe/YE2ZEen2/FzPkSr4aMiwVJix2tvQH5PmuGacyPchMwL9nHzZP+jy/rsZaY61UIRSUDd6FZzhn3pmEsx/Ml6xNmknEpnLc85FuKg/54n4LMzK9sBxjkPKJfacFGBEBL+vdwkEzbverEWV3sf8ChPnMJDPXhKizowfkjMeaIHRI0GBncbLDHGJis5PtfEWmMmwsKSEV9ZgF2X0pj5FyiLYwFbYVwr0cwT57qS0w+hvF4/rIZ7SXJNWEKQ==" -DOTENV_VAULT_CI_VERSION=2 - -# staging -DOTENV_VAULT_STAGING="WYlLotFMoE4cFynsXOWH3dr+QaL8RcckPnljRMD49o4xCyMU3+9BsaKqdqthg4YE7eCs4pd7tSpGF+ipTZQc5mHm8uVnFm9Srxmt10Z+rSNbi90Blga3MTUN79MYuPsBXNJG/LjZa7nM6WQrBFsHxOSZgMCHfVowKfpiMHjCjhWAR3iCxEFQAvsBAS81xE0C278WVHkU+vwAEGgnQqTH6tKF9f2R1hNcUgJN/oX35agxCCzVBlNb325A1Tfuiueq9w==" -DOTENV_VAULT_STAGING_VERSION=2 - -# production -DOTENV_VAULT_PRODUCTION="xtpJ5kfM2+GhpsxC6u7HWaoDeRuKgjcoU+y3Tt9DYc74kDVGDTqRt53TCOa9L5K1+0sSrtB5M0BVsxJy1iBRwZmy4Nt+kZcO4IH+iF77JI+gxwXW1jgUDn7kfTh5iICJjATNdCq0yL/dRPeNxHSJP5Vo6bejSMmcjpH5avZ0TusvX/vVRyP4ICwj8GHVeXvrce7CK8vx3RYPWuc4MMPs2sAEue43MnFXwMDoEBHojfldr90vRRj16UWiwoInXP+AFQ==" -DOTENV_VAULT_PRODUCTION_VERSION=2 - -#/----------------settings/metadata-----------------/ -DOTENV_VAULT="vlt_002f3f39fbfe3880ff24a60084d423d6c89b54b3298d09591d5151e116f22b50" -DOTENV_API_URL="https://vault.dotenv.org" -DOTENV_CLI="npx dotenv-vault@latest" diff --git a/README.md b/README.md index 017f1ee..8cf90f4 100644 --- a/README.md +++ b/README.md @@ -78,3 +78,16 @@ Xem `src/config/` - mỗi feature có config riêng: `auth.config.js`, `payment. ## API Docs Postman collection trong `postman/` folder. + +--- + +## Sequence Diagrams + +Document chi tiết các flows trong hệ thống: [docs/diagrams/sequence/](docs/diagrams/sequence/) + +| Diagram | Mô tả | +|---------|--------| +| [01-auto-refund.md](docs/diagrams/sequence/01-auto-refund.md) | User request → Admin approve → Payment gateway → Complete | +| [02-date-change.md](docs/diagrams/sequence/02-date-change.md) | Request → OTP → Payment → Approve → Release/Reserve seats | +| [03-flight-combo.md](docs/diagrams/sequence/03-flight-combo.md) | Mixed search: direct, 1-stop, 2-stop + roundtrip | +| [04-flight-season.md](docs/diagrams/sequence/04-flight-season.md) | Season/Holiday/Override detection với caching | diff --git a/README1.md b/README1.md index f0ce44b..61853e6 100644 --- a/README1.md +++ b/README1.md @@ -1,165 +1,572 @@ -# Changes Log - Date Change Payment & Flight Combo +# README ghi chú thay đổi gần đây -## 1. Date Change with Payment +File này là bản note nội bộ, viết theo kiểu dễ đọc để ai vào sau cũng hiểu nhanh là hệ thống vừa được cập nhật gì, tại sao cập nhật, và hành vi hiện tại đang như thế nào. -### Flow tổng quan +--- -``` -1. User request change - POST /api/date-changes/bookings/:bookingCode/change-flight - → Tạo request + gửi OTP email - → Status: pending_otp - -2. User confirm với OTP - POST /api/date-changes/confirm - → Verify OTP - → Tính chênh lệch giá - - ┌─ price_difference > 0 (khách trả thêm) - │ → Status: pending_payment - │ → Tạo payment riêng - │ - ├─ price_difference = 0 - │ → Auto approve luôn - │ - └─ price_difference < 0 (giá mới rẻ hơn) - → Auto approve luôn (không hoàn tiền tự động) - → Admin duyet neu > 1M -``` +## 1. Date change: V1 đã chốt theo hướng gọn, rõ trạng thái và không làm quá tay -### Tính chênh lệch giá +Phần date change hiện tại nên hiểu là **V1 đã đủ dùng** và đã được dọn lại để tránh ôm thêm logic/admin payload không cần thiết. Mục tiêu của đợt này không phải mở rộng feature, mà là chốt một flow đổi ngày bay rõ ràng, kiểm soát được trạng thái, và đủ cho user + admin vận hành. -```javascript -priceDifference = (newFlight.base_price * soVe) - booking.total_price +### Phạm vi V1 hiện tại -// Ví dụ: -booking cu = 3,000,000 VND (3 vé) -chọn flight mới = 1,800,000 VND/ghế -→ priceDifference = 1,800,000 * 3 - 3,000,000 = +2,400,000 -→ Khách phải trả thêm 2.4M -``` +V1 hiện chỉ support **đổi ngày cho `outbound` leg**. -### APIs mới +Điểm quan trọng: +- đã có `flight_leg` để định danh leg đang đổi +- current allowed value cho V1 là `outbound` +- uniqueness active request được tính **theo từng leg**, không còn theo cả booking kiểu quá rộng +Nói ngắn gọn: V1 này đã chuẩn bị cấu trúc đúng để sau này mở rộng, nhưng **hiện tại chỉ chốt outbound-only** chứ chưa làm return-leg flow. + +### Flow đang chạy + +**Bước 1: user tạo yêu cầu đổi ngày** +- Endpoint: `POST /api/date-changes/bookings/:bookingCode/change-flight` +- Input chính: `new_flight_id`, `new_seat_class`, `reason`, `flight_leg` (V1 hiện chỉ nhận `outbound`) +- Hệ thống sẽ: + - check booking có hợp lệ không + - check booking đang ở trạng thái `confirmed` + - check user có quyền với booking đó không + - check leg có được phép đổi không + - check đã có request active cho đúng `flight_leg` này chưa + - check chuyến mới và hạng ghế mới có hợp lệ không + - tính chênh lệch giá theo phần vé đang đổi + - tạo request với status `pending_otp` + - gửi OTP về email booking + +**Bước 2: user xác thực OTP** +- Endpoint: `POST /api/date-changes/confirm` +- Input: `email`, `otp`, `requestCode` +- Sau khi OTP đúng, hệ thống chia flow như sau: + - nếu `price_difference > 0` và config yêu cầu thu thêm tiền thì chuyển sang `pending_payment` + - nếu không cần thu thêm thì chuyển sang `pending` + +Điểm quan trọng của V1 mới: +- **không auto approve ngay sau OTP** +- OTP chỉ là bước xác nhận request hợp lệ để đi tiếp vào flow xử lý chính thức + +### Cách tính chênh lệch giá hiện tại + +Hiện tại service đang so sánh phần **giá vé của leg đang đổi theo số ghế cần xử lý**, thay vì lấy cả `booking.total_price` để trừ. + +Công thức đang dùng về bản chất là: + +```javascript +newTotalPrice = newFlight.base_price * seatsNeeded; +oldPrice = booking.base_price * seatsNeeded; +priceDifference = newTotalPrice - oldPrice; ``` -POST /api/date-changes/bookings/:code/change-flight - Request change -POST /api/date-changes/confirm - Confirm với OTP -POST /api/date-changes/:code/payment - Tạo payment cho chênh lệch -GET /api/date-changes/:code/payment - Xem payment status -POST /api/date-changes/:code/payment/cancel - Hủy payment -``` -### OTP bắt buộc +Ý nghĩa của cách này: +- chỉ so sánh phần vé đổi thực tế +- không trộn baggage / ancillary / chiều chưa đổi +- phù hợp hơn với rule của V1 outbound-only + +### Sau khi OTP đúng thì đi nhánh nào? + +#### Trường hợp 1: `price_difference > 0` +Khách phải trả thêm. + +Hệ thống sẽ: +- update request sang `pending_payment` +- trả về `requires_payment: true` +- chờ user tạo payment riêng cho khoản chênh lệch + +Endpoint tiếp theo: +- `POST /api/date-changes/:requestCode/payment` +- `GET /api/date-changes/:requestCode/payment` +- `DELETE /api/date-changes/:requestCode/payment` + +#### Trường hợp 2: `price_difference <= 0` +Hệ thống sẽ: +- update request sang `pending` +- trả về `requires_payment: false` +- chờ admin xử lý + +Điểm cần nhớ ở đây là: +- **V1 hiện tại không còn nhánh auto approve sau OTP** +- kể cả không cần trả thêm thì request vẫn vào hàng chờ xử lý chính thức + +### Payment cho date change đã tách riêng + +Phần thanh toán phụ phí đổi ngày có flow riêng, không dùng chung với payment booking gốc. + +Hiện service đã xử lý: +- chỉ cho tạo payment khi request đang ở `pending_payment` +- không cho tạo payment nếu request không cần trả thêm +- lock request để tránh race condition +- có thể reuse payment cũ nếu payment đó vẫn còn hiệu lực và chưa terminal +- sau khi payment được xác nhận thành công thì service set payment `SUCCESS`, đưa request về `pending`, rồi gọi flow approve thực tế để đổi ghế/chuyến + +Các method hiện support theo config: +- `BANK_QR` +- `MOMO` +- `PAYPAL` + +Với nhánh `PAYPAL`: +- `POST /api/date-changes/:requestCode/payment` với `payment_method = "PAYPAL"` sẽ tạo payment code dạng `PAY-DC-*` +- service gọi provider PayPal để tạo order +- response payment sẽ mang theo `gateway_response` / instruction để frontend redirect user sang PayPal checkout +- nếu payment PayPal cũ còn pending và vẫn còn checkout URL hợp lệ thì service có thể reuse thay vì tạo payment mới + +### Nếu khách phải trả thêm thì flow hiện tại đi như nào? + +Đây là nhánh quan trọng nhất của date change V1 khi chuyến mới đắt hơn. + +Flow thực tế: +1. User tạo request đổi ngày → request được tạo với `status = pending_otp` +2. System trả về `price_difference` +3. User verify OTP qua `POST /api/date-changes/confirm` +4. Nếu `price_difference > 0` và config `chargeIfPositive` bật, request chuyển sang `pending_payment` +5. User gọi `POST /api/date-changes/:requestCode/payment` với `payment_method = "PAYPAL"` +6. Khi payment được xác nhận thành công, request được đẩy về `pending` +7. Sau đó flow approve thực tế sẽ đổi ghế/chuyến và hoàn tất cập nhật booking + +Điểm cần nhớ: +- nhánh này là **thu thêm trước rồi mới hoàn tất đổi chuyến** +- request không nhảy thẳng sang `approved` ngay sau OTP +- payment thành công là điều kiện để request quay lại hàng chờ xử lý tiếp + +### Nếu khách được hoàn tiền thì tiền được xử lý lúc nào? + +Đây là phần dễ hiểu nhầm nhất nếu chỉ nhìn `price_difference` ở lúc tạo request. + +Hiện logic refund của date change **không phải** là cứ `price_difference < 0` thì lập tức tạo refund ngay sau OTP. + +Thực tế flow đang là: +- sau OTP, nếu không cần thu thêm thì request đi vào `pending` +- đến lúc `approveDateChange` chạy thật, service mới: + - release ghế cũ + - reserve ghế mới + - hủy ancillary outbound không còn phù hợp (trừ insurance) + - cập nhật lại `booking.total_price` + - tính phần hoàn thực tế + +Công thức refund thực tế ở bước approve đang là: +- `ticketRefund = max(0, -ticketDiff)` +- `refundableAmount = ticketRefund + cancelledAncillaryTotal` + +Tức là khoản hoàn có thể đến từ 2 nguồn: +1. **chênh lệch vé giảm** nếu chuyến mới rẻ hơn chuyến cũ +2. **dịch vụ outbound bị hủy** do đổi chuyến, ví dụ baggage/ancillary không còn áp dụng nữa + +Nếu `refundableAmount > 0` thì system sẽ: +- auto tạo một record trong bảng `refunds` +- gắn `refund_type = partial_leg` +- link refund đó vào `date_change_requests.related_refund_id` +- để `status = pending` cho refund này + +Nói ngắn gọn: +- **thu thêm** được xử lý ở nhánh payment trước khi hoàn tất đổi chuyến +- **hoàn tiền** chỉ được quyết toán sau khi approve đổi chuyến và sau khi biết chính xác ticket diff + ancillary bị hủy + +### Có trường hợp vừa đổi chuyến vừa phát sinh refund không? -Mọi date change đều cần verify OTP trước khi xử lý. +Có. + +Ví dụ thực tế: +- vé chuyến mới đắt hơn ở phần ticket +- nhưng khi approve thì outbound ancillary bị hủy tạo ra một khoản hoàn +- hoặc vé mới rẻ hơn và lại còn có ancillary bị hủy + +Lúc đó system sẽ tính trên tổng thực tế sau approve: +- nếu có khoản hoàn dương thì tạo refund record +- nếu phần vé tăng sau khi trừ ancillary vẫn còn dương thì admin notes có thể được ghi thêm thông tin phụ thu + +Code hiện tại còn có bước ghi note nội bộ kiểu: +- `PHỤ THU: ... VND chưa thu từ khách` + +Điều này cho thấy approve flow đang cố phản ánh **net effect cuối cùng** của việc đổi ngày, chứ không chỉ nhìn duy nhất vào một con số ở bước request ban đầu. + +### Admin flow trong V1 + +Admin hiện chỉ cần làm đúng phần cốt lõi: +- xem danh sách request đang chờ xử lý +- xem chi tiết request +- approve / reject request -### Auto vs Manual +Để giữ V1 gọn, phần response admin đã được trim lại: +- vẫn giữ `payment_status` để admin biết request đã thanh toán hay chưa +- bỏ `queue_summary` +- bỏ `approved_change_count` khỏi các query trả dữ liệu admin/detail +- pending list chỉ giữ các field đủ dùng cho vòng xử lý cơ bản + +### Ý chính cần nhớ về date change V1 + +- V1 hiện tại **đã đủ** và nên freeze scope tại đây +- Chỉ support `outbound` leg +- Có OTP bắt buộc +- Có tách nhánh `pending_payment` riêng khi khách phải bù tiền +- Payment success chỉ chuyển request về `pending`, chưa approve +- Admin là bước duyệt cuối +- Schema và unique index đã được chỉnh theo `flight_leg` +- Phần payload/admin response đã được dọn bớt cho gọn V1 -| price_difference | Action | -|------------------|--------| -| > 0 | Tạo payment, khách trả thêm | -| = 0 | Auto approve | -| < 0, \|value\| < 1M | Auto approve (không hoàn tiền tự động) | -| < 0, \|value\| >= 1M | Admin duyet | +### Những gì chưa làm trong V1 và nên để sau + +Các phần dưới đây không nên nhét thêm vào V1 nữa: +- return-leg date change +- auto-approve phức tạp +- analytics/reporting cho admin +- mở rộng thêm field response nếu frontend chưa thực sự cần +- các phần polish không ảnh hưởng đến correctness của flow --- -## 2. Flight Combo - One Way & Round Trip +## 2. Flight combo: phần tìm chuyến ghép đã sạch logic hơn và đồng bộ pricing hơn -### Tìm kiếm multi-leg +Phần `flight-combo` hiện tại không chỉ là tìm chuyến nối đơn giản nữa, mà đã được dọn khá rõ về validation, flow query và cách xếp hạng kết quả. -API: `mixedSearch()` trong `flight-combo.service.js` +### Những loại combo đang có -**One-way:** Direct (0 stop) + 1-stop + 2-stop +Hiện service đang hỗ trợ: +- **direct**: bay thẳng +- **1-stop**: một điểm dừng +- **2-stop**: hai điểm dừng +- **roundtrip combinations**: ghép outbound với return -``` -A → B (direct) -A → X → B (1 stop) -A → X → Y → B (2 stop) -``` +Tức là với một request tìm kiếm, hệ thống sẽ gom hết các option 1 chiều trước, rồi nếu có `return_date` thì build thêm tổ hợp khứ hồi. -**Round-trip:** Cross-product outbound × return +### Validation đầu vào đã chặt hơn -``` -Outbound: A → B -Return: B → A -→ Kết hợp tất cả options -→ Limit: 30 combos mỗi direction để tránh quá nhiều kết quả -``` +Trước khi search, service đang validate khá rõ: +- bắt buộc có `from`, `to`, `outbound_date` +- ngày phải đúng format `YYYY-MM-DD` +- `return_date` không được sớm hơn `outbound_date` +- `seat_class` chỉ nhận `economy`, `business`, `first` +- `sort_by` chỉ nhận `recommended`, `price`, `duration` +- `max_stops` chỉ từ `0` đến `2` +- `infants` không được nhiều hơn `adults` -### Ranking logic +Điểm này nhỏ nhưng quan trọng vì giúp response đỡ bị lệch và giảm lỗi khó debug ở tầng query. -```javascript -score = price * 0.4 - + duration * 0.3 - + layover * 0.2 - - bonus_nếu_multi_airline - -// Ưu tiên: -1. Giá rẻ -2. Thời gian bay ngắn -3. Thời gian chờ hợp lý (45 phút - 8 tiếng) -``` +### Giá combo giờ đã ăn theo season logic -### Layover rules +Mỗi leg khi format ra hiện tại đều có: +- `season_info` +- dynamic price đã nhân với season multiplier +- `seat.total_price` theo số lượng hành khách -``` -MIN: 45 phút -MAX: 8 tiếng (480 phút) -``` +Nghĩa là combo pricing bây giờ đã đồng bộ với pricing logic chung của flight search. Không còn kiểu combo tính một đường, season tính một đường. -### Response structure - -```json -{ - "one_way_options": [ - { - "stops": 0, - "total_price": 2400000, - "total_duration_minutes": 180, - "legs": [ - { - "flight_id": 1, - "airline": { "code": "VN", "name": "Vietnam Airlines" }, - "departure": { "code": "HAN", "time": "..." }, - "arrival": { "code": "SGN", "time": "..." } - } - ] - } - ], - "roundtrip_combinations": [...] -} -``` +### Flow tìm one-way đang như nào? -### Giá vé theo loại khách +Service sẽ chạy theo `max_stops`: +- nếu cho phép `0 stop` thì lấy direct +- nếu cho phép `1 stop` thì lấy thêm 1-stop +- nếu cho phép `2 stop` thì lấy thêm 2-stop -``` -Người lớn: base_price * 1 -Trẻ em: base_price * 0.75 -Em bé: base_price * 0.10 -``` +Sau đó gom tất cả lại rồi sort theo lựa chọn của user. + +### Phần 1-stop đã được dọn + +Flow hiện tại của 1-stop dễ hiểu hơn: +- query tất cả first legs từ điểm đi +- lấy danh sách airport trung gian +- query second leg theo từng airport trung gian +- ghép lại nếu layover hợp lệ + +Tức là bây giờ logic đi theo hướng build dữ liệu thực sự cần dùng, thay vì để các đoạn query placeholder hoặc query thừa làm nhiễu. + +### Roundtrip đang build như nào? + +Nếu có `return_date`: +- outbound one-way options được reuse +- return options được query riêng +- sau đó service mới cross-product để tạo `roundtrip_combinations` + +Đồng thời đang có giới hạn số lượng option trước khi ghép để tránh nổ quá nhiều tổ hợp. Đây là kiểu fix thực dụng, không fancy nhưng rất cần vì roundtrip combo tăng số lượng rất nhanh. + +### Ranking đang ưu tiên điều gì? + +Hiện tại có 3 cách sort: +- `price`: rẻ nhất lên trước +- `duration`: tổng thời gian ngắn nhất lên trước +- `recommended`: qua hàm chấm điểm riêng + +Với `recommended`, score hiện đang dựa trên mấy yếu tố chính: +- tổng giá +- tổng duration +- tổng layover +- bonus/penalty nhẹ theo cấu trúc airline và độ hợp lệ của layover + +Nói đơn giản: service đang cố cân bằng giữa **rẻ**, **đỡ lâu**, và **không quá bất tiện** chứ không chỉ sort một chiều theo giá. + +### Ý chính cần nhớ về flight combo + +- Đã hỗ trợ direct, 1-stop, 2-stop, roundtrip +- Validation đầu vào tốt hơn +- Combo price đã đi cùng season pricing +- Flow query 1-stop và roundtrip rõ ràng hơn +- Ranking có consistency hơn thay vì chỉ sort sơ sài --- -## 3. Flight Brand/Airline +## 3. Season: logic hiện tại đã thành một flow pricing khá đầy đủ, có cache, holiday rules âm lịch và override theo ngày -Đơn giản: JOIN airlines là có đủ. +Phần season hiện tại không còn chỉ là mấy mốc mùa cao điểm đơn giản nữa. Logic thực tế bây giờ là một flow resolve thống nhất để backend quyết định **một ngày bay cụ thể** đang thuộc diện nào, lấy multiplier bao nhiêu, và trả metadata gì ra cho client. -```sql -SELECT f.*, al.code, al.name, al.logo_url -FROM flights f -JOIN airlines al ON al.id = f.airline_id -``` +Điểm quan trọng nhất là toàn bộ hệ thống đang cố dùng **một nguồn season info chung** cho pricing, search, combo, detail và price alert để tránh mỗi nơi tính một kiểu. + +### Thứ tự ưu tiên hiện tại -Frontend nhận: -```json -{ - "airline": { - "code": "VN", - "name": "Vietnam Airlines", - "logo_url": "..." - } -} +Service đang resolve theo đúng thứ tự này: + +```text +override -> holiday_rules -> holidays -> season -> normal(1.0) ``` +Cụ thể: +1. **Override**: admin chỉnh tay cho đúng một ngày cụ thể trong `price_overrides`, priority cao nhất +2. **Holiday rule**: rule động trong `holiday_rules`, có thể resolve theo solar hoặc lunar calendar +3. **Holiday cố định**: dữ liệu trong bảng `holidays` +4. **Season period**: dữ liệu trong `season_periods` +5. Không match gì thì coi là normal/off-peak, multiplier = `1.0` + +Điểm này quan trọng vì nếu đã có override thì holiday/season phía dưới **không còn tác dụng** cho ngày đó nữa. + +### Các nguồn dữ liệu season hiện đang dùng + +Hiện tại season service đang đọc từ 4 nguồn dữ liệu chính: +- `season_periods`: mùa cao điểm theo khoảng ngày-tháng +- `holidays`: ngày lễ kiểu fixed-date hoặc date có year cụ thể +- `holiday_rules`: rule lễ động, có thể resolve theo năm +- `price_overrides`: override một ngày cụ thể do admin tạo + +Nói ngắn gọn: +- `season_periods` giải quyết bài toán “mùa” +- `holidays` giải quyết bài toán “ngày lễ tĩnh” +- `holiday_rules` giải quyết bài toán “ngày lễ động”, đặc biệt hữu ích cho Tết âm lịch +- `price_overrides` là lớp manual control mạnh nhất hiện tại + +### Holiday rules là phần mới quan trọng nhất + +Đây là chỗ season logic hiện tại đã tiến hơn bản cũ khá nhiều. + +`holiday_rules` cho phép backend định nghĩa một ngày lễ theo rule thay vì phải seed thủ công từng ngày cho từng năm. + +Hiện service support: +- `calendar_type = solar` +- `calendar_type = lunar` +- `offset_days` để dịch trước/sau ngày anchor +- `priority` để xử lý khi nhiều rule rơi vào cùng một ngày +- `group_key` để gom nhóm cùng một dịp lễ nếu cần dùng ở tầng trên + +Ý nghĩa thực tế: +- với lễ dương như 01/01 hoặc 30/04 thì có thể resolve trực tiếp theo solar +- với Tết âm lịch thì có thể resolve từ lịch âm sang ngày dương bằng `solarlunar` +- nếu cần “cao điểm trước Tết 2 ngày” hay “sau lễ 1 ngày” thì dùng `offset_days` + +Tức là nếu sau này muốn làm Tết đúng business logic thì hướng chuẩn hơn là seed `holiday_rules`, không phải hardcode trong code, cũng không phải mỗi năm lại nhập tay toàn bộ ngày lễ vào `holidays`. + +### Season period hiện đang match như nào? + +`season_periods` hiện không bị giới hạn ở các khoảng nằm gọn trong một năm dương lịch. Service đã support cả season **cross-year**. + +Ví dụ kiểu: +- bắt đầu tháng 12 năm nay +- kết thúc tháng 1 năm sau + +thì vẫn match đúng. + +Cách làm hiện tại là service build season window theo `referenceYear - 1` và `referenceYear`, rồi check xem `departureDate` có nằm trong một trong các window đó không. + +Điểm này giúp các mùa kiểu cuối năm - đầu năm hoạt động đúng mà không cần hack dữ liệu. + +### Nếu có nhiều season cùng match thì chọn cái nào? + +Nếu một ngày rơi vào nhiều season periods cùng lúc, hệ thống không lấy đại bản ghi đầu tiên. + +Nó sẽ chọn theo rule: +1. `priority` cao hơn thắng +2. nếu `priority` bằng nhau thì `multiplier` cao hơn thắng + +Nghĩa là admin/data layer có thể chồng season lên nhau, miễn là biết season nào cần priority cao hơn. + +### `getSeasonInfo()` hiện trả về gì? + +`getSeasonInfo(departureDate)` là hàm trung tâm của toàn bộ flow. + +Nếu match được override / holiday / season thì response thường có các thông tin như: +- `name` +- `multiplier` +- `reason` +- `type` (`override`, `holiday`, `season`) +- `daysUntil` +- các flag như `isPeak`, `isHoliday`, `isOverride`, `isApproaching`, `isInside` + +Ngoài ra còn có metadata phụ thuộc loại match: +- holiday từ rule có thể có thêm `calendar_type`, `rule_type`, `group_key` +- season có thể có `approachingInfo` + +Nếu không match gì thì `getSeasonInfo()` trả `null`. + +Điểm này dẫn tới một rule rất thực dụng ở tầng pricing: +- có info thì lấy `info.multiplier` +- không có info thì mặc định `1.0` + +### `isPeak`, `isHoliday`, `isOverride` hiện nên hiểu như nào? + +- `isOverride`: ngày đó đang bị override tay bởi admin +- `isHoliday`: ngày đó match holiday hoặc holiday rule +- `isPeak`: hiện đang hiểu khá thực dụng là multiplier đủ cao để coi là peak, hoặc holiday thì luôn được coi là peak + +Cụ thể: +- với override: `isPeak = multiplier >= 1.20` +- với season: `isPeak = multiplier >= 1.20` +- với holiday/rule holiday: service đang trả `isPeak = true` + +Tức là holiday đang được coi là một dạng peak day về mặt messaging/pricing context. + +### `isApproachingPeakSeason()` đang làm gì? + +Đây là phần README cũ chưa nói rõ. + +Service hiện có thêm khái niệm **approaching peak season** để phục vụ alert/messaging. + +Logic này: +- default threshold là `30` ngày +- nếu ngày bay đang nằm trong season thì trả `isApproaching: true` và `isInside: true` +- nếu hiện tại đang ở gần ngày bắt đầu một season sắp tới, và ngày bay nằm từ điểm season bắt đầu trở đi, service có thể trả trạng thái “sắp vào mùa cao điểm” + +Khi match season thường, `getSeasonInfo()` có thể đính kèm: +- `isApproaching` +- `isInside` +- `approachingInfo.reason` +- `approachingInfo.daysUntilSeasonStart` + +Điểm quan trọng: đây không phải một loại `type` riêng, mà là metadata bổ sung quanh một season match. + +### Cache của season service hiện tại + +Season service hiện đã có cache trong memory để giảm query DB lặp lại: +- `seasonCache` +- `holidayCache` +- `holidayRuleCache` +- `overrideCache` + +TTL hiện tại là khoảng `1 giờ`. + +Ý nghĩa thực tế: +- search flight nhiều lần sẽ không phải query full season tables liên tục +- override theo ngày cũng có cache riêng theo `dateStr` +- khi admin tạo/sửa/xóa override thì controller có gọi clear cache override để tránh stale data quá lâu +- service cũng có `refreshCache()` để clear toàn bộ cache season/holiday/rule/override + +Nói đơn giản: logic này thiên về performance đủ dùng chứ chưa phải distributed cache phức tạp. + +### Override hiện custom được tới đâu? + +Nếu hỏi admin hiện “custom season” được gì thì câu trả lời thực tế là: +- custom mạnh nhất đang nằm ở `price_overrides` +- admin có thể set multiplier cho **một ngày cụ thể** +- override này thắng toàn bộ holiday/season bên dưới +- create/update/delete override đều có clear override cache + +Đây chính là cơ chế phù hợp nhất cho các case test nhanh, hotfix pricing, hoặc dịp đặc biệt chưa kịp seed rule chính thức. + +Nhưng cũng cần nói rõ là: +- override hiện chỉ target theo **date** +- chưa target theo route, airline, cabin, campaign hay segmentation nâng cao + +Tức là đây là custom control hữu ích, nhưng chưa phải pricing rule engine tổng quát. + +### Season logic hiện đang đi vào pricing như nào? + +Flow hiện tại về bản chất là: +1. lấy `seasonInfo` hoặc `seasonMultiplier` +2. đưa multiplier đó vào dynamic pricing chung +3. trả kết quả giá cuối cùng + metadata season cho client + +Với `flight.service`: +- `getSeasonMultiplier(departureTime)` được đưa vào `applyDynamicPricing(...)` +- `formatFlights(...)` đồng thời gắn luôn `season_info` vào từng flight +- `seat.base_price`, `price_breakdown`, `seat.total_price` đều đã phản ánh multiplier mùa tương ứng + +Với `flight-combo.service`: +- mỗi leg trong combo cũng có `season_info` +- combo pricing hiện đã đi cùng season logic, không còn là nhánh giá tách rời + +Nói ngắn gọn: season bây giờ không chỉ để “ghi chú là đang mùa cao điểm”, mà thực sự đã đi vào số tiền trả ra. + +### Season info hiện đang được trả ra ở đâu? + +Hiện season metadata đang được dùng lại ở nhiều chỗ: +- `GET /api/flights/search` +- `GET /api/flights/combo` +- các response flight detail +- `GET /api/flights/:id/price-analysis` +- `price_alert` / detailed analysis + +Điểm tốt của kiến trúc hiện tại là client không phải tự đoán mùa từ ngày bay. Backend đã resolve sẵn và trả `season_info` cùng dữ liệu giá. + +### Price alert hiện đang phụ thuộc vào season ra sao? + +`price-alert.service` cũng reuse season service thay vì tự tính riêng. + +Hiện alert logic dùng season theo mấy cách chính: +- lấy `seasonMultiplier` để tính breakdown giá +- lấy `seasonInfo` để build message/recommendation +- dùng `shouldAlert(departureDate)` để quyết định có nên alert mạnh không + +Rule trong `shouldAlert()` hiện là: +- alert nếu là holiday +- hoặc là peak +- hoặc là approaching peak với `multiplier >= 1.15` + +Nghĩa là season không chỉ ảnh hưởng giá, mà còn ảnh hưởng cả cách backend giải thích cho người dùng rằng giá đang cao vì sao. + +### Tết hiện tại nên hiểu đúng như nào? + +Đây là phần rất dễ hiểu nhầm nếu chỉ nhìn README cũ. + +Hiện code **đã có khả năng support Tết âm lịch** thông qua `holiday_rules` + `calendar_type = lunar` + thư viện `solarlunar`. + +Nhưng khả năng support trong code **không đồng nghĩa** với việc môi trường hiện tại chắc chắn đã có dữ liệu Tết trong DB. + +Nên cần tách 2 ý: +- **về code**: đã hỗ trợ resolve Tết âm lịch +- **về dữ liệu DB**: có thể vẫn chưa seed `holiday_rules` hoặc `holidays` cho Tết + +Vì vậy nếu DB chưa có dữ liệu Tết thì cách test nhanh hợp lý nhất vẫn là dùng `price_overrides` để mô phỏng. + +### Ý chính cần nhớ về season hiện tại + +- Hệ thống không còn chỉ có `override > holiday > season`, mà thực tế là `override > holiday_rules > holidays > season > normal` +- Có support holiday rule theo **solar và lunar calendar** +- Có support season cross-year +- Có cơ chế chọn season theo `priority`, rồi fallback theo `multiplier` +- Có cache in-memory khoảng 1 giờ cho season/holiday/rule/override +- `getSeasonInfo()` là nguồn dữ liệu trung tâm cho pricing, search, combo, detail và alert +- `price_overrides` là công cụ custom/manual mạnh nhất hiện tại +- Đã support Tết ở tầng code nếu DB có `holiday_rules` đúng, nhưng không đảm bảo mọi môi trường đã có dữ liệu đó +- Chưa phải custom pricing engine đầy đủ theo route/airline/campaign/segment + +--- + +## 4. Bonus note: lỗi runtime vừa gặp lúc dev + +Trong lúc chạy local có 2 lỗi thực tế đã lòi ra: + +### Lỗi 1: `ALERT_THRESHOLD_PERCENT is not defined` +Nguyên nhân là `price-alert.service.js` bị thiếu constant đầu file. + +Đã fix bằng cách khôi phục: +- import pricing helpers +- constant `ALERT_THRESHOLD_PERCENT = 5` + +### Lỗi 2: `EADDRINUSE: port 3000` +Lỗi này không phải bug business logic, chỉ là cổng `3000` đang bị process khác chiếm. + +--- + +## 5. Chốt nhanh cho người đọc sau + +Nếu cần nhớ ngắn gọn những gì vừa đổi thì có thể hiểu như này: + +- **Date change**: đã tách luồng rõ ràng hơn, có OTP, có nhánh thanh toán riêng khi đổi sang chuyến đắt hơn. +- **Flight combo**: đã sạch hơn ở validation, flow query, ranking, và giá combo giờ có ăn theo season. +- **Season**: đã có thứ tự ưu tiên rõ ràng giữa override, holiday và season; đủ dùng cho pricing hiện tại nhưng chưa phải custom framework full cho admin. +Nếu sau này cần viết lại thành tài liệu chính thức cho team hoặc cho BA/QA, nên tách file này thành 3 doc riêng: `date-change`, `flight-combo`, `season-pricing` để dễ maintain hơn. \ No newline at end of file diff --git a/date-change-paypal.postman_collection.json b/date-change-paypal.postman_collection.json new file mode 100644 index 0000000..d739016 --- /dev/null +++ b/date-change-paypal.postman_collection.json @@ -0,0 +1,713 @@ +{ + "info": { + "_postman_id": "dc-quick-test-paypal-001", + "name": "Date Change - PayPal Quick Test", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:3000/api" + }, + { + "key": "userToken", + "value": "" + }, + { + "key": "adminToken", + "value": "" + }, + { + "key": "bookingCode", + "value": "BK-2026-XXXX" + }, + { + "key": "newFlightId", + "value": "100" + }, + { + "key": "newSeatClass", + "value": "economy" + }, + { + "key": "flightLeg", + "value": "outbound" + }, + { + "key": "otp", + "value": "123456" + }, + { + "key": "dateChangeRequestCode", + "value": "" + }, + { + "key": "paymentCode", + "value": "" + }, + { + "key": "paypalApproveUrl", + "value": "" + } + ], + "item": [ + { + "name": "0 - Get Booking Detail", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status is 200', function() {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Response has booking_code', function() {", + " const json = pm.response.json();", + " pm.expect(json.data).to.have.property('booking_code');", + " if (json.data.booking_code) {", + " pm.variables.set('bookingCode', json.data.booking_code);", + " console.log('bookingCode set to:', json.data.booking_code);", + " }", + "});", + "", + "pm.test('Booking is confirmed', function() {", + " const json = pm.response.json();", + " pm.expect(json.data.status).to.equal('confirmed');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{userToken}}", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/bookings/{{bookingCode}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "bookings", + "{{bookingCode}}" + ] + }, + "description": "Confirm the booking is confirmed and note the outbound_flight_id to use for date change test." + }, + "response": [] + }, + { + "name": "1 - Create Date Change Request", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status is 201', function() {", + " pm.response.to.have.status(201);", + "});", + "", + "pm.test('Response has request_code and pending_otp status', function() {", + " const json = pm.response.json();", + " pm.expect(json.data).to.have.property('request_code');", + " pm.expect(json.data.status).to.equal('pending_otp');", + " pm.expect(json.data).to.have.property('price_difference');", + "", + " pm.variables.set('dateChangeRequestCode', json.data.request_code);", + " console.log('request_code set to:', json.data.request_code);", + " console.log('price_difference:', json.data.price_difference);", + "});", + "", + "pm.test('price_difference_label reflects extra charge needed', function() {", + " const json = pm.response.json();", + " // For this test scenario, expect price_difference > 0 to trigger PayPal flow", + " const diff = parseFloat(json.data.price_difference);", + " if (diff > 0) {", + " pm.expect(json.data.price_difference_label).to.include('tra them');", + " pm.test('requires_otp is true', function() {", + " pm.expect(json.data.requires_otp).to.equal(true);", + " });", + " } else {", + " console.log('price_difference is not positive:', diff);", + " }", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{userToken}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"new_flight_id\": \"{{newFlightId}}\",\n \"new_seat_class\": \"{{newSeatClass}}\",\n \"flight_leg\": \"{{flightLeg}}\",\n \"reason\": \"Test date change PayPal flow\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/date-changes/bookings/{{bookingCode}}/change-flight", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "date-changes", + "bookings", + "{{bookingCode}}", + "change-flight" + ] + }, + "description": "Create a date change request. Requires Bearer token. Returns request_code and price_difference. If price_difference > 0 and chargeIfPositive config is on, flow will go to pending_payment after OTP." + }, + "response": [] + }, + { + "name": "2 - Verify OTP", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status is 200', function() {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('OTP verified successfully', function() {", + " const json = pm.response.json();", + " pm.expect(json.data.success).to.equal(true);", + "});", + "", + "pm.test('After OTP, status is pending_payment if price_difference > 0', function() {", + " const json = pm.response.json();", + " const diff = parseFloat(json.data.price_difference || 0);", + "", + " if (diff > 0) {", + " pm.expect(json.data.status).to.equal('pending_payment');", + " pm.expect(json.data.requires_payment).to.equal(true);", + " console.log('Flow confirmed: pending_payment with requires_payment=true');", + " } else {", + " pm.expect(json.data.status).to.equal('pending');", + " pm.expect(json.data.requires_payment).to.equal(false);", + " console.log('Flow confirmed: pending (no extra charge needed)');", + " }", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"{{bookingEmail}}\",\n \"otp\": \"{{otp}}\",\n \"requestCode\": \"{{dateChangeRequestCode}}\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/date-changes/confirm", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "date-changes", + "confirm" + ] + }, + "description": "Verify OTP sent to booking contact email. After successful OTP, if price_difference > 0 the request moves to pending_payment. Replace {{otp}} with the actual OTP from email (or mock in dev)." + }, + "response": [] + }, + { + "name": "3 - Get Date Change Detail", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status is 200', function() {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('request_code matches', function() {", + " const json = pm.response.json();", + " pm.expect(json.data.request_code).to.equal(pm.variables.get('dateChangeRequestCode'));", + "});", + "", + "pm.test('price_difference is shown', function() {", + " const json = pm.response.json();", + " pm.expect(json.data).to.have.property('price_difference');", + " const diff = parseFloat(json.data.price_difference);", + " console.log('price_difference in detail:', diff);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/date-changes/{{dateChangeRequestCode}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "date-changes", + "{{dateChangeRequestCode}}" + ] + }, + "description": "Check date change request detail. Shows status, price_difference, and payment info." + }, + "response": [] + }, + { + "name": "4 - Create PayPal Payment", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status is 201', function() {", + " pm.response.to.have.status(201);", + "});", + "", + "pm.test('payment_code is returned', function() {", + " const json = pm.response.json();", + " pm.expect(json.data).to.have.property('payment_code');", + " pm.variables.set('paymentCode', json.data.payment_code);", + " console.log('paymentCode set to:', json.data.payment_code);", + "});", + "", + "pm.test('payment_method is PAYPAL', function() {", + " const json = pm.response.json();", + " pm.expect(json.data.payment_method).to.equal('PAYPAL');", + "});", + "", + "pm.test('gateway_response contains PayPal order info', function() {", + " const json = pm.response.json();", + " const gw = json.data.gateway_response || {};", + " console.log('gateway_response:', JSON.stringify(gw, null, 2));", + "", + " // PayPal order fields vary by provider version", + " if (gw.approve_url || gw.redirect_url) {", + " const approveUrl = gw.approve_url || gw.redirect_url;", + " pm.variables.set('paypalApproveUrl', approveUrl);", + " console.log('paypalApproveUrl:', approveUrl);", + " } else if (gw.order_id) {", + " console.log('PayPal order_id:', gw.order_id);", + " }", + "", + " pm.expect(gw.provider).to.equal('PAYPAL');", + "});", + "", + "pm.test('price_difference in payment matches request', function() {", + " const json = pm.response.json();", + " pm.expect(json.data.price_difference).to.be.ok;", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"payment_method\": \"PAYPAL\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/date-changes/{{dateChangeRequestCode}}/payment", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "date-changes", + "{{dateChangeRequestCode}}", + "payment" + ] + }, + "description": "Create PayPal payment for the date change price_difference. Returns payment_code and gateway_response with PayPal approval URL. Open the approve_url / redirect_url in browser to complete checkout. After checkout, use the payment status requests in this collection to verify the backend has processed the PayPal return/webhook and moved the date-change request forward." + }, + "response": [] + }, + { + "name": "5 - Get Payment Status", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status is 200', function() {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('payment status is shown', function() {", + " const json = pm.response.json();", + " pm.expect(json.data).to.have.property('status');", + " pm.expect(json.data).to.have.property('price_difference');", + " console.log('payment status:', json.data.status);", + "", + " const payment = json.data.payment;", + " if (payment) {", + " console.log('payment.payment_method:', payment.payment_method);", + " console.log('payment.status:', payment.status);", + " console.log('payment.amount:', payment.amount);", + " }", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/date-changes/{{dateChangeRequestCode}}/payment", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "date-changes", + "{{dateChangeRequestCode}}", + "payment" + ] + }, + "description": "Poll this after completing PayPal checkout. Status should transition from PENDING to SUCCESS once payment is confirmed. If status is still PENDING, wait and re-run this request." + }, + "response": [] + }, + { + "name": "6 - Check Payment Status After PayPal Checkout", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status is 200', function() {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Response includes request and payment info', function() {", + " const json = pm.response.json();", + " console.log('payment status payload:', JSON.stringify(json.data, null, 2));", + " pm.expect(json.data).to.have.property('request_code');", + " pm.expect(json.data).to.have.property('status');", + " pm.expect(json.data).to.have.property('price_difference');", + "});", + "", + "pm.test('If webhook/return completed, request should move out of pending_payment', function() {", + " const json = pm.response.json();", + " const reqStatus = json.data.status;", + " const payment = json.data.payment;", + " if (payment && ['SUCCESS', 'PAID', 'COMPLETED', 'CONFIRMED'].includes(String(payment.status || '').toUpperCase())) {", + " pm.expect(['pending', 'approved']).to.include(reqStatus);", + " console.log('Payment is completed and request moved to:', reqStatus);", + " } else {", + " console.log('Payment not completed yet. Re-run this request after PayPal checkout/webhook.');", + " }", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/date-changes/{{dateChangeRequestCode}}/payment", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "date-changes", + "{{dateChangeRequestCode}}", + "payment" + ] + }, + "description": "Run this after completing PayPal checkout in the browser. The actual success transition happens through backend payment return/webhook handling. Re-run until payment.status becomes SUCCESS (or equivalent) and request status moves from pending_payment to pending/approved." + }, + "response": [] + }, + { + "name": "7 - Admin: Get Date Change Pending List", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status is 200', function() {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Returns pending date change requests', function() {", + " const json = pm.response.json();", + " pm.expect(json.data).to.be.an('array');", + "", + " const target = json.data.find(r => r.request_code === pm.variables.get('dateChangeRequestCode'));", + " if (target) {", + " console.log('Found request:', target.request_code);", + " console.log('status:', target.status);", + " console.log('price_difference:', target.price_difference);", + " } else {", + " console.log('Request not found in pending list, may already be approved or status changed');", + " }", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminToken}}", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/date-changes/admin?status=pending", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "date-changes", + "admin" + ], + "query": [ + { + "key": "status", + "value": "pending" + } + ] + }, + "description": "Admin checks the pending date change requests. The PayPal-paid request should appear here with status pending after payment is confirmed." + }, + "response": [] + }, + { + "name": "8 - Admin: Approve Date Change", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status is 200', function() {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Date change approved successfully', function() {", + " const json = pm.response.json();", + " pm.expect(json.data.success).to.equal(true);", + " pm.expect(json.data.status).to.equal('approved');", + " console.log('Approved:', json.data.request_code);", + "});", + "", + "pm.test('Booking is updated (date_changed status)', function() {", + " const json = pm.response.json();", + " pm.expect(json.data.message).to.be.ok;", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminToken}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"admin_notes\": \"Approved after PayPal payment confirmed - date change quick test\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/date-changes/{{dateChangeRequestCode}}/approve", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "date-changes", + "{{dateChangeRequestCode}}", + "approve" + ] + }, + "description": "Admin approves the date change request. This executes the actual flight change: releases old seats, reserves new seats, updates booking. If the net effect reduces booking total, a refund record may be auto-created." + }, + "response": [] + }, + { + "name": "9 - Verify Final Booking Detail", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status is 200', function() {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Booking status updated to date_changed', function() {", + " const json = pm.response.json();", + " pm.expect(json.data.status).to.equal('date_changed');", + "});", + "", + "pm.test('outbound flight is updated to new flight', function() {", + " const json = pm.response.json();", + " const newFlightId = pm.variables.get('newFlightId');", + " pm.expect(json.data.outbound_flight_id.toString()).to.equal(newFlightId.toString());", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{userToken}}", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/bookings/{{bookingCode}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "bookings", + "{{bookingCode}}" + ] + }, + "description": "Final verification that the booking now reflects the new flight and has status date_changed." + }, + "response": [] + }, + { + "name": "X - Cancel Date Change Request", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status is 200 on successful cancel', function() {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Request cancelled', function() {", + " const json = pm.response.json();", + " pm.expect(json.data.status).to.equal('cancelled');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{userToken}}", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/date-changes/{{dateChangeRequestCode}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "date-changes", + "{{dateChangeRequestCode}}" + ] + }, + "description": "Cancel the date change request (only allowed while status is pending_otp, pending_payment, or pending). Associated pending payment will also be cancelled." + }, + "response": [] + }, + { + "name": "X - Cancel Date Change Payment", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status is 200 on successful payment cancel', function() {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Payment cancelled', function() {", + " const json = pm.response.json();", + " pm.expect(json.data.success).to.equal(true);", + " console.log('Cancelled payment:', json.data.payment_code);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/date-changes/{{dateChangeRequestCode}}/payment", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "date-changes", + "{{dateChangeRequestCode}}", + "payment" + ] + }, + "description": "Cancel the pending payment without cancelling the date change request itself. Request stays at pending_payment so user can create a new payment." + }, + "response": [] + } + ] +} diff --git a/docs/diagrams/sequence/01-auto-refund.md b/docs/diagrams/sequence/01-auto-refund.md new file mode 100644 index 0000000..f692d29 --- /dev/null +++ b/docs/diagrams/sequence/01-auto-refund.md @@ -0,0 +1,99 @@ +```plantuml +@startuml +hide footbox +skinparam ParticipantPadding 20 +skinparam BoxPadding 10 +skinparam sequenceMessageAlign center + +title Auto-Refund Flow + +actor User +actor Guest +actor Admin as ADMIN +participant "Refund Controller" as C +participant "Refund Service" as S +database "PostgreSQL" as DB +participant "OTP Store" as OTP +participant "Notification Service" as NOTIF +participant "Payment Gateway" as PG +participant "Loyalty Service" as LOYALTY + +== User section == +User -> C: POST /api/refunds/user +C -> S: requestRefund(userId, bookingCode, data) +S -> DB: SELECT_BOOKING_DETAIL +S -> DB: SELECT_PAYMENT_BY_BOOKING +S -> S: validateRefundRequest +S -> DB: CHECK_PENDING_REFUND_FOR_BOOKING +S -> S: calculateRefundAmount +alt OTP required + S -> OTP: isOTPVerified(email) + OTP --> S: true/false +end +alt net_refund_amount < threshold + S -> S: status = approved +else + S -> S: status = pending +end +S -> DB: INSERT_REFUND +S -> DB: UPDATE_BOOKING_STATUS(refund_pending) +S -> NOTIF: REFUND_REQUESTED +S --> User: refund_code, status + +== Guest section == +Guest -> C: POST /api/refunds/guest +C -> S: requestGuestRefund(bookingCode, guestEmail, data) +S -> DB: SELECT_BOOKING_DETAIL +S -> S: verify guestEmail matches booking +S -> DB: SELECT_PAYMENT_BY_BOOKING +S -> S: validateRefundRequest +S -> DB: CHECK_PENDING_REFUND_FOR_BOOKING +S -> S: calculateRefundAmount +alt OTP required + S -> OTP: isOTPVerified(guestEmail) + OTP --> S: true/false +end +alt net_refund_amount < threshold + S -> S: status = approved +else + S -> S: status = pending +end +S -> DB: INSERT_REFUND(is_guest = true) +S -> DB: UPDATE_BOOKING_STATUS(refund_pending) +S -> NOTIF: REFUND_REQUESTED +S --> Guest: refund_code, status + +== Admin approve == +ADMIN -> C: POST /api/admin/refunds/:refundCode/approve +C -> S: approveRefund(adminId, refundCode, admin_notes) +S -> DB: SELECT_REFUND_BY_CODE +S -> DB: UPDATE_REFUND_STATUS(approved) +S -> NOTIF: REFUND_APPROVED + +== Admin process == +ADMIN -> C: POST /api/admin/refunds/:refundCode/complete +C -> S: processRefund(adminId, refundCode) +S -> DB: SELECT_REFUND_BY_CODE +S -> DB: UPDATE_REFUND_STATUS(processing) +S -> PG: reversePayment(payment_id, net_refund_amount) +alt success + PG --> S: ok + S -> DB: UPDATE_REFUND_COMPLETED + S -> DB: UPDATE_BOOKING_STATUS(refunded) + S -> LOYALTY: revokePointsForRefund + S -> NOTIF: REFUND_COMPLETED +else failure + PG --> S: error + S -> DB: UPDATE_REFUND_STATUS(failed) +end + +== Admin reject == +ADMIN -> C: POST /api/admin/refunds/:refundCode/reject +C -> S: rejectRefund(adminId, refundCode, reason) +S -> DB: SELECT_REFUND_BY_CODE +S -> DB: UPDATE_REFUND_STATUS(rejected) +S -> DB: UPDATE_BOOKING_STATUS(confirmed) +S -> NOTIF: REFUND_REJECTED + +@enduml +``` diff --git a/docs/diagrams/sequence/02-date-change-user-flow.md b/docs/diagrams/sequence/02-date-change-user-flow.md new file mode 100644 index 0000000..00e949d --- /dev/null +++ b/docs/diagrams/sequence/02-date-change-user-flow.md @@ -0,0 +1,73 @@ +```plantuml +@startuml +hide footbox +skinparam ParticipantPadding 20 +skinparam BoxPadding 10 +skinparam sequenceMessageAlign center + +title Date-Change User Flow + +actor User +participant "Date-Change Controller" as C +participant "Date-Change Service" as S +database "PostgreSQL" as DB +participant "OTP Store" as OTP +participant "Payment Provider" as PAY +participant "Notification Service" as NOTIF + +== Request == +User -> C: POST /api/date-changes/bookings/:bookingCode/change-flight +C -> S: requestDateChange(userId, bookingCode, data) +S -> DB: SELECT_BOOKING_DETAIL +S -> S: validateDateChangeRequest +S -> DB: CHECK_PENDING_DATE_CHANGE_FOR_BOOKING +S -> DB: INSERT_DATE_CHANGE(pending_otp) +S -> OTP: requestDateChangeOTP(email, requestCode) +S -> NOTIF: DATE_CHANGE_REQUESTED +S --> User: request_code, status + +== Confirm OTP == +User -> C: POST /api/date-changes/confirm +C -> S: confirmDateChange(email, otp, requestCode) +S -> OTP: verifyDateChangeOTP(email, otp, requestCode) +S -> DB: SELECT_DATE_CHANGE_BY_CODE +alt price_difference > 0 + S -> DB: UPDATE_DATE_CHANGE_STATUS(pending_payment) + S --> User: pending_payment +else price_difference <= 0 + S -> DB: UPDATE_DATE_CHANGE_STATUS(pending) + alt absDiff < threshold + S -> S: approveDateChange(null, requestCode, auto) + end + S --> User: pending +end + +== Create payment == +User -> C: POST /api/date-changes/:requestCode/payment +C -> S: createDateChangePayment(requestCode, payment_method, userId) +S -> DB: SELECT ... FOR UPDATE +S -> DB: INSERT_PAYMENT +S -> PAY: create payment instruction +PAY --> S: payment_url / qr_payload +S --> User: payment_code, expires_at + +== Confirm payment == +PAY -> C: payment webhook +C -> S: confirmDateChangePayment(paymentCode) +S -> DB: SELECT_PAYMENT +S -> DB: UPDATE_PAYMENT_STATUS(SUCCESS) +S -> DB: UPDATE_DATE_CHANGE_STATUS(pending) +S -> S: approveDateChange(null, requestCode, auto) +S -> DB: UPDATE paid_at +S --> User: approved + +== Cancel == +User -> C: DELETE /api/date-changes/:requestCode +C -> S: cancelDateChangeRequest(userId, requestCode) +S -> DB: UPDATE_DATE_CHANGE_STATUS(cancelled) +alt payment exists + S -> DB: UPDATE_PAYMENT_STATUS(CANCELLED) +end + +@enduml +``` diff --git a/docs/diagrams/sequence/03-date-change-admin-flow.md b/docs/diagrams/sequence/03-date-change-admin-flow.md new file mode 100644 index 0000000..fcd1852 --- /dev/null +++ b/docs/diagrams/sequence/03-date-change-admin-flow.md @@ -0,0 +1,54 @@ +```plantuml +@startuml +hide footbox +skinparam ParticipantPadding 20 +skinparam BoxPadding 10 +skinparam sequenceMessageAlign center + +title Date-Change Execution and Admin Flow + +actor Admin as ADMIN +participant "Date-Change Controller" as C +participant "Date-Change Service" as S +database "PostgreSQL" as DB +participant "Notification Service" as NOTIF + +== Approve execution == +C -> S: approveDateChange(adminId, requestCode, admin_notes) +S -> DB: SELECT_DATE_CHANGE_BY_CODE +S -> DB: SELECT_SEAT_INFO(newFlight) +S -> DB: UPDATE flight_seats release old +S -> DB: UPDATE flight_seats reserve new +S -> DB: CANCEL outbound ancillaries except insurance +S -> DB: UPDATE bookings.total_price +alt refundableAmount > 0 + S -> DB: INSERT_REFUND(partial_leg, pending) +end +alt surchargeAmount > 0 + S -> S: append surcharge note +end +S -> DB: UPDATE_BOOKING_FLIGHT +S -> DB: UPDATE_DATE_CHANGE_STATUS(approved) +S -> NOTIF: DATE_CHANGE_APPROVED + +== Admin approve == +ADMIN -> C: POST /api/admin/date-changes/:requestCode/approve +C -> S: approveDateChange(adminId, requestCode, admin_notes) + +== Admin reject == +ADMIN -> C: POST /api/admin/date-changes/:requestCode/reject +C -> S: rejectDateChange(adminId, requestCode, reason) +S -> DB: SELECT_DATE_CHANGE_BY_CODE +S -> DB: UPDATE_DATE_CHANGE_STATUS(rejected) +S -> NOTIF: DATE_CHANGE_REJECTED + +== Admin cancel == +ADMIN -> C: DELETE /api/admin/date-changes/:requestCode +C -> S: cancelDateChangeRequest(null, requestCode) +S -> DB: UPDATE_DATE_CHANGE_STATUS(cancelled) +alt payment exists + S -> DB: UPDATE_PAYMENT_STATUS(CANCELLED) +end + +@enduml +``` diff --git a/docs/diagrams/sequence/README.md b/docs/diagrams/sequence/README.md new file mode 100644 index 0000000..245f7d2 --- /dev/null +++ b/docs/diagrams/sequence/README.md @@ -0,0 +1,42 @@ +# Sequence Diagrams - Backend Log Function + +Collection of sequence diagrams documenting the core business flows. + +## Table of Contents + +| Diagram | Description | File | +|---------|-------------|------| +| Auto-Refund | User request → auto/manual approval → refund execution → gateway outcome | [01-auto-refund.md](01-auto-refund.md) | +| Date-Change User Flow | Request → OTP → payment creation → payment confirmation/cancel | [02-date-change-user-flow.md](02-date-change-user-flow.md) | +| Date-Change Execution & Admin Flow | approve/reject/cancel → seat swap → booking update → embedded refund | [03-date-change-admin-flow.md](03-date-change-admin-flow.md) | + +## Quick Reference + +### Auto-Refund Status Flow +``` +pending → approved → processing → completed + ↓ ↓ + rejected failed +``` + +### Date-Change User Flow +``` +pending_otp → pending_payment → approved + ↓ ↓ + pending cancelled + ↓ + approved / rejected / cancelled +``` + +### Date-Change Execution & Admin Flow +``` +pending -> approved -> done + ↓ +rejected / cancelled +``` + +## Notes + +- All diagrams are written in PlantUML for easier maintenance and export. +- Auto-refund remains separated from date-change, but date-change approval can still create an embedded refund record when the recalculated amount becomes refundable. +- These docs now follow the current service-level implementation and route naming more closely than the previous generalized version. diff --git a/package-lock.json b/package-lock.json index 268eb43..9c8c27a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,8 @@ "pg": "^8.20.0", "qrcode": "^1.5.4", "resend": "^6.9.4", - "socket.io": "^4.8.3" + "socket.io": "^4.8.3", + "solarlunar": "^3.1.0" }, "devDependencies": { "jest": "^30.4.2", @@ -6132,6 +6133,12 @@ "node": ">= 0.6" } }, + "node_modules/solarlunar": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/solarlunar/-/solarlunar-3.1.0.tgz", + "integrity": "sha512-PCJsIeSaMyQVK6TZk+tlPNaoAlk02+2UxhI/DS+K0zdLkwTihMp4px6S+Jv6HPPC0p7QsUM18WSs2/HEC1o0wQ==", + "license": "ISC" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index 319c6d2..db1bbd7 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "pg": "^8.20.0", "qrcode": "^1.5.4", "resend": "^6.9.4", - "socket.io": "^4.8.3" + "socket.io": "^4.8.3", + "solarlunar": "^3.1.0" }, "devDependencies": { "jest": "^30.4.2", diff --git a/postman/Refund_DateChange_Tests.postman_collection.json b/postman/Refund_DateChange_Tests.postman_collection.json deleted file mode 100644 index d151ca2..0000000 --- a/postman/Refund_DateChange_Tests.postman_collection.json +++ /dev/null @@ -1,504 +0,0 @@ -{ - "info": { - "name": "Vivudee - Refund & Date Change Tests", - "description": "Test collection for Refund + Date Change with OTP and Auto/Manual approval", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" - }, - "variable": [ - { - "key": "baseUrl", - "value": "http://localhost:3000/api" - }, - { - "key": "bookingCode", - "value": "UB421473" - }, - { - "key": "userEmail", - "value": "test@example.com" - }, - { - "key": "userToken", - "value": "" - }, - { - "key": "adminToken", - "value": "" - } - ], - "item": [ - { - "name": "===== REFUND TESTS =====", - "item": [ - { - "name": "1. [USER] Request OTP for Refund", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "if (pm.response.code === 200) {", - " console.log('OTP requested successfully');", - " var data = pm.response.json();", - " if (data._debug_code) {", - " console.log('DEBUG OTP:', data._debug_code);", - " pm.collectionVariables.set('debug_otp', data._debug_code);", - " }", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{userToken}}" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"bookingCode\": \"{{bookingCode}}\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/refunds/user/request-otp", - "host": ["{{baseUrl}}"], - "path": ["refunds", "user", "request-otp"] - } - } - }, - { - "name": "2. [USER] Verify OTP", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"{{userEmail}}\",\n \"code\": \"123456\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/refunds/user/verify-otp", - "host": ["{{baseUrl}}"], - "path": ["refunds", "user", "verify-otp"] - } - } - }, - { - "name": "3. [USER] Submit Refund (< 1M = Auto)", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "if (pm.response.code === 200 || pm.response.code === 201) {", - " var data = pm.response.json();", - " console.log('Status:', data.status);", - " console.log('Message:', data.message);", - " ", - " if (data.status === 'approved') {", - " console.log('AUTO APPROVED - Refund < 1M');", - " } else if (data.status === 'pending') {", - " console.log('PENDING - Refund >= 1M, need admin approval');", - " }", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{userToken}}" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"bookingCode\": \"{{bookingCode}}\",\n \"refund_type\": \"full\",\n \"reason\": \"Khong con nhu yen\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/refunds/user", - "host": ["{{baseUrl}}"], - "path": ["refunds", "user"] - } - } - }, - { - "name": "4. [GUEST] Request OTP", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "if (pm.response.code === 200) {", - " var data = pm.response.json();", - " if (data._debug_code) {", - " console.log('DEBUG OTP:', data._debug_code);", - " }", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"bookingCode\": \"{{bookingCode}}\",\n \"email\": \"{{userEmail}}\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/refunds/guest/request-otp", - "host": ["{{baseUrl}}"], - "path": ["refunds", "guest", "request-otp"] - } - } - }, - { - "name": "5. [GUEST] Verify OTP", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"{{userEmail}}\",\n \"code\": \"123456\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/refunds/guest/verify-otp", - "host": ["{{baseUrl}}"], - "path": ["refunds", "guest", "verify-otp"] - } - } - }, - { - "name": "6. [GUEST] Submit Refund", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "if (pm.response.code === 200 || pm.response.code === 201) {", - " var data = pm.response.json();", - " console.log('Status:', data.data?.status);", - " console.log('Message:', data.message);", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"bookingCode\": \"{{bookingCode}}\",\n \"guestEmail\": \"{{userEmail}}\",\n \"refund_type\": \"full\",\n \"reason\": \"Thay doi lich trinh\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/refunds/guest", - "host": ["{{baseUrl}}"], - "path": ["refunds", "guest"] - } - } - } - ] - }, - { - "name": "===== DATE CHANGE TESTS =====", - "item": [ - { - "name": "1. [USER] Request Date Change (sends OTP)", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "if (pm.response.code === 201) {", - " var data = pm.response.json();", - " console.log('Status:', data.data?.status);", - " console.log('Request Code:', data.data?.request_code);", - " console.log('Message:', data.message);", - " pm.collectionVariables.set('dateChangeCode', data.data?.request_code);", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{userToken}}" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"new_flight_id\": 2,\n \"new_seat_class\": \"economy\",\n \"reason\": \"Muon doi ngay di som hon\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/date-changes/bookings/{{bookingCode}}/change-flight", - "host": ["{{baseUrl}}"], - "path": ["date-changes", "bookings", "{{bookingCode}}", "change-flight"] - } - } - }, - { - "name": "2. [USER] Confirm Date Change (verify OTP)", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "if (pm.response.code === 200) {", - " var data = pm.response.json();", - " console.log('Status:', data.status);", - " console.log('Auto Approved:', data.auto_approved);", - " console.log('Message:', data.message);", - " ", - " if (data.auto_approved === true) {", - " console.log('AUTO APPROVED - Price diff < 1M');", - " } else if (data.status === 'pending') {", - " console.log('PENDING - Price diff >= 1M, need admin approval');", - " }", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"{{userEmail}}\",\n \"otp\": \"123456\",\n \"requestCode\": \"{{dateChangeCode}}\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/date-changes/confirm", - "host": ["{{baseUrl}}"], - "path": ["date-changes", "confirm"] - } - } - }, - { - "name": "3. Get Date Change Detail", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/date-changes/{{dateChangeCode}}", - "host": ["{{baseUrl}}"], - "path": ["date-changes", "{{dateChangeCode}}"] - } - } - }, - { - "name": "4. Cancel Date Change", - "request": { - "method": "DELETE", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{userToken}}" - } - ], - "url": { - "raw": "{{baseUrl}}/date-changes/{{dateChangeCode}}", - "host": ["{{baseUrl}}"], - "path": ["date-changes", "{{dateChangeCode}}"] - } - } - } - ] - }, - { - "name": "===== ADMIN TESTS =====", - "item": [ - { - "name": "1. Get All Pending Refunds", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{adminToken}}" - } - ], - "url": { - "raw": "{{baseUrl}}/admin/refunds?status=pending", - "host": ["{{baseUrl}}"], - "path": ["admin", "refunds"], - "query": [ - { - "key": "status", - "value": "pending" - } - ] - } - } - }, - { - "name": "2. Get All Pending Date Changes", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{adminToken}}" - } - ], - "url": { - "raw": "{{baseUrl}}/admin/date-changes?status=pending", - "host": ["{{baseUrl}}"], - "path": ["admin", "date-changes"], - "query": [ - { - "key": "status", - "value": "pending" - } - ] - } - } - }, - { - "name": "3. Approve Refund", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{adminToken}}" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"admin_notes\": \"Da kiem tra, chap nhan refund\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/admin/refunds/{{refundCode}}/approve", - "host": ["{{baseUrl}}"], - "path": ["admin", "refunds", "{{refundCode}}", "approve"] - } - } - }, - { - "name": "4. Reject Refund", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{adminToken}}" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"reason\": \"Khong nam trong chinh sach hoan tien\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/admin/refunds/{{refundCode}}/reject", - "host": ["{{baseUrl}}"], - "path": ["admin", "refunds", "{{refundCode}}", "reject"] - } - } - }, - { - "name": "5. Approve Date Change", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{adminToken}}" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"admin_notes\": \"Da kiem tra, chap nhan doi ngay\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/date-changes/{{dateChangeCode}}/approve", - "host": ["{{baseUrl}}"], - "path": ["date-changes", "{{dateChangeCode}}", "approve"] - } - } - }, - { - "name": "6. Reject Date Change", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{adminToken}}" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"reason\": \"Chuyen bay moi khong con ghe\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/date-changes/{{dateChangeCode}}/reject", - "host": ["{{baseUrl}}"], - "path": ["date-changes", "{{dateChangeCode}}", "reject"] - } - } - } - ] - } - ] -} diff --git a/postman/Vivudee_API_Full_Collection.json b/postman/Vivudee_API_Full_Collection.json deleted file mode 100644 index efa09de..0000000 --- a/postman/Vivudee_API_Full_Collection.json +++ /dev/null @@ -1,585 +0,0 @@ -{ - "info": { - "name": "Vivudee API - Full Collection", - "description": "API endpoints day du cho he thong dat ve may bay. Cac luong: Auth, Flights, Bookings, Payments, Refunds, Seats, Check-in", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" - }, - "variable": [ - { - "key": "baseUrl", - "value": "http://localhost:3000/api", - "type": "string" - }, - { - "key": "token", - "value": "", - "type": "string" - }, - { - "key": "bookingCode", - "value": "YOUR_BOOKING_CODE", - "type": "string" - }, - { - "key": "refundCode", - "value": "RF123456", - "type": "string" - }, - { - "key": "flightId", - "value": "1", - "type": "string" - } - ], - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "{{token}}", - "type": "string" - } - ] - }, - "item": [ - { - "name": "AUTH - Xac thuc", - "item": [ - { - "name": "1. Dang ky tai khoan", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "if (pm.response.code === 201 || pm.response.code === 200) {", - " var data = pm.response.json();", - " if (data.token) {", - " pm.collectionVariables.set('token', data.token);", - " console.log('Token saved:', data.token);", - " }", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [{"key": "Content-Type", "value": "application/json"}], - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"test@example.com\",\n \"password\": \"Password123!\",\n \"full_name\": \"Nguyen Van A\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/auth/register", - "host": ["{{baseUrl}}"], - "path": ["auth", "register"] - } - } - }, - { - "name": "2. Dang nhap", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "if (pm.response.code === 200) {", - " var data = pm.response.json();", - " if (data.token) {", - " pm.collectionVariables.set('token', data.token);", - " console.log('Token saved:', data.token);", - " }", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [{"key": "Content-Type", "value": "application/json"}], - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"test@example.com\",\n \"password\": \"Password123!\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/auth/login", - "host": ["{{baseUrl}}"], - "path": ["auth", "login"] - } - } - } - ] - }, - { - "name": "FLIGHTS - Chuyen bay", - "item": [ - { - "name": "Tim kiem chuyen bay", - "request": { - "method": "GET", - "url": { - "raw": "{{baseUrl}}/flights/search?from_city=HAN&to_city=SGN&departure_date=2026-06-01&adults=1&children=0&infants=0", - "host": ["{{baseUrl}}"], - "path": ["flights", "search"], - "query": [ - {"key": "from_city", "value": "HAN"}, - {"key": "to_city", "value": "SGN"}, - {"key": "departure_date", "value": "2026-06-01"}, - {"key": "adults", "value": "1"}, - {"key": "children", "value": "0"}, - {"key": "infants", "value": "0"} - ] - } - } - }, - { - "name": "Lay seat map (economy)", - "request": { - "method": "GET", - "url": { - "raw": "{{baseUrl}}/flights/{{flightId}}/seats?seat_class=economy", - "host": ["{{baseUrl}}"], - "path": ["flights", "{{flightId}}", "seats"], - "query": [{"key": "seat_class", "value": "economy"}] - } - } - }, - { - "name": "Lay seat pricing", - "request": { - "method": "GET", - "url": { - "raw": "{{baseUrl}}/flights/{{flightId}}/seats/pricing?seat_class=economy", - "host": ["{{baseUrl}}"], - "path": ["flights", "{{flightId}}", "seats", "pricing"], - "query": [{"key": "seat_class", "value": "economy"}] - } - } - } - ] - }, - { - "name": "BOOKINGS - Dat ve", - "item": [ - { - "name": "1. Tao booking (chua thanh toan)", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "if (pm.response.code === 201 || pm.response.code === 200) {", - " var data = pm.response.json();", - " var code = data.booking ? data.booking.booking_code : data.bookingCode;", - " if (code) {", - " pm.collectionVariables.set('bookingCode', code);", - " console.log('Booking code saved:', code);", - " }", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [{"key": "Content-Type", "value": "application/json"}], - "body": { - "mode": "raw", - "raw": "{\n \"outbound_flight_id\": 1,\n \"outbound_seat_class\": \"economy\",\n \"trip_type\": \"one_way\",\n \"adults\": 1,\n \"children\": 0,\n \"infants\": 0,\n \"contact_name\": \"Nguyen Van A\",\n \"contact_email\": \"test@example.com\",\n \"contact_phone\": \"0909123456\",\n \"passengers\": [\n {\n \"passenger_type\": \"adult\",\n \"full_name\": \"Nguyen Van A\",\n \"date_of_birth\": \"1990-01-01\",\n \"gender\": \"male\",\n \"nationality\": \"Vietnam\",\n \"passport_number\": \"B1234567\",\n \"passport_expiry\": \"2030-01-01\",\n \"flight_type\": \"outbound\",\n \"extra_baggage_kg\": 0\n }\n ]\n}" - }, - "url": { - "raw": "{{baseUrl}}/bookings", - "host": ["{{baseUrl}}"], - "path": ["bookings"] - } - } - }, - { - "name": "2. Lay thong tin booking", - "request": { - "method": "GET", - "url": { - "raw": "{{baseUrl}}/bookings/{{bookingCode}}", - "host": ["{{baseUrl}}"], - "path": ["bookings", "{{bookingCode}}"] - } - } - }, - { - "name": "3. Lay danh sach booking cua toi", - "request": { - "auth": {"type": "bearer", "bearer": [{"key": "token", "value": "{{token}}", "type": "string"}]}, - "method": "GET", - "url": { - "raw": "{{baseUrl}}/bookings/my", - "host": ["{{baseUrl}}"], - "path": ["bookings", "my"] - } - } - } - ] - }, - { - "name": "SEAT SELECTION - Chon ghe", - "item": [ - { - "name": "1. Lay ghe da assign", - "request": { - "method": "GET", - "url": { - "raw": "{{baseUrl}}/bookings/{{bookingCode}}/seats", - "host": ["{{baseUrl}}"], - "path": ["bookings", "{{bookingCode}}", "seats"] - } - } - }, - { - "name": "2. Chon ghe cu the (A/F=10k, B-E=5k)", - "request": { - "method": "POST", - "header": [{"key": "Content-Type", "value": "application/json"}], - "body": { - "mode": "raw", - "raw": "{\n \"selections\": [\n {\n \"passenger_id\": 1,\n \"flight_type\": \"outbound\",\n \"seat_number\": \"12A\"\n }\n ]\n}" - }, - "url": { - "raw": "{{baseUrl}}/bookings/{{bookingCode}}/seats", - "host": ["{{baseUrl}}"], - "path": ["bookings", "{{bookingCode}}", "seats"] - } - } - }, - { - "name": "3. Auto assign ghe ngau nhien (FREE)", - "request": { - "method": "POST", - "url": { - "raw": "{{baseUrl}}/bookings/{{bookingCode}}/seats/auto", - "host": ["{{baseUrl}}"], - "path": ["bookings", "{{bookingCode}}", "seats", "auto"] - } - } - } - ] - }, - { - "name": "PAYMENTS - Thanh toan", - "item": [ - { - "name": "1. Khoi tao thanh toan", - "request": { - "method": "POST", - "header": [{"key": "Content-Type", "value": "application/json"}], - "body": { - "mode": "raw", - "raw": "{\n \"booking_id\": 1,\n \"email\": \"test@example.com\",\n \"phone\": \"0909123456\",\n \"name\": \"Nguyen Van A\",\n \"payment_method\": \"BANK_QR\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/payments/init", - "host": ["{{baseUrl}}"], - "path": ["payments", "init"] - } - } - }, - { - "name": "2. Xac nhan thanh toan", - "request": { - "method": "POST", - "header": [{"key": "Content-Type", "value": "application/json"}], - "body": { - "mode": "raw", - "raw": "{\n \"payment_code\": \"PAY123456\",\n \"transaction_id\": \"BANK123456\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/payments/PAY123456/confirm", - "host": ["{{baseUrl}}"], - "path": ["payments", "PAY123456", "confirm"] - } - } - } - ] - }, - { - "name": "REFUNDS (GUEST) - Hoan tien", - "item": [ - { - "name": "1. [GUEST] Request OTP (bill > 5M)", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "if (pm.response.code === 200) {", - " console.log('OTP requested successfully');", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [{"key": "Content-Type", "value": "application/json"}], - "body": { - "mode": "raw", - "raw": "{\n \"bookingCode\": \"{{bookingCode}}\",\n \"email\": \"test@example.com\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/refunds/guest/request-otp", - "host": ["{{baseUrl}}"], - "path": ["refunds", "guest", "request-otp"] - } - } - }, - { - "name": "2. [GUEST] Verify OTP (nhap ma nhan duoc)", - "request": { - "method": "POST", - "header": [{"key": "Content-Type", "value": "application/json"}], - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"test@example.com\",\n \"code\": \"123456\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/refunds/guest/verify-otp", - "host": ["{{baseUrl}}"], - "path": ["refunds", "guest", "verify-otp"] - } - } - }, - { - "name": "3. [GUEST] Submit refund request", - "request": { - "method": "POST", - "header": [{"key": "Content-Type", "value": "application/json"}], - "body": { - "mode": "raw", - "raw": "{\n \"bookingCode\": \"{{bookingCode}}\",\n \"guestEmail\": \"test@example.com\",\n \"reason\": \"Thay doi lich trinh\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/refunds/guest", - "host": ["{{baseUrl}}"], - "path": ["refunds", "guest"] - } - } - }, - { - "name": "4. [GUEST] Huy refund request", - "request": { - "method": "POST", - "header": [{"key": "Content-Type", "value": "application/json"}], - "body": { - "mode": "raw", - "raw": "{\n \"refund_code\": \"{{refundCode}}\",\n \"email\": \"test@example.com\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/refunds/guest/cancel", - "host": ["{{baseUrl}}"], - "path": ["refunds", "guest", "cancel"] - } - } - }, - { - "name": "5. [GUEST] Lay chi tiet refund", - "request": { - "method": "GET", - "url": { - "raw": "{{baseUrl}}/refunds/guest/{{refundCode}}?email=test@example.com", - "host": ["{{baseUrl}}"], - "path": ["refunds", "guest", "{{refundCode}}"], - "query": [{"key": "email", "value": "test@example.com"}] - } - } - }, - { - "name": "6. [GUEST] Kiem tra trang thai OTP", - "request": { - "method": "GET", - "url": { - "raw": "{{baseUrl}}/refunds/guest/otp-status?email=test@example.com", - "host": ["{{baseUrl}}"], - "path": ["refunds", "guest", "otp-status"], - "query": [{"key": "email", "value": "test@example.com"}] - } - } - } - ] - }, - { - "name": "REFUNDS (USER) - Hoan tien", - "item": [ - { - "name": "1. [USER] Request OTP (bill > 5M) - CAN AUTH", - "request": { - "auth": {"type": "bearer", "bearer": [{"key": "token", "value": "{{token}}", "type": "string"}]}, - "method": "POST", - "header": [{"key": "Content-Type", "value": "application/json"}], - "body": { - "mode": "raw", - "raw": "{\n \"bookingCode\": \"{{bookingCode}}\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/refunds/user/request-otp", - "host": ["{{baseUrl}}"], - "path": ["refunds", "user", "request-otp"] - } - } - }, - { - "name": "2. [USER] Verify OTP (dung /guest/verify-otp)", - "request": { - "method": "POST", - "header": [{"key": "Content-Type", "value": "application/json"}], - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"test@example.com\",\n \"code\": \"123456\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/refunds/guest/verify-otp", - "host": ["{{baseUrl}}"], - "path": ["refunds", "guest", "verify-otp"] - } - } - }, - { - "name": "3. [USER] Lay lich su refund cua minh", - "request": { - "auth": {"type": "bearer", "bearer": [{"key": "token", "value": "{{token}}", "type": "string"}]}, - "method": "GET", - "url": { - "raw": "{{baseUrl}}/refunds/my", - "host": ["{{baseUrl}}"], - "path": ["refunds", "my"] - } - } - }, - { - "name": "4. [USER] Lay chi tiet refund", - "request": { - "method": "GET", - "url": { - "raw": "{{baseUrl}}/refunds/{{refundCode}}", - "host": ["{{baseUrl}}"], - "path": ["refunds", "{{refundCode}}"] - } - } - }, - { - "name": "5. [USER] Huy refund request - CAN AUTH", - "request": { - "auth": {"type": "bearer", "bearer": [{"key": "token", "value": "{{token}}", "type": "string"}]}, - "method": "DELETE", - "url": { - "raw": "{{baseUrl}}/refunds/{{refundCode}}", - "host": ["{{baseUrl}}"], - "path": ["refunds", "{{refundCode}}"] - } - } - } - ] - }, - { - "name": "CHECK-IN - Checkin online", - "item": [ - { - "name": "1. Lay trang thai check-in", - "request": { - "method": "GET", - "url": { - "raw": "{{baseUrl}}/checkin/status/{{bookingCode}}", - "host": ["{{baseUrl}}"], - "path": ["checkin", "status", "{{bookingCode}}"] - } - } - }, - { - "name": "2. Check-in tat ca hanh khach", - "request": { - "method": "POST", - "header": [{"key": "Content-Type", "value": "application/json"}], - "body": { - "mode": "raw", - "raw": "{\n \"booking_code\": \"{{bookingCode}}\",\n \"flight_type\": \"outbound\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/checkin", - "host": ["{{baseUrl}}"], - "path": ["checkin"] - } - } - }, - { - "name": "3. Check-in 1 hanh khach", - "request": { - "method": "POST", - "header": [{"key": "Content-Type", "value": "application/json"}], - "body": { - "mode": "raw", - "raw": "{\n \"booking_code\": \"{{bookingCode}}\",\n \"passenger_id\": 1,\n \"flight_type\": \"outbound\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/checkin/passenger", - "host": ["{{baseUrl}}"], - "path": ["checkin", "passenger"] - } - } - }, - { - "name": "4. Lay boarding pass", - "request": { - "method": "GET", - "url": { - "raw": "{{baseUrl}}/checkin/{{bookingCode}}-P1/boarding-pass", - "host": ["{{baseUrl}}"], - "path": ["checkin", "{{bookingCode}}-P1", "boarding-pass"] - } - } - }, - { - "name": "5. Lay QR code (base64 image)", - "request": { - "method": "GET", - "url": { - "raw": "{{baseUrl}}/checkin/{{bookingCode}}-P1/qr", - "host": ["{{baseUrl}}"], - "path": ["checkin", "{{bookingCode}}-P1", "qr"] - } - } - } - ] - }, - { - "name": "ADMIN - Quan tri", - "item": [ - { - "name": "1. Lay danh sach booking - CAN AUTH", - "request": { - "auth": {"type": "bearer", "bearer": [{"key": "token", "value": "{{token}}", "type": "string"}]}, - "method": "GET", - "url": { - "raw": "{{baseUrl}}/admin/bookings?page=1&limit=10", - "host": ["{{baseUrl}}"], - "path": ["admin", "bookings"], - "query": [ - {"key": "page", "value": "1"}, - {"key": "limit", "value": "10"} - ] - } - } - }, - { - "name": "2. Xac nhan booking - CAN AUTH", - "request": { - "auth": {"type": "bearer", "bearer": [{"key": "token", "value": "{{token}}", "type": "string"}]}, - "method": "POST", - "url": { - "raw": "{{baseUrl}}/admin/bookings/1/confirm", - "host": ["{{baseUrl}}"], - "path": ["admin", "bookings", "1", "confirm"] - } - } - } - ] - } - ] -} diff --git a/postman/admin-coupons.postman_collection.json b/postman/admin-coupons.postman_collection.json deleted file mode 100644 index 088c5c2..0000000 --- a/postman/admin-coupons.postman_collection.json +++ /dev/null @@ -1,373 +0,0 @@ -{ - "info": { - "_postman_id": "2b3209d6-cba7-45fc-8a73-a4f34beae3e1", - "name": "Admin Coupons", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" - }, - "variable": [ - { - "key": "baseUrl", - "value": "http://localhost:3000" - }, - { - "key": "adminEmail", - "value": "" - }, - { - "key": "adminPassword", - "value": "" - }, - { - "key": "adminToken", - "value": "" - }, - { - "key": "couponId", - "value": "" - }, - { - "key": "airlineId", - "value": "1" - } - ], - "item": [ - { - "name": "Admin Login", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"{{adminEmail}}\",\n \"password\": \"{{adminPassword}}\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/auth/login", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "auth", - "login" - ] - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test(\"login success\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "const json = pm.response.json();", - "if (json.token) {", - " pm.collectionVariables.set(\"adminToken\", json.token);", - "}" - ] - } - } - ] - }, - { - "name": "List Coupons", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{adminToken}}" - } - ], - "url": { - "raw": "{{baseUrl}}/api/admin/coupons?page=1&limit=10&search=WELCOME&is_active=true", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "admin", - "coupons" - ], - "query": [ - { - "key": "page", - "value": "1" - }, - { - "key": "limit", - "value": "10" - }, - { - "key": "search", - "value": "WELCOME" - }, - { - "key": "is_active", - "value": "true" - } - ] - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test(\"list coupons success\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "const json = pm.response.json();", - "pm.test(\"response has data array\", function () {", - " pm.expect(Array.isArray(json.data)).to.eql(true);", - "});" - ] - } - } - ] - }, - { - "name": "Create Coupon", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{adminToken}}" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"airline_id\": {{airlineId}},\n \"code\": \"POSTMAN_{{$timestamp}}\",\n \"name\": \"Postman Coupon\",\n \"description\": \"Created from Postman collection\",\n \"type\": \"percent\",\n \"value\": 15,\n \"min_order\": 300000,\n \"max_discount\": 100000,\n \"start_at\": \"2026-04-01T00:00:00.000Z\",\n \"expiry_at\": \"2026-12-31T23:59:59.000Z\",\n \"usage_limit\": 50,\n \"usage_limit_per_user\": 1,\n \"welcome_only\": false,\n \"is_active\": true\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/admin/coupons", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "admin", - "coupons" - ] - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test(\"create coupon success\", function () {", - " pm.response.to.have.status(201);", - "});", - "", - "const json = pm.response.json();", - "pm.test(\"created coupon has id\", function () {", - " pm.expect(json.data.id).to.be.a(\"string\");", - "});", - "", - "if (json.data && json.data.id) {", - " pm.collectionVariables.set(\"couponId\", json.data.id);", - "}" - ] - } - } - ] - }, - { - "name": "Get Coupon By ID", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{adminToken}}" - } - ], - "url": { - "raw": "{{baseUrl}}/api/admin/coupons/{{couponId}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "admin", - "coupons", - "{{couponId}}" - ] - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test(\"get coupon detail success\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "const json = pm.response.json();", - "pm.test(\"coupon id matches variable\", function () {", - " pm.expect(json.data.id).to.eql(pm.collectionVariables.get(\"couponId\"));", - "});" - ] - } - } - ] - }, - { - "name": "Update Coupon", - "request": { - "method": "PUT", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{adminToken}}" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Updated Postman Coupon\",\n \"description\": \"Updated from Postman\",\n \"value\": 20,\n \"max_discount\": 120000,\n \"usage_limit\": 80,\n \"welcome_only\": true\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/admin/coupons/{{couponId}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "admin", - "coupons", - "{{couponId}}" - ] - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test(\"update coupon success\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "const json = pm.response.json();", - "pm.test(\"coupon updated\", function () {", - " pm.expect(json.data.name).to.eql(\"Updated Postman Coupon\");", - " pm.expect(json.data.welcome_only).to.eql(true);", - "});" - ] - } - } - ] - }, - { - "name": "Update Coupon Status", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{adminToken}}" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"is_active\": false\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/admin/coupons/{{couponId}}/status", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "admin", - "coupons", - "{{couponId}}", - "status" - ] - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test(\"update coupon status success\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "const json = pm.response.json();", - "pm.test(\"coupon status disabled\", function () {", - " pm.expect(json.coupon.is_active).to.eql(false);", - "});" - ] - } - } - ] - }, - { - "name": "Delete Coupon", - "request": { - "method": "DELETE", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{adminToken}}" - } - ], - "url": { - "raw": "{{baseUrl}}/api/admin/coupons/{{couponId}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "admin", - "coupons", - "{{couponId}}" - ] - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test(\"delete coupon success\", function () {", - " pm.response.to.have.status(200);", - "});" - ] - } - } - ] - } - ] -} diff --git a/src/app.js b/src/app.js index f4a4b15..55ac8df 100644 --- a/src/app.js +++ b/src/app.js @@ -47,7 +47,12 @@ pool.query(` const app = express(); -app.use(cors()); +app.use(cors({ + origin: '*', + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept', 'Origin'], + credentials: true +})); app.use(express.json({ limit: "10mb" })); app.use("/api/auth", authRoutes); diff --git a/src/config/refund.config.js b/src/config/refund.config.js index f876ede..2f4c36c 100644 --- a/src/config/refund.config.js +++ b/src/config/refund.config.js @@ -140,7 +140,11 @@ const DATE_CHANGE = { enabled: true, methods: ['BANK_QR', 'MOMO', 'PAYPAL'], // Các phương thức thanh toán hỗ trợ expiryMinutes: 30, // Thời hạn thanh toán (30 phút) - autoApproveOnPayment: true, // Tự động approve date change sau khi payment thành công + autoApproveOnPayment: false, // Payment success chỉ xác nhận đã nhận tiền, vẫn chờ admin duyệt + }, + + limits: { + maxApprovedChangesPerLeg: 2, }, }; diff --git a/src/controllers/admin/date-change.controller.js b/src/controllers/admin/date-change.controller.js index 2f65caa..f0b8a4a 100644 --- a/src/controllers/admin/date-change.controller.js +++ b/src/controllers/admin/date-change.controller.js @@ -89,7 +89,7 @@ const getPendingDateChanges = async (req, res) => { const total = parseInt(countResult.rows[0].count); res.json({ - message: 'Lấy danh sách đổi ngày bay chờ duyệt thành công', + message: 'Lấy danh sách đổi ngày bay chờ xử lý thành công', data: dataResult.rows, pending_count: total, pagination: { diff --git a/src/controllers/admin/price-override.controller.js b/src/controllers/admin/price-override.controller.js new file mode 100644 index 0000000..97d6b2a --- /dev/null +++ b/src/controllers/admin/price-override.controller.js @@ -0,0 +1,215 @@ +const pool = require("../../config/db"); +const { clearOverrideCache } = require("../../services/season.service"); + +const getOverrides = async (req, res) => { + try { + const { month, year, active } = req.query; + let query = ` + SELECT po.*, u.email as created_by_email + FROM price_overrides po + LEFT JOIN users u ON po.created_by = u.id + WHERE 1=1 + `; + const params = []; + + if (month) { + params.push(month); + query += ` AND EXTRACT(MONTH FROM po.date) = $${params.length}`; + } + if (year) { + params.push(year); + query += ` AND EXTRACT(YEAR FROM po.date) = $${params.length}`; + } + if (active !== undefined) { + params.push(active === "true"); + query += ` AND po.is_active = $${params.length}`; + } + + query += " ORDER BY po.date ASC"; + + const { rows } = await pool.query(query, params); + res.json({ data: rows, total: rows.length }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; + +const getOverrideById = async (req, res) => { + try { + const { rows } = await pool.query( + `SELECT po.*, u.email as created_by_email + FROM price_overrides po + LEFT JOIN users u ON po.created_by = u.id + WHERE po.id = $1`, + [req.params.id] + ); + if (rows.length === 0) { + return res.status(404).json({ error: "Không tìm thấy override" }); + } + res.json({ data: rows[0] }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; + +const createOverride = async (req, res) => { + try { + const { date, multiplier, reason, is_active = true } = req.body; + + if (!date || !multiplier) { + return res.status(400).json({ error: "date và multiplier là bắt buộc" }); + } + + const multiplierNum = parseFloat(multiplier); + if (isNaN(multiplierNum) || multiplierNum <= 0) { + return res.status(400).json({ error: "multiplier phải > 0" }); + } + + const { rows } = await pool.query( + `INSERT INTO price_overrides (date, multiplier, reason, is_active, created_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [date, multiplierNum, reason, is_active, req.user.id] + ); + + res.status(201).json({ + message: "Tạo override thành công", + data: rows[0], + }); + clearOverrideCache(); + } catch (err) { + if (err.code === "23505") { + return res.status(400).json({ error: "Ngày này đã có override active" }); + } + res.status(500).json({ error: err.message }); + } +}; + +const updateOverride = async (req, res) => { + try { + const { multiplier, reason, is_active } = req.body; + const fields = []; + const values = []; + let paramCount = 0; + + if (multiplier !== undefined) { + const multiplierNum = parseFloat(multiplier); + if (isNaN(multiplierNum) || multiplierNum <= 0) { + return res.status(400).json({ error: "multiplier phải > 0" }); + } + paramCount++; + fields.push(`multiplier = $${paramCount}`); + values.push(multiplierNum); + } + if (reason !== undefined) { + paramCount++; + fields.push(`reason = $${paramCount}`); + values.push(reason); + } + if (is_active !== undefined) { + paramCount++; + fields.push(`is_active = $${paramCount}`); + values.push(is_active); + } + + if (fields.length === 0) { + return res.status(400).json({ error: "Không có trường nào để cập nhật" }); + } + + paramCount++; + values.push(req.params.id); + fields.push(`updated_at = NOW()`); + + const { rows } = await pool.query( + `UPDATE price_overrides SET ${fields.join(", ")} WHERE id = $${paramCount} RETURNING *`, + values + ); + + if (rows.length === 0) { + return res.status(404).json({ error: "Không tìm thấy override" }); + } + + res.json({ message: "Cập nhật thành công", data: rows[0] }); + clearOverrideCache(); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; + +const deleteOverride = async (req, res) => { + try { + const { rows } = await pool.query( + "DELETE FROM price_overrides WHERE id = $1 RETURNING id", + [req.params.id] + ); + if (rows.length === 0) { + return res.status(404).json({ error: "Không tìm thấy override" }); + } + res.json({ message: "Xóa override thành công" }); + clearOverrideCache(); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; + +const bulkCreate = async (req, res) => { + try { + const { overrides } = req.body; + + if (!Array.isArray(overrides) || overrides.length === 0) { + return res.status(400).json({ error: "overrides phải là mảng không rỗng" }); + } + + const results = []; + const errors = []; + + for (const item of overrides) { + const { date, multiplier, reason } = item; + + if (!date || !multiplier) { + errors.push({ date, error: "Thiếu date hoặc multiplier" }); + continue; + } + + const multiplierNum = parseFloat(multiplier); + if (isNaN(multiplierNum) || multiplierNum <= 0) { + errors.push({ date, error: "multiplier không hợp lệ" }); + continue; + } + + try { + const { rows } = await pool.query( + `INSERT INTO price_overrides (date, multiplier, reason, created_by) + VALUES ($1, $2, $3, $4) + ON CONFLICT ON CONSTRAINT idx_price_overrides_date_active + DO UPDATE SET multiplier = EXCLUDED.multiplier, reason = EXCLUDED.reason, updated_at = NOW() + RETURNING *`, + [date, multiplierNum, reason, req.user.id] + ); + results.push(rows[0]); + } catch (err) { + errors.push({ date, error: err.message }); + } + } + + res.status(201).json({ + message: `Tạo ${results.length}/${overrides.length} overrides`, + created: results.length, + failed: errors.length, + data: results, + errors: errors.length > 0 ? errors : undefined, + }); + clearOverrideCache(); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; + +module.exports = { + getOverrides, + getOverrideById, + createOverride, + updateOverride, + deleteOverride, + bulkCreate, +}; diff --git a/src/controllers/date-change.controller.js b/src/controllers/date-change.controller.js index f1ee880..0ce0717 100644 --- a/src/controllers/date-change.controller.js +++ b/src/controllers/date-change.controller.js @@ -141,7 +141,10 @@ const confirmDateChange = async (req, res) => { } const result = await dateChangeService.confirmDateChange(email, otp, requestCode); - res.json(result); + res.json({ + message: result.message, + data: result, + }); } catch (err) { console.error('[ConfirmDateChange]', err.message); res.status(400).json({ error: err.message }); diff --git a/src/controllers/flight.controller.js b/src/controllers/flight.controller.js index a961f2f..3a846dc 100644 --- a/src/controllers/flight.controller.js +++ b/src/controllers/flight.controller.js @@ -192,6 +192,88 @@ const getFlightPosition = async (req, res) => { } }; +/** + * ========================================================== + * PRICE ALERT CONTROLLERS + * ========================================================== + */ + +/** + * GET /api/flights/price-analysis?departure_date=2026-06-15&base_price=1000000&available_seats=50&total_seats=180 + * Phân tích giá dựa trên params (không cần flight ID) + */ +const getPriceAnalysis = async (req, res) => { + try { + const { departure_date, base_price, available_seats, total_seats } = req.query; + + // Validation + if (!departure_date) { + return res.status(400).json({ error: "departure_date là bắt buộc" }); + } + if (!base_price || parseFloat(base_price) <= 0) { + return res.status(400).json({ error: "base_price phải > 0" }); + } + + const { getDetailedAnalysis } = require('../services/price-alert.service'); + + // Tạo flight object giả lập để phân tích + const mockFlight = { + id: null, + departure_time: departure_date, + base_price: parseFloat(base_price), + available_seats: parseInt(available_seats) || 0, + total_seats: parseInt(total_seats) || 1, + }; + + const analysis = await getDetailedAnalysis(mockFlight); + + res.json({ + message: "Phân tích giá thành công", + data: analysis + }); + } catch (err) { + console.error("[getPriceAnalysis]", err.message); + res.status(500).json({ error: err.message }); + } +}; + +/** + * GET /api/flights/:id/price-analysis + * Phân tích chi tiết giá cho một flight cụ thể + */ +const getFlightPriceAnalysis = async (req, res) => { + try { + const { id } = req.params; + const { adults, children, infants } = req.query; + + if (!id || isNaN(Number(id))) { + return res.status(400).json({ error: "Flight ID không hợp lệ" }); + } + + // Lấy flight từ DB + const flight = await flightService.getFlightById(Number(id), { + adults: adults || 1, + children: children || 0, + infants: infants || 0, + }); + + // Get detailed analysis + const { getDetailedAnalysis } = require('../services/price-alert.service'); + const analysis = await getDetailedAnalysis(flight); + + res.json({ + message: "Phân tích giá thành công", + data: analysis + }); + } catch (err) { + console.error("[getFlightPriceAnalysis]", err.message); + if (err.message === "Không tìm thấy chuyến bay") { + return res.status(404).json({ error: err.message }); + } + res.status(500).json({ error: err.message }); + } +}; + module.exports = { searchFlights, getAirports, @@ -203,6 +285,9 @@ module.exports = { getSeatMap, getFlightRecommendations, getFlightPosition, + // Price analysis exports + getPriceAnalysis, + getFlightPriceAnalysis, browseFlights: async (req, res) => { try { const limit = parseInt(req.query.limit) || 40; diff --git a/src/migrations/012_add_date_change_payment_columns.sql b/src/migrations/012_add_date_change_payment_columns.sql index 3fc5c49..35b7bd1 100644 --- a/src/migrations/012_add_date_change_payment_columns.sql +++ b/src/migrations/012_add_date_change_payment_columns.sql @@ -1,9 +1,11 @@ --- MIGRATION 012: Add payment_id to date_change_requests +-- MIGRATION 012: Add payment linkage to date_change_requests -- For linking date change requests to their payment records --- Add payment_id column +-- Add payment_id column as text reference because payments.id schema is not defined by tracked migrations. +-- Application code already compares payments.id via ::text, so storing the linkage as text keeps V1 safe +-- until the payments table schema is formalized in versioned migrations. ALTER TABLE date_change_requests -ADD COLUMN IF NOT EXISTS payment_id UUID REFERENCES payments(id); +ADD COLUMN IF NOT EXISTS payment_id VARCHAR(64); -- Add payment_code column for easier lookup ALTER TABLE date_change_requests diff --git a/src/migrations/019_align_date_change_v1_schema.sql b/src/migrations/019_align_date_change_v1_schema.sql new file mode 100644 index 0000000..4a10058 --- /dev/null +++ b/src/migrations/019_align_date_change_v1_schema.sql @@ -0,0 +1,45 @@ +-- MIGRATION 019: Align date change V1 schema with per-leg workflow + +-- 1) Add flight_leg for per-leg request tracking +ALTER TABLE date_change_requests +ADD COLUMN IF NOT EXISTS flight_leg VARCHAR(20); + +UPDATE date_change_requests +SET flight_leg = COALESCE(NULLIF(TRIM(flight_leg), ''), 'outbound') +WHERE flight_leg IS NULL OR TRIM(flight_leg) = ''; + +ALTER TABLE date_change_requests +ALTER COLUMN flight_leg SET NOT NULL; + +-- 2) Normalize allowed statuses for V1 +ALTER TABLE date_change_requests +DROP CONSTRAINT IF EXISTS chk_date_change_status; + +ALTER TABLE date_change_requests +ADD CONSTRAINT chk_date_change_status + CHECK (status IN ('pending_otp', 'pending_payment', 'pending', 'approved', 'rejected', 'completed', 'cancelled')); + +-- 3) Add flight_leg validation +ALTER TABLE date_change_requests +DROP CONSTRAINT IF EXISTS chk_date_change_flight_leg; + +ALTER TABLE date_change_requests +ADD CONSTRAINT chk_date_change_flight_leg + CHECK (flight_leg IN ('outbound')); + +-- 4) Replace booking-wide active-request uniqueness with per-leg uniqueness +DROP INDEX IF EXISTS idx_date_changes_unique_pending; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_date_changes_unique_pending_leg + ON date_change_requests (booking_id, flight_leg) + WHERE status IN ('pending_otp', 'pending_payment', 'pending'); + +-- 5) Helpful lookup indexes for leg-based filtering +CREATE INDEX IF NOT EXISTS idx_date_changes_booking_leg + ON date_change_requests (booking_id, flight_leg); + +CREATE INDEX IF NOT EXISTS idx_date_changes_status_leg + ON date_change_requests (status, flight_leg); + +COMMENT ON COLUMN date_change_requests.flight_leg IS 'Stable booking leg identifier for date-change V1 (currently outbound only)'; +COMMENT ON COLUMN date_change_requests.status IS 'pending_otp | pending_payment | pending | approved | rejected | completed | cancelled'; diff --git a/src/migrations/024_create_season_holiday_config.sql b/src/migrations/024_create_season_holiday_config.sql new file mode 100644 index 0000000..565429a --- /dev/null +++ b/src/migrations/024_create_season_holiday_config.sql @@ -0,0 +1,117 @@ +-- ========================================================== +-- Migration: 024_create_season_holiday_config +-- Tạo bảng cho season/holiday configuration và price history +-- ========================================================== + +-- ===================================================== +-- 1. SEASON PERIODS - Mùa cao điểm theo khoảng thời gian +-- ===================================================== +CREATE TABLE IF NOT EXISTS season_periods ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + start_month INT NOT NULL, + start_day INT, + end_month INT NOT NULL, + end_day INT, + multiplier DECIMAL(3,2) DEFAULT 1.20, + reason VARCHAR(255), + priority INT DEFAULT 0, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + + CONSTRAINT chk_month_range CHECK (start_month BETWEEN 1 AND 12 AND end_month BETWEEN 1 AND 12), + CONSTRAINT chk_day_range CHECK ( + (start_day IS NULL OR (start_day BETWEEN 1 AND 31)) AND + (end_day IS NULL OR (end_day BETWEEN 1 AND 31)) + ) +); + +COMMENT ON TABLE season_periods IS 'Khoảng thời gian mùa cao điểm (VD: Mùa hè 1/6-31/8)'; +COMMENT ON COLUMN season_periods.multiplier IS 'Hệ số nhân giá cho mùa này (1.0 = off-peak, 1.5 = peak cao)'; + +-- ===================================================== +-- 2. HOLIDAYS - Ngày lễ cụ thể +-- ===================================================== +CREATE TABLE IF NOT EXISTS holidays ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + date DATE NOT NULL, + year INT, + month INT GENERATED ALWAYS AS (EXTRACT(MONTH FROM date)) STORED, + day INT GENERATED ALWAYS AS (EXTRACT(DAY FROM date)) STORED, + multiplier DECIMAL(3,2) DEFAULT 1.20, + reason VARCHAR(255), + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + + CONSTRAINT uq_holiday_date_year UNIQUE (date, year) +); + +COMMENT ON TABLE holidays IS 'Ngày lễ cụ thể (VD: 25/12/2026 Giáng Sinh)'; +COMMENT ON COLUMN holidays.year IS 'Năm cụ thể. NULL = lặp lại hàng năm (VD: 25/12)'; + +-- ===================================================== +-- 3. PRICE HISTORY - Snapshot giá theo ngày (optional) +-- ===================================================== +CREATE TABLE IF NOT EXISTS price_history ( + id SERIAL PRIMARY KEY, + flight_id INT NOT NULL REFERENCES flights(id) ON DELETE CASCADE, + seat_class VARCHAR(20) DEFAULT 'economy', + base_price INT NOT NULL, + calculated_price INT NOT NULL, + available_seats INT, + total_seats INT, + occupancy_rate DECIMAL(5,2), + day_of_week_mult DECIMAL(3,2), + advance_mult DECIMAL(3,2), + demand_mult DECIMAL(3,2), + season_mult DECIMAL(3,2) DEFAULT 1.00, + holiday_mult DECIMAL(3,2) DEFAULT 1.00, + record_date DATE NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + + CONSTRAINT uq_price_history_flight_date UNIQUE (flight_id, seat_class, record_date), + CONSTRAINT chk_valid_prices CHECK (calculated_price > 0 AND base_price > 0) +); + +COMMENT ON TABLE price_history IS 'Lịch sử giá theo ngày - dùng để phân tích trend (optional)'; + +-- ===================================================== +-- 4. INDEXES +-- ===================================================== +CREATE INDEX IF NOT EXISTS idx_season_active ON season_periods(is_active) WHERE is_active = true; +CREATE INDEX IF NOT EXISTS idx_holidays_date ON holidays(date); +CREATE INDEX IF NOT EXISTS idx_holidays_reusable ON holidays(year) WHERE year IS NULL; +CREATE INDEX IF NOT EXISTS idx_price_history_flight ON price_history(flight_id, record_date DESC); +CREATE INDEX IF NOT EXISTS idx_price_history_date ON price_history(record_date); + +-- ===================================================== +-- 5. SEED DATA - Season & Holidays 2026 (idempotent) +-- ===================================================== +INSERT INTO season_periods (name, start_month, start_day, end_month, end_day, multiplier, reason, priority) VALUES + ('Mùa hè', 6, 1, 8, 31, 1.30, 'học sinh nghỉ hè', 100) +ON CONFLICT DO NOTHING; + +INSERT INTO season_periods (name, start_month, start_day, end_month, end_day, multiplier, reason, priority) VALUES + ('Mùa thu', 9, 1, 9, 30, 1.00, 'mùa thu nhẹ nhàng', 0) +ON CONFLICT DO NOTHING; + +-- Holidays - lặp lại hàng năm +INSERT INTO holidays (name, date, year, multiplier, reason) VALUES + ('Tết Dương Lịch', '2026-01-01', NULL, 1.15, 'ngày đầu năm mới'), + ('Giáng Sinh', '2026-12-25', NULL, 1.30, 'lễ Giáng Sinh') +ON CONFLICT (date, year) DO UPDATE SET + multiplier = EXCLUDED.multiplier, + reason = EXCLUDED.reason; + +-- Holidays - ngày cố định trong năm 2026 +INSERT INTO holidays (name, date, year, multiplier, reason) VALUES + ('Giao thừa', '2026-02-16', 2026, 1.40, 'đêm Giao thừa Tết'), + ('Tết Nguyên Đán', '2026-02-17', 2026, 1.30, 'Tết cổ truyền'), + ('Tết Nguyên Đán', '2026-02-18', 2026, 1.30, 'Tết cổ truyền'), + ('Tết Nguyên Đán', '2026-02-19', 2026, 1.30, 'Tết cổ truyền') +ON CONFLICT (date, year) DO UPDATE SET + multiplier = EXCLUDED.multiplier, + reason = EXCLUDED.reason; diff --git a/src/migrations/025_create_holiday_rules.sql b/src/migrations/025_create_holiday_rules.sql new file mode 100644 index 0000000..cd0e385 --- /dev/null +++ b/src/migrations/025_create_holiday_rules.sql @@ -0,0 +1,49 @@ +-- ========================================================== +-- Migration: 025_create_holiday_rules +-- Thêm bảng recurring holiday rules cho solar/lunar/offset events +-- ========================================================== + +CREATE TABLE IF NOT EXISTS holiday_rules ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + rule_type VARCHAR(30) NOT NULL DEFAULT 'single_day', + calendar_type VARCHAR(20) NOT NULL DEFAULT 'solar', + anchor_month INT NOT NULL, + anchor_day INT NOT NULL, + anchor_is_leap_month BOOLEAN DEFAULT false, + offset_days INT DEFAULT 0, + multiplier DECIMAL(3,2) DEFAULT 1.20, + reason VARCHAR(255), + priority INT DEFAULT 0, + group_key VARCHAR(100), + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + + CONSTRAINT chk_holiday_rule_type CHECK (rule_type IN ('single_day', 'offset_from_anchor')), + CONSTRAINT chk_holiday_calendar_type CHECK (calendar_type IN ('solar', 'lunar')), + CONSTRAINT chk_holiday_anchor_month CHECK (anchor_month BETWEEN 1 AND 12), + CONSTRAINT chk_holiday_anchor_day CHECK (anchor_day BETWEEN 1 AND 30) +); + +COMMENT ON TABLE holiday_rules IS 'Recurring holiday rules cho các sự kiện solar/lunar/offset như Quốc khánh, Trung Thu, Tết'; +COMMENT ON COLUMN holiday_rules.rule_type IS 'single_day hoặc offset_from_anchor'; +COMMENT ON COLUMN holiday_rules.calendar_type IS 'solar hoặc lunar'; +COMMENT ON COLUMN holiday_rules.group_key IS 'Dùng để gom holiday family như tet'; + +CREATE INDEX IF NOT EXISTS idx_holiday_rules_active + ON holiday_rules(is_active, priority DESC) + WHERE is_active = true; + +INSERT INTO holiday_rules ( + name, rule_type, calendar_type, anchor_month, anchor_day, anchor_is_leap_month, + offset_days, multiplier, reason, priority, group_key +) VALUES + ('Quốc khánh', 'single_day', 'solar', 9, 2, false, 0, 1.35, 'kỳ nghỉ Quốc khánh', 200, 'national_day'), + ('Giáng Sinh', 'single_day', 'solar', 12, 25, false, 0, 1.20, 'lễ Giáng Sinh', 120, 'christmas'), + ('Trung Thu', 'single_day', 'lunar', 8, 15, false, 0, 1.18, 'dịp Trung Thu', 110, 'mid_autumn'), + ('Tết Nguyên Đán', 'single_day', 'lunar', 1, 1, false, 0, 1.35, 'mùng 1 Tết cổ truyền', 300, 'tet'), + ('Giao thừa', 'offset_from_anchor', 'lunar', 1, 1, false, -1, 1.40, 'đêm Giao thừa Tết', 320, 'tet'), + ('Tết Nguyên Đán', 'offset_from_anchor', 'lunar', 1, 1, false, 1, 1.30, 'mùng 2 Tết cổ truyền', 290, 'tet'), + ('Tết Nguyên Đán', 'offset_from_anchor', 'lunar', 1, 1, false, 2, 1.28, 'mùng 3 Tết cổ truyền', 280, 'tet') +ON CONFLICT DO NOTHING; diff --git a/src/migrations/025_create_price_overrides.sql b/src/migrations/025_create_price_overrides.sql new file mode 100644 index 0000000..d0da760 --- /dev/null +++ b/src/migrations/025_create_price_overrides.sql @@ -0,0 +1,25 @@ +-- Migration: 025_create_price_overrides +-- Bảng cho phép admin tùy chỉnh giá theo ngày cụ thể (override hoàn toàn season/holiday logic) + +-- Price Overrides - cho phép admin set giá custom cho ngày bất kỳ +CREATE TABLE IF NOT EXISTS price_overrides ( + id SERIAL PRIMARY KEY, + date DATE NOT NULL, + multiplier DECIMAL(4,2) NOT NULL CHECK (multiplier > 0), + reason VARCHAR(255), + is_active BOOLEAN DEFAULT true, + created_by BIGINT REFERENCES users(id), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Mỗi ngày chỉ có 1 override active +CREATE UNIQUE INDEX IF NOT EXISTS idx_price_overrides_date_active + ON price_overrides(date) WHERE is_active = true; + +-- Index cho truy vấn nhanh theo ngày +CREATE INDEX IF NOT EXISTS idx_price_overrides_date ON price_overrides(date); + +-- Comment +COMMENT ON TABLE price_overrides IS 'Admin override giá cho ngày cụ thể. Override > Holiday > Season.'; +COMMENT ON COLUMN price_overrides.multiplier IS 'Hệ số nhân. VD: 1.50 = tăng 50%, 0.80 = giảm 20%'; diff --git a/src/providers/paypal.provider.js b/src/providers/paypal.provider.js index 7688e4d..2f79519 100644 --- a/src/providers/paypal.provider.js +++ b/src/providers/paypal.provider.js @@ -22,6 +22,33 @@ const getBaseUrl = () => ? 'https://api-m.paypal.com' : 'https://api-m.sandbox.paypal.com'; +const isPlaceholderValue = (value = '') => { + const normalized = String(value || '').trim().toLowerCase(); + return ( + !normalized || + normalized === 'your_paypal_client_id' || + normalized === 'your_paypal_client_secret' + ); +}; + +const logPayPalConfigDebug = (stage) => { + const clientId = String(config.paypal.clientId || '').trim(); + const clientSecret = String(config.paypal.clientSecret || '').trim(); + + console.log('[PayPal Debug]', { + stage, + env: config.paypal.env, + baseUrl: getBaseUrl(), + enabled: Boolean(config.paypal.enabled), + hasClientId: Boolean(clientId), + hasClientSecret: Boolean(clientSecret), + clientIdLength: clientId.length, + clientSecretLength: clientSecret.length, + clientIdPlaceholder: isPlaceholderValue(clientId), + clientSecretPlaceholder: isPlaceholderValue(clientSecret), + }); +}; + const getFrontendResultBaseUrl = () => config.paypal.frontendUrl ? `${config.paypal.frontendUrl}/payment/paypal/result` @@ -64,6 +91,8 @@ const getAccessToken = async () => { return accessTokenCache.token; } + logPayPalConfigDebug('getAccessToken:start'); + const clientId = getRequiredConfig('PAYPAL_CLIENT_ID', config.paypal.clientId); const clientSecret = getRequiredConfig('PAYPAL_CLIENT_SECRET', config.paypal.clientSecret); @@ -79,6 +108,12 @@ const getAccessToken = async () => { const payload = await parseJsonSafe(response); if (!response.ok || !payload.access_token) { + console.error('[PayPal Debug] OAuth failed', { + status: response.status, + statusText: response.statusText, + error: payload.error || null, + errorDescription: payload.error_description || null, + }); throw new Error(payload.error_description || payload.error || 'Cannot authenticate with PayPal'); } @@ -181,17 +216,17 @@ const createPayPalOrder = async (payment) => { }, }, ], - payment_source: { - paypal: { - experience_context: { - brand_name: config.paypal.brandName || 'FlightBooking', - user_action: 'PAY_NOW', - return_url: returnUrl, - cancel_url: cancelUrl, - }, - }, - }, }, + }).catch(async (err) => { + console.error('[PayPal Debug] createOrder failed', { + error: err.message, + paymentCode, + amountValue, + currency, + returnUrl, + cancelUrl, + }); + throw err; }); const approveUrl = findLink(response.links, 'approve') || findLink(response.links, 'payer-action'); diff --git a/src/queries/date-change.queries.js b/src/queries/date-change.queries.js index 0bae2a7..95e611a 100644 --- a/src/queries/date-change.queries.js +++ b/src/queries/date-change.queries.js @@ -24,9 +24,10 @@ const INSERT_DATE_CHANGE = ` price_difference, status, reason, - requested_by + requested_by, + flight_leg ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING * `; @@ -42,6 +43,7 @@ const SELECT_DATE_CHANGE_BY_CODE = ` u.full_name AS user_name, u.email AS user_email, admin.full_name AS admin_name, + p.status AS payment_status, -- Old flight info old_f.flight_number AS old_flight_number, @@ -70,6 +72,7 @@ const SELECT_DATE_CHANGE_BY_CODE = ` JOIN airports new_arr ON new_f.arrival_airport_id = new_arr.id LEFT JOIN users u ON dcr.requested_by = u.id LEFT JOIN users admin ON dcr.processed_by = admin.id + LEFT JOIN payments p ON dcr.payment_id = p.id WHERE dcr.request_code = $1 `; @@ -116,38 +119,41 @@ const SELECT_PENDING_DATE_CHANGES = ` SELECT dcr.request_code, dcr.status, + dcr.flight_leg, dcr.price_difference, dcr.created_at, + dcr.paid_at, b.booking_code, b.contact_name, b.contact_email, u.full_name AS user_name, old_f.flight_number AS old_flight_number, new_f.flight_number AS new_flight_number, - old_dep.code AS old_departure_code, - new_dep.code AS new_departure_code + p.status AS payment_status FROM date_change_requests dcr JOIN bookings b ON dcr.booking_id = b.id JOIN flights old_f ON dcr.old_flight_id = old_f.id JOIN flights new_f ON dcr.new_flight_id = new_f.id - JOIN airports old_dep ON old_f.departure_airport_id = old_dep.id - JOIN airports new_dep ON new_f.departure_airport_id = new_dep.id LEFT JOIN users u ON dcr.requested_by = u.id - WHERE dcr.status = 'pending' - ORDER BY dcr.created_at ASC + LEFT JOIN payments p ON dcr.payment_id = p.id + WHERE dcr.status IN ('pending_payment', 'pending') + ORDER BY + CASE WHEN dcr.status = 'pending' THEN 0 ELSE 1 END, + dcr.created_at ASC LIMIT $1 OFFSET $2 `; const COUNT_PENDING_DATE_CHANGES = ` SELECT COUNT(*) FROM date_change_requests - WHERE status = 'pending' + WHERE status IN ('pending_payment', 'pending') `; const SELECT_DATE_CHANGES_ADMIN = (whereClause, idx, idx2) => ` SELECT dcr.request_code, dcr.status, + dcr.flight_leg, dcr.old_seat_class, dcr.new_seat_class, dcr.old_price, @@ -157,19 +163,24 @@ const SELECT_DATE_CHANGES_ADMIN = (whereClause, idx, idx2) => ` dcr.admin_notes, dcr.created_at, dcr.processed_at, + dcr.paid_at, b.booking_code, b.contact_name, b.contact_email, u.full_name AS user_name, old_f.flight_number AS old_flight_number, + old_f.departure_time AS old_departure_time, new_f.flight_number AS new_flight_number, - admin.full_name AS processed_by_name + new_f.departure_time AS new_departure_time, + admin.full_name AS processed_by_name, + p.status AS payment_status FROM date_change_requests dcr JOIN bookings b ON dcr.booking_id = b.id JOIN flights old_f ON dcr.old_flight_id = old_f.id JOIN flights new_f ON dcr.new_flight_id = new_f.id LEFT JOIN users u ON dcr.requested_by = u.id LEFT JOIN users admin ON dcr.processed_by = admin.id + LEFT JOIN payments p ON dcr.payment_id = p.id ${whereClause} ORDER BY dcr.created_at DESC LIMIT $${idx} OFFSET $${idx2} @@ -230,10 +241,19 @@ const CHECK_DATE_CHANGE_EXISTS_BY_CODE = ` const CHECK_PENDING_DATE_CHANGE_FOR_BOOKING = ` SELECT id FROM date_change_requests WHERE booking_id = $1 - AND status IN ('pending', 'pending_otp') + AND flight_leg = $2 + AND status IN ('pending', 'pending_otp', 'pending_payment') LIMIT 1 `; +const COUNT_APPROVED_DATE_CHANGES_FOR_LEG = ` + SELECT COUNT(*) + FROM date_change_requests + WHERE booking_id = $1 + AND flight_leg = $2 + AND status = 'approved' +`; + // ========================================================= // BOOKING UPDATE (for approved date change) // ========================================================= @@ -297,7 +317,7 @@ const UPDATE_DATE_CHANGE_PAYMENT_ID = ` const UPDATE_DATE_CHANGE_PAID = ` UPDATE date_change_requests SET - status = 'approved', + status = 'pending', paid_at = NOW(), updated_at = NOW() WHERE payment_code = $1 @@ -339,4 +359,5 @@ module.exports = { // Checks CHECK_DATE_CHANGE_EXISTS_BY_CODE, CHECK_PENDING_DATE_CHANGE_FOR_BOOKING, + COUNT_APPROVED_DATE_CHANGES_FOR_LEG, }; diff --git a/src/routes/admin.routes.js b/src/routes/admin.routes.js index e13ce14..13acc45 100644 --- a/src/routes/admin.routes.js +++ b/src/routes/admin.routes.js @@ -15,6 +15,7 @@ const adminUserController = require("../controllers/admin/user.controller"); const adminChatController = require("../controllers/admin/chat.controller"); const adminCronController = require("../controllers/admin/cron.controller"); const adminAutoFlightController = require("../controllers/admin/auto-flight.controller"); +const adminPriceOverrideController = require("../controllers/admin/price-override.controller"); // Tất cả routes admin: phải đăng nhập + role = 'admin' router.use(authenticate, authorize("admin")); @@ -112,6 +113,14 @@ router.get("/chat/conversations/:id", adminChatController.getSupportConversation router.post("/chat/conversations/:id/message", adminChatController.replySupportConversation); router.patch("/chat/conversations/:id/status", adminChatController.updateSupportConversationStatus); +// A-13: Price Overrides +router.get("/price-overrides", adminPriceOverrideController.getOverrides); +router.get("/price-overrides/:id", adminPriceOverrideController.getOverrideById); +router.post("/price-overrides", adminPriceOverrideController.createOverride); +router.put("/price-overrides/:id", adminPriceOverrideController.updateOverride); +router.delete("/price-overrides/:id", adminPriceOverrideController.deleteOverride); +router.post("/price-overrides/bulk", adminPriceOverrideController.bulkCreate); + // ── Newsletter ──────────────────────────────────────────────────────────────── const { sendNewsletterBroadcast } = require('../utils/mailer'); const pool = require('../config/db'); diff --git a/src/routes/flight.routes.js b/src/routes/flight.routes.js index 30e3c6d..030bb48 100644 --- a/src/routes/flight.routes.js +++ b/src/routes/flight.routes.js @@ -11,10 +11,10 @@ router.get("/alternatives", flightController.getAlternativeFlights); router.get("/combo", flightController.getFlightCombos); router.get("/price-calendar", flightController.getPriceCalendar); router.get("/recommendations", flightController.getFlightRecommendations); -router.get("/browse", flightController.browseFlights); -router.get("/by-airline/:code", flightController.getFlightsByAirline); +router.get("/browse", flightController.browseFlights); router.get("/:id/seat-map", flightController.getSeatMap); router.get("/:id/position", flightController.getFlightPosition); +router.get("/:id/price-analysis", flightController.getFlightPriceAnalysis); // Flight-specific price analysis router.get("/:id", flightController.getFlightById); module.exports = router; \ No newline at end of file diff --git a/src/routes/payment.routes.js b/src/routes/payment.routes.js index adb5941..bd5d321 100644 --- a/src/routes/payment.routes.js +++ b/src/routes/payment.routes.js @@ -174,7 +174,8 @@ router.get("/return/momo", async (req, res) => { // Handle date change payment return const result = await dateChangeService.confirmDateChangePayment(paymentCode); const params = new URLSearchParams({ - status: result.status === 'approved' ? 'success' : 'pending', + status: result.status === 'pending' ? 'success' : result.status, + paymentStatus: result.payment_status || 'SUCCESS', paymentCode: paymentCode, requestCode: result.request_code || '', }); @@ -210,7 +211,8 @@ router.get("/return/payos/:status", async (req, res) => { // Handle date change payment return const result = await dateChangeService.confirmDateChangePayment(paymentCode); const params = new URLSearchParams({ - status: result.status === 'approved' ? 'success' : 'pending', + status: result.status === 'pending' ? 'success' : result.status, + paymentStatus: result.payment_status || 'SUCCESS', paymentCode: paymentCode, requestCode: result.request_code || '', }); @@ -242,10 +244,38 @@ router.get("/return/paypal", async (req, res) => { const paymentCode = String(req.query.payment_code || '').trim(); if (isDateChangePayment(paymentCode)) { - // Handle date change payment return - const result = await dateChangeService.confirmDateChangePayment(paymentCode); + const orderId = String(req.query.token || req.query.orderId || '').trim(); + const payment = await paymentService.getPaymentByCode(paymentCode); + const gatewayResponse = payment?.gateway_response || {}; + const effectiveOrderId = orderId || gatewayResponse.order_id || gatewayResponse.provider_order_code || ''; + + if (!effectiveOrderId) { + throw new Error('Missing PayPal order token for date change payment'); + } + + const capture = await paymentService.handlePaypalReturn({ + ...req.query, + payment_code: paymentCode, + token: effectiveOrderId, + }); + + const result = await dateChangeService.confirmDateChangePayment(paymentCode, { + trustedPaid: true, + gatewayPayload: { + ...gatewayResponse, + provider: 'PAYPAL', + order_id: effectiveOrderId, + provider_order_code: effectiveOrderId, + capture_id: capture.capture_id || gatewayResponse.capture_id || null, + capture_status: 'COMPLETED', + approve_url: null, + redirect_url: null, + returnedAt: new Date().toISOString(), + }, + }); const params = new URLSearchParams({ - status: result.status === 'approved' ? 'success' : 'pending', + status: result.status === 'pending' ? 'success' : result.status, + paymentStatus: result.payment_status || 'SUCCESS', paymentCode: paymentCode, requestCode: result.request_code || '', }); diff --git a/src/services/ancillary.service.js b/src/services/ancillary.service.js index 9814a6b..07af3c5 100644 --- a/src/services/ancillary.service.js +++ b/src/services/ancillary.service.js @@ -1,13 +1,22 @@ -"use strict"; - -const pool = require("../config/db"); -const Q = require("../queries/ancillary.queries"); - -// ─── Helpers ────────────────────────────────────────────────────────────────── +/* +============================================================ +ANCILLARY SERVICE - Dịch vụ bổ sung (meal, baggage, insurance...) +============================================================ + +Các loại dịch vụ: +- meal: Bữa ăn +- baggage: Hành lý thêm +- insurance: Bảo hiểm +- lounge: Phòng chờ +- wifi: Wifi trên máy bay +============================================================ +*/ + +// Helpers const VALID_TYPES = ["meal", "baggage", "insurance", "lounge", "wifi"]; -// Group ancillaries theo passenger để trả về cho FE dễ dùng +// Nhóm ancillaries theo passenger const groupByPassenger = (rows) => { const map = {}; for (const r of rows) { @@ -40,10 +49,7 @@ const groupByPassenger = (rows) => { // ─── Exported Functions ─────────────────────────────────────────────────────── -/** - * Lấy danh sách dịch vụ bổ sung có thể chọn - * SB-04 Step 2: Hệ thống load danh sách dịch vụ - */ +// Lấy danh sách dịch vụ có thể chọn const getAncillaryOptions = async (type = null) => { if (type && !VALID_TYPES.includes(type)) { throw new Error(`type phải là một trong: ${VALID_TYPES.join(", ")}`); @@ -71,9 +77,7 @@ const getAncillaryOptions = async (type = null) => { }; }; -/** - * Lấy danh sách ancillaries đã chọn của 1 booking - */ +// Lấy ancillaries đã chọn của 1 booking const getBookingAncillaries = async (bookingId) => { const rows = await pool.query(Q.GET_ANCILLARIES_BY_BOOKING, [bookingId]); const totalResult = await pool.query(Q.GET_ANCILLARY_TOTAL, [bookingId]); @@ -85,11 +89,7 @@ const getBookingAncillaries = async (bookingId) => { }; }; -/** - * Thêm dịch vụ bổ sung cho 1 hành khách - * SB-04 Step 3: User chọn dịch vụ - * SB-04 Step 4: Hệ thống tính lại tổng tiền - */ +// Thêm dịch vụ cho 1 hành khách const addAncillary = async (bookingId, data) => { const { passenger_id, diff --git a/src/services/auth.service.js b/src/services/auth.service.js index 4578da2..b4dca16 100644 --- a/src/services/auth.service.js +++ b/src/services/auth.service.js @@ -1,3 +1,23 @@ +/* +============================================================ +AUTH SERVICE - Đăng nhập, đăng ký, quản lý tài khoản +============================================================ + +Các chức năng chính: +- Đăng ký tài khoản mới (có OTP xác thực email) +- Đăng nhập (email/password) +- Social login (Google/Facebook) +- Quên mật khẩu / Reset mật khẩu +- Refresh token / Logout +- Cập nhật profile + +Token system: +- Access token: JWT, dùng để xác thực API (ngắn hạn) +- Refresh token: Lưu trong DB, dùng để lấy access token mới (dài hạn) +- Mỗi lần refresh sẽ revoke token cũ và tạo token mới +============================================================ +*/ + const { sendOTPEmail } = require("../utils/mailer"); const { generateOTP } = require("../utils/otp"); const pool = require("../config/db"); @@ -11,10 +31,13 @@ const { const QU = require("../queries/user.queries"); const QA = require("../queries/auth.queries"); +// Hủy toàn bộ refresh tokens của user (dùng khi đổi mật khẩu hoặc logout toàn bộ) const revokeAllUserRefreshTokens = async (userId, db = pool) => { await db.query(QA.REVOKE_ALL_REFRESH_TOKENS, [userId]); }; +// Tạo access token + refresh token mới cho user +// Refresh token cũ sẽ bị xóa khỏi DB const issueAuthTokens = async (user, db = pool) => { const accessToken = generateAccessToken(user); const refreshTokenData = generateRefreshToken(user); @@ -31,6 +54,8 @@ const issueAuthTokens = async (user, db = pool) => { }; }; +// Dùng refresh token để lấy access token mới +// Token cũ sẽ bị revoke sau khi lấy token mới const refreshUserSession = async (refreshToken) => { if (!refreshToken) throw new Error("refresh_token is required"); @@ -63,6 +88,8 @@ const refreshUserSession = async (refreshToken) => { } }; +// Đăng xuất - hủy refresh token +// Nếu không truyền refreshToken thì hủy toàn bộ tokens của user const logoutUserSession = async (userId, refreshToken = null) => { if (!userId) throw new Error("userId is required"); @@ -75,6 +102,10 @@ const logoutUserSession = async (userId, refreshToken = null) => { return true; }; +// Đăng ký tài khoản mới +// - Hash password +// - Tạo user record +// - Gửi OTP qua email để xác thực const registerUser = async (data) => { const { full_name, email, phone, password, confirm_password } = data; @@ -84,9 +115,11 @@ const registerUser = async (data) => { if (password.length < 8) throw new Error("Password must be at least 8 characters"); if (password !== confirm_password) throw new Error("Password and confirm password do not match"); + // Kiểm tra email đã tồn tại chưa const existingEmail = await pool.query(QU.FIND_USER_EMAIL_AUTH, [email]); if (existingEmail.rows.length > 0) { const existing = existingEmail.rows[0]; + // Nếu account dùng social login (không có password) if (!existing.password_hash) { throw new Error( "This email is already linked to a social account (Google/Facebook). Please sign in with social login, then set a password from your profile if needed." @@ -95,18 +128,22 @@ const registerUser = async (data) => { throw new Error("Email already exists"); } + // Kiểm tra phone đã tồn tại chưa if (phone) { const existingPhone = await pool.query(QU.FIND_USER_BY_PHONE, [phone]); if (existingPhone.rows.length > 0) throw new Error("Phone already exists"); } + // Hash password và tạo user const passwordHash = await hashPassword(password); const userResult = await pool.query(QU.INSERT_USER, [full_name, email, phone || null, passwordHash]); const user = userResult.rows[0]; const otp = generateOTP(); + // Lưu OTP để xác thực sau await pool.query(QA.INSERT_REGISTER_OTP, [user.id, otp]); + // Gửi email (không block nếu thất bại) try { sendOTPEmail(user.email, otp); } catch (err) { @@ -116,6 +153,9 @@ const registerUser = async (data) => { return { user, otp }; }; +// Đăng nhập bằng email + password +// Kiểm tra: tài khoản active, email đã verify, chưa bị lock +// Sau 5 lần sai sẽ bị lock 15 phút const loginUser = async (data) => { const { email, password } = data; if (!email || !password) throw new Error("Email and password are required"); @@ -125,22 +165,26 @@ const loginUser = async (data) => { const user = result.rows[0]; + // Kiểm tra các điều kiện account if (user.status !== "active") throw new Error("Account is inactive or blocked"); if (!user.email_verified) throw new Error("Email not verified"); if (user.locked_until && new Date(user.locked_until) > new Date()) { throw new Error("Account is temporarily locked. Please try again later"); } + // Account social login không có password if (!user.password_hash) { throw new Error( "This account uses Google/Facebook login and has no password set. Please sign in with social login, or use 'Forgot Password' to set a password." ); } + // So sánh password const match = await comparePassword(password, user.password_hash); if (!match) { const newFailedAttempts = user.failed_login_attempts + 1; + // Lock account sau 5 lần sai if (newFailedAttempts >= 5) { await pool.query(QU.UPDATE_LOGIN_LOCK, [newFailedAttempts, user.id]); throw new Error("Too many failed login attempts. Account locked for 15 minutes"); @@ -150,10 +194,12 @@ const loginUser = async (data) => { } } + // Reset trạng thái login khi thành công await pool.query(QU.RESET_LOGIN_STATE, [user.id]); return user; }; +// Xác thực OTP để verify email sau khi đăng ký const verifyRegisterOTP = async (email, otp) => { const userResult = await pool.query(QU.FIND_USER_BY_EMAIL, [email]); if (userResult.rows.length === 0) throw new Error("User not found"); @@ -168,6 +214,7 @@ const verifyRegisterOTP = async (email, otp) => { return true; }; +// Quên mật khẩu - gửi OTP reset qua email const forgotPassword = async (email) => { const userResult = await pool.query(QU.FIND_USER_BY_EMAIL, [email]); if (userResult.rows.length === 0) throw new Error("Email not found"); @@ -186,6 +233,7 @@ const forgotPassword = async (email) => { return { otp }; }; +// Xác thực OTP reset mật khẩu const verifyResetOTP = async (email, otp) => { const userResult = await pool.query(QU.FIND_USER_BY_EMAIL, [email]); if (userResult.rows.length === 0) throw new Error("User not found"); @@ -198,6 +246,8 @@ const verifyResetOTP = async (email, otp) => { return true; }; +// Reset mật khẩu mới sau khi đã verify OTP +// Đánh dấu OTP đã sử dụng, cập nhật password mới, revoke toàn bộ tokens cũ const resetPassword = async (email, otp, newPassword, confirmPassword) => { if (!email || !otp || !newPassword || !confirmPassword) throw new Error("All fields are required"); if (newPassword.length < 8) throw new Error("Password must be at least 8 characters"); @@ -218,18 +268,22 @@ const resetPassword = async (email, otp, newPassword, confirmPassword) => { return true; }; +// Lấy thông tin profile của user hiện tại const getMe = async (userId) => { const result = await pool.query(QU.SELECT_ME, [userId]); if (result.rows.length === 0) throw new Error("User not found"); return result.rows[0]; }; +// Normalize profile text - loại bỏ whitespace thừa, convert empty string thành null const normalizeProfileText = (value) => { if (value === undefined) return undefined; const normalized = String(value || "").trim(); return normalized || null; }; +// Cập nhật thông tin profile người dùng +// Các trường: full_name, phone, date_of_birth, gender, address, avatar_url const updateProfile = async (userId, data = {}) => { const fullName = normalizeProfileText(data.full_name); const phone = normalizeProfileText(data.phone); @@ -244,6 +298,7 @@ const updateProfile = async (userId, data = {}) => { if (gender && !["male", "female", "other"].includes(gender)) throw new Error("Gender must be male, female or other"); if (avatarUrl && avatarUrl.length > 750000) throw new Error("Avatar image is too large"); + // Kiểm tra phone không trùng với user khác if (phone) { const existingPhone = await pool.query(QU.CHECK_PHONE_EXISTS_EXCLUDE_SELF, [phone, userId]); if (existingPhone.rows.length > 0) throw new Error("Phone already exists"); @@ -257,6 +312,8 @@ const updateProfile = async (userId, data = {}) => { return result.rows[0]; }; +// Đổi mật khẩu - yêu cầu nhập mật khẩu cũ để xác thực +// Revoke toàn bộ refresh tokens cũ sau khi đổi thành công const changePassword = async (userId, oldPassword, newPassword, confirmPassword) => { if (!oldPassword || !newPassword || !confirmPassword) throw new Error("All fields are required"); if (newPassword.length < 8) throw new Error("New password must be at least 8 characters"); @@ -278,6 +335,7 @@ const changePassword = async (userId, oldPassword, newPassword, confirmPassword) return true; }; +// Đặt mật khẩu mới cho account social login (chưa có password) const setPassword = async (userId, newPassword, confirmPassword) => { if (!newPassword || !confirmPassword) throw new Error("new_password and confirm_password are required"); if (newPassword.length < 8) throw new Error("Password must be at least 8 characters"); @@ -295,6 +353,7 @@ const setPassword = async (userId, newPassword, confirmPassword) => { return true; }; +// Gửi lại OTP cho user chưa verify email const resendRegisterOTP = async (email) => { if (!email) throw new Error("Email is required"); diff --git a/src/services/booking.service.js b/src/services/booking.service.js index 9595c32..10f95b8 100644 --- a/src/services/booking.service.js +++ b/src/services/booking.service.js @@ -1,3 +1,33 @@ +/* +============================================================ +BOOKING SERVICE - Tạo và quản lý booking vé máy bay +============================================================ + +Các chức năng chính: +- Tạo booking mới (tạo giữ chỗ 30 phút) +- Xem chi tiết booking +- Xem danh sách booking của user +- Hủy booking (có refund nếu đã thanh toán) +- Tự động hủy booking quá hạn (cron job) + +Quy trình tạo booking: +1. Validate thông tin đầu vào +2. Kiểm tra ghế còn trống +3. Tính giá (base + hành lý + seat fee + ancillary) +4. Tạo booking record +5. Tạo passenger records +6. Assign seat tự động +7. Giảm available_seats +8. Tích điểm loyalty (nếu đã login) +9. Commit transaction + +Giá vé tính theo: +- Người lớn: 100% +- Trẻ em (2-11 tuổi): 75% +- Em bé (<2 tuổi): 10% +============================================================ +*/ + const pool = require("../config/db"); const { assignSeat } = require("../utils/seat"); const { rollbackReservedVoucherUsageForBooking } = require("./payment.service"); @@ -7,9 +37,8 @@ const QP = require("../queries/payment.queries"); const QAnc = require("../queries/ancillary.queries"); const QB2 = { SELECT_MY_BOOKINGS: QB.SELECT_MY_BOOKINGS }; -// ====================== THÊM LOYALTY SERVICE ====================== +// Loyalty service - tích điểm khi booking thành công const loyaltyService = require('../services/loyalty.service'); -// ================================================================= const generateBookingCode = () => { const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; @@ -20,6 +49,8 @@ const generateBookingCode = () => { return code; }; +// Tính tổng giá vé theo loại hành khách +// Người lớn: 100%, Trẻ em: 75%, Em bé: 10% const calcTotalPrice = (basePrice, adults, children, infants) => { const adultTotal = basePrice * adults; const childTotal = basePrice * 0.75 * children; @@ -29,8 +60,9 @@ const calcTotalPrice = (basePrice, adults, children, infants) => { const { applyDynamicPricing: applyDemand } = require('../utils/pricing'); +// Validate thông tin booking từ client +// Kiểm tra các trường bắt buộc, số lượng hành khách, thông tin passenger const validateBookingInput = (data) => { - // ... (giữ nguyên code validate của bạn) const { outbound_flight_id, outbound_seat_class, return_flight_id, return_seat_class, @@ -83,7 +115,6 @@ const validateBookingInput = (data) => { }; const checkAndGetSeatInfo = async (client, flightId, seatClass, seatsNeeded) => { - // ... (giữ nguyên code của bạn) const result = await client.query(QF.SELECT_SEAT_INFO, [flightId, seatClass]); if (result.rows.length === 0) throw new Error(`Không tìm thấy hạng ghế ${seatClass} cho chuyến bay ID ${flightId}`); @@ -98,8 +129,15 @@ const checkAndGetSeatInfo = async (client, flightId, seatClass, seatsNeeded) => return seat; }; -// ─── createBooking ──────────────────────────────────────────────────────────── - +// ─── Tạo booking mới ──────────────────────────────────────────── +// Quy trình: +// 1. Validate input +// 2. Kiểm tra user/guest không trùng email với account đã login +// 3. Kiểm tra ghế còn trống +// 4. Tính giá (base + hành lý + ancillary) +// 5. Tạo booking, passengers, assign seat +// 6. Tích điểm loyalty (nếu đã login) +// 7. Giảm available_seats const createBooking = async (data, userId = null) => { validateBookingInput(data); @@ -320,7 +358,7 @@ const createBooking = async (data, userId = null) => { } }; -// Các hàm còn lại giữ nguyên (getBookingDetail, getMyBookings, cancelBooking, expireHeldBookings) +// ─── Xem chi tiết 1 booking ──────────────────────────────────── const getBookingDetail = async (bookingCode, userId = null) => { const result = await pool.query(QB.SELECT_BOOKING_DETAIL, [bookingCode]); if (result.rows.length === 0) throw new Error("Không tìm thấy booking"); @@ -420,8 +458,7 @@ const getBookingDetail = async (bookingCode, userId = null) => { }; }; -// ─── getMyBookings ──────────────────────────────────────────────────────────── - +// ─── Xem danh sách booking của user ───────────────────────────── const getMyBookings = async (userId, filter = "all", from_date, to_date) => { const conditions = []; const values = []; @@ -456,6 +493,9 @@ const getMyBookings = async (userId, filter = "all", from_date, to_date) => { return result.rows; }; +// ─── Hủy booking ─────────────────────────────────────────────── +// Chưa thanh toán → hủy trực tiếp, giải phóng ghế +// Đã thanh toán → chuyển sang refund flow const cancelBooking = async (userId, bookingCode, reason = null) => { const client = await pool.connect(); try { @@ -528,8 +568,10 @@ const cancelBooking = async (userId, bookingCode, reason = null) => { } }; +// ─── Cron: Hủy booking quá hạn ───────────────────────────────── +// Chạy định kỳ để hủy các booking ở trạng thái "pending" +// đã quá thời gian giữ chỗ (30 phút) mà chưa thanh toán const expireHeldBookings = async () => { - // ... (giữ nguyên) }; module.exports = { createBooking, getBookingDetail, getMyBookings, cancelBooking, expireHeldBookings }; \ No newline at end of file diff --git a/src/services/chat.service.js b/src/services/chat.service.js index f60c389..7a9534d 100644 --- a/src/services/chat.service.js +++ b/src/services/chat.service.js @@ -1,3 +1,17 @@ +/* +============================================================ +CHAT SERVICE - AI Assistant & Support Chat +============================================================ + +Hai loại conversation: +- ai: Chat với AI assistant +- support: Chat với support team + +Message roles: user, admin, assistant, system +Support statuses: open, pending_admin, pending_user, resolved +============================================================ +*/ + const pool = require("../config/db"); const assistantChatService = require("./admin/chat.service"); const { diff --git a/src/services/checkin.service.js b/src/services/checkin.service.js index c84acdb..2877195 100644 --- a/src/services/checkin.service.js +++ b/src/services/checkin.service.js @@ -1,25 +1,25 @@ 'use strict'; /* -========================================================= -CHECKIN SERVICE - Xu ly check-in online -========================================================= -Input: Booking code hoac QR code -Output: Boarding pass info - -Quy trinh: -1. Validate booking (ton tai, chua check-in, trong thoi gian) -2. Generate boarding pass cho moi passenger -3. Tra ve boarding pass info -========================================================= +============================================================ +CHECKIN SERVICE - Check-in online +============================================================ + +Quy trình: +1. Validate booking (tồn tại, chưa check-in, trong thời gian) +2. Generate boarding pass cho mỗi passenger +3. Trả về boarding pass info + +Config: +- Cho phép check-in trước 24h +- Không cho check-in sau 30 phút trước giờ bay +============================================================ */ const db = require('../config/db'); const SQ = require('../queries/seat.queries'); -// ========================================================= -// CONFIGURATION -// ========================================================= +// Config const CHECKIN_CONFIG = { // Cho phep check-in truoc bao nhieu gio @@ -30,49 +30,35 @@ const CHECKIN_CONFIG = { defaultGate: 'TBA', }; -// ========================================================= -// HELPER FUNCTIONS -// ========================================================= +// Helpers -/** - * Generate boarding pass code - * Format: {BOOKING_CODE}-{PASSENGER_INDEX} - * VD: VJ8PKSL-P1 - */ +// Tạo mã boarding pass: {BOOKING_CODE}-{PASSENGER_INDEX} const generateBoardingPassCode = (bookingCode, passengerIndex) => { return `${bookingCode}-P${passengerIndex}`; }; -/** - * Get next sequence number - */ +// Lấy số sequence tiếp theo const getNextSequenceNumber = async (bookingId, flightType) => { const pool = db; const result = await pool.query(SQ.GET_NEXT_SEQUENCE_NUMBER, [bookingId, flightType]); return result.rows[0]?.next_seq || 1; }; -/** - * Format date for boarding pass - */ +// Format ngày cho boarding pass const formatDate = (dateString) => { const date = new Date(dateString); const options = { day: '2-digit', month: 'short', year: 'numeric' }; return date.toLocaleDateString('en-GB', options).toUpperCase(); }; -/** - * Format time for boarding pass - */ +// Format giờ cho boarding pass const formatTime = (dateString) => { const date = new Date(dateString); return date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }); }; -/** - * Tính số phút boarding trước giờ bay theo độ dài chuyến - * ≤ 2h → 45 phút | 2–5h → 60 phút | > 5h → 90 phút - */ +// Tính số phút boarding trước giờ bay +// ≤ 2h → 45 phút | 2-5h → 60 phút | > 5h → 90 phút const getBoardingMinutes = (depTime, arrTime) => { if (!depTime || !arrTime) return 45; const durationMins = (new Date(arrTime) - new Date(depTime)) / 60000; @@ -81,12 +67,12 @@ const getBoardingMinutes = (depTime, arrTime) => { return 90; }; -/** - * Sinh gate tự động dựa theo country từ DB: - * - Vietnam → chỉ số (tự động đúng với mọi sân bay VN mới thêm vào) - * - Quốc tế → chữ + số (theo chuẩn phổ biến của hub đó) - * Hash từ flight_number → cùng chuyến luôn ra cùng gate - */ +// Tạo gate tự động theo sân bay +// Vietnam → số thường +// Quốc tế → chữ + số (theo hub) +// Vietnam → chỉ số (tự động đúng với mọi sân bay VN mới thêm vào) +// Quốc tế → chữ + số (theo chuẩn phổ biến của hub đó) +// Hash từ flight_number → cùng chuyến luôn ra cùng gate const generateGate = (flightNumber, departureAirport, departureCountry) => { const fn = (flightNumber || '').toUpperCase(); const dep = (departureAirport || '').toUpperCase(); @@ -120,13 +106,9 @@ const generateGate = (flightNumber, departureAirport, departureCountry) => { return 'ABCDE'[h % 5] + pick(15); }; -// ========================================================= -// CHECKIN FUNCTIONS -// ========================================================= +// ─── Checkin Functions ───────────────────────── -/** - * Check booking status for checkin - */ +// Kiểm tra booking có thể check-in không const checkBookingCheckinStatus = async (bookingCode) => { const pool = db; @@ -163,9 +145,7 @@ const checkBookingCheckinStatus = async (bookingCode) => { return booking; }; -/** - * Get all passengers for a booking - */ +// Lấy danh sách passengers cho checkin const getPassengersForCheckin = async (bookingId, flightType) => { const pool = db; @@ -174,9 +154,7 @@ const getPassengersForCheckin = async (bookingId, flightType) => { return result.rows; }; -/** - * Get booking details for boarding pass - */ +// Lấy chi tiết booking cho boarding pass const getBookingDetailsForCheckin = async (bookingCode) => { const pool = db; @@ -189,9 +167,7 @@ const getBookingDetailsForCheckin = async (bookingCode) => { return result.rows[0]; }; -/** - * Check-in cho 1 passenger cu the - */ +// Check-in 1 passenger cụ thể const checkinPassenger = async (bookingCode, passengerId, flightType) => { const pool = db; const client = await pool.connect(); @@ -307,9 +283,7 @@ const checkinPassenger = async (bookingCode, passengerId, flightType) => { } }; -/** - * Check-in tat ca passengers cua 1 booking cho 1 flight - */ +// Check-in tất cả passengers của 1 booking const checkinAllPassengers = async (bookingCode, flightType = 'outbound') => { const pool = db; const client = await pool.connect(); @@ -459,9 +433,7 @@ const checkinAllPassengers = async (bookingCode, flightType = 'outbound') => { } }; -/** - * Get boarding pass info - */ +// Lấy thông tin boarding pass const getBoardingPass = async (boardingPassCode) => { const pool = db; @@ -518,9 +490,7 @@ const getBoardingPass = async (boardingPassCode) => { }; }; -/** - * Get checkin status cho 1 booking - */ +// Lấy trạng thái checkin của 1 booking const getCheckinStatus = async (bookingCode) => { const pool = db; diff --git a/src/services/coupon.service.js b/src/services/coupon.service.js index feafc80..89f064c 100644 --- a/src/services/coupon.service.js +++ b/src/services/coupon.service.js @@ -1,5 +1,13 @@ -const pool = require("../config/db"); -const Q = require("../queries/coupon.queries"); +/* +============================================================ +COUPON SERVICE - Mã giảm giá +============================================================ + +Các chức năng: +- Lấy danh sách coupons công khai +- Filter theo: search, airline, welcome_only, availability +============================================================ +*/ const sanitizeCoupon = (row) => ({ id: row.id, diff --git a/src/services/date-change.service.js b/src/services/date-change.service.js index 8fe890c..b63ca6c 100644 --- a/src/services/date-change.service.js +++ b/src/services/date-change.service.js @@ -1,8 +1,6 @@ 'use strict'; -/* -DATE CHANGE SERVICE - Business Logic -*/ +// DATE CHANGE SERVICE - Đổi ngày bay const pool = require('../config/db'); const QCD = require('../queries/date-change.queries'); @@ -43,14 +41,80 @@ const generateRequestCode = () => { return `DCR-${date}-${suffix}`; }; -const validateDateChangeRequest = async (booking, newFlightId, seatClass) => { +const DATE_CHANGE_LEGS = ['outbound']; + +const ACTIVE_DATE_CHANGE_STATUSES = ['pending_otp', 'pending_payment', 'pending']; + +const toPassengerCount = (booking, passengerIds = null) => { + if (Array.isArray(passengerIds) && passengerIds.length > 0) { + return passengerIds.length; + } + + return [booking.total_adults, booking.total_children, booking.total_infants] + .map((value) => parseInt(value || 0, 10)) + .reduce((sum, value) => sum + (Number.isFinite(value) ? value : 0), 0); +}; + +const normalizeFlightLeg = (flightLeg) => { + const normalized = String(flightLeg || 'outbound').trim().toLowerCase(); + if (!DATE_CHANGE_LEGS.includes(normalized)) { + throw new Error(`flight_leg phai la mot trong: ${DATE_CHANGE_LEGS.join(', ')}`); + } + return normalized; +}; + +const validateLegEligibility = (booking, flightLeg) => { + if (flightLeg === 'outbound') { + return { + flightId: booking.outbound_flight_id, + seatClass: booking.outbound_seat_class, + departureTime: booking.outbound_departure_time, + basePrice: parseFloat(booking.base_price || 0), + currentFlightNumber: booking.outbound_flight_number, + }; + } + + throw new Error(`Date change V1 chua ho tro leg "${flightLeg}"`); +}; + +const getApprovedDateChangeCountForLeg = async (bookingId, flightLeg, db = pool) => { + const result = await db.query(QCD.COUNT_APPROVED_DATE_CHANGES_FOR_LEG, [bookingId, flightLeg]); + return parseInt(result.rows[0]?.count || 0, 10); +}; + +const validateApprovedDateChangeLimit = async (bookingId, flightLeg, db = pool) => { + const approvedCount = await getApprovedDateChangeCountForLeg(bookingId, flightLeg, db); + const limit = DATE_CHANGE.limits?.maxApprovedChangesPerLeg || 2; + + if (approvedCount >= limit) { + throw new Error(`Leg ${flightLeg} da dat toi da ${limit} lan doi ngay duoc duyet`); + } + + return approvedCount; +}; + +const ensureRequestStatus = (request, allowedStatuses, actionLabel) => { + if (!allowedStatuses.includes(request.status)) { + throw new Error(`${actionLabel} khong hop le cho yeu cau o trang thai "${request.status}"`); + } +}; + +const markDateChangeRequestAsPending = async (db, requestCode) => { + const result = await db.query(QCD.UPDATE_DATE_CHANGE_STATUS_SIMPLE, ['pending', requestCode]); + return result.rows[0] || null; +}; + +// HELPERS +const validateDateChangeRequest = async (booking, newFlightId, seatClass, flightLeg = 'outbound') => { + const legContext = validateLegEligibility(booking, flightLeg); + // 1. Booking phải confirmed if (booking.status !== 'confirmed') { throw new Error(`Không thể đổi ngày bay cho booking có trạng thái "${booking.status}"`); } // 2. Check thời gian trước departure - const hoursUntilDeparture = (new Date(booking.outbound_departure_time) - new Date()) / (1000 * 60 * 60); + const hoursUntilDeparture = (new Date(legContext.departureTime) - new Date()) / (1000 * 60 * 60); if (DATE_CHANGE.minHoursBeforeFlight && hoursUntilDeparture < DATE_CHANGE.minHoursBeforeFlight) { throw new Error(`Không thể đổi ngày bay khi còn ít hơn ${DATE_CHANGE.minHoursBeforeFlight} tiếng trước giờ khởi hành`); } @@ -75,7 +139,7 @@ const validateDateChangeRequest = async (booking, newFlightId, seatClass) => { // 6. Check date range if (DATE_CHANGE.maxDateRange) { - const currentDeparture = new Date(booking.outbound_departure_time); + const currentDeparture = new Date(legContext.departureTime); const newDeparture = new Date(newFlight.departure_time); const daysDiff = Math.abs((newDeparture - currentDeparture) / (1000 * 60 * 60 * 24)); if (daysDiff > DATE_CHANGE.maxDateRange) { @@ -83,10 +147,12 @@ const validateDateChangeRequest = async (booking, newFlightId, seatClass) => { } } - return newFlight; + return { + ...newFlight, + current_leg_context: legContext, + }; }; -// Bug 2 + 3 + 4 Fix: OTP lưu vào DB (persistent, không mất khi server restart) -// Key theo requestCode (không phải email), tránh collision khi cùng email có nhiều yêu cầu +// Hàm gửi OTP cho email const requestDateChangeOTP = async (email, requestCode) => { const otp = Math.floor(100000 + Math.random() * 900000).toString(); const expiresAt = new Date(Date.now() + OTP_CONFIG.expiresInMinutes * 60 * 1000); @@ -108,40 +174,53 @@ const requestDateChangeOTP = async (email, requestCode) => { console.log(`[DateChange OTP] Sent to ${email} for request ${requestCode}`); return { expiresIn: OTP_CONFIG.expiresInMinutes }; -}; - -// Bug 3 + 4 Fix: Verify từ DB, key theo requestCode +} +// Hàm verify OTP const verifyDateChangeOTP = async (email, otp, requestCode) => { - const result = await pool.query( - `SELECT otp_code, otp_expires_at, otp_attempts + const normalizedEmail = String(email || '').toLowerCase().trim(); + + const { rows } = await pool.query( + `SELECT request_code, otp_code, otp_expires_at, otp_attempts, booking_id FROM date_change_requests - WHERE request_code = $1 AND status = 'pending_otp'`, + WHERE request_code = $1 + LIMIT 1`, [requestCode] ); - if (result.rows.length === 0) { - throw new Error('Không tìm thấy yêu cầu đổi ngày hoặc yêu cầu đã được xử lý'); - } + if (rows.length === 0) throw new Error('Khong tim thay yeu cau doi ngay bay'); - const row = result.rows[0]; + const otpData = rows[0]; + if (!otpData.otp_code) throw new Error('Khong tim thay ma OTP'); + if (new Date() > new Date(otpData.otp_expires_at)) throw new Error('Ma OTP da het han'); + if ((otpData.otp_attempts || 0) >= OTP_CONFIG.maxAttempts) throw new Error('Qua so lan thu'); - if (!row.otp_code) { - throw new Error('Không tìm thấy mã OTP. Vui lòng tạo yêu cầu mới.'); - } - if (new Date() > new Date(row.otp_expires_at)) { - throw new Error('Mã OTP đã hết hạn. Vui lòng tạo yêu cầu đổi ngày mới.'); - } - if (row.otp_attempts >= OTP_CONFIG.maxAttempts) { - throw new Error('Quá số lần thử OTP. Vui lòng tạo yêu cầu mới.'); + const bookingResult = await pool.query( + `SELECT contact_email FROM bookings WHERE id = $1 LIMIT 1`, + [otpData.booking_id] + ); + + const bookingEmail = String(bookingResult.rows[0]?.contact_email || '').toLowerCase().trim(); + if (!bookingEmail || bookingEmail !== normalizedEmail) { + throw new Error('Email xac thuc khong hop le'); } - if (row.otp_code !== otp) { + + if (otpData.otp_code !== otp) { await pool.query( - `UPDATE date_change_requests SET otp_attempts = otp_attempts + 1 WHERE request_code = $1`, + `UPDATE date_change_requests + SET otp_attempts = COALESCE(otp_attempts, 0) + 1 + WHERE request_code = $1`, [requestCode] ); - throw new Error('Mã OTP không đúng'); + throw new Error('Ma OTP khong dung'); } + await pool.query( + `UPDATE date_change_requests + SET otp_attempts = 0 + WHERE request_code = $1`, + [requestCode] + ); + return { verified: true }; }; @@ -202,7 +281,7 @@ const updateDateChangePaymentProviderFields = async (paymentCode, fields = {}) = return rows[0] || null; }; -// REQUEST DATE CHANGE (USER) +// ─── USER REQUEST DATE CHANGE ─────────────────────────────── const requestDateChange = async (userId, bookingCode, data) => { const { @@ -210,10 +289,12 @@ const requestDateChange = async (userId, bookingCode, data) => { new_seat_class, passenger_ids = null, reason, + flight_leg = 'outbound', } = data; if (!new_flight_id) throw new Error('new_flight_id là bắt buộc'); if (!new_seat_class) throw new Error('new_seat_class là bắt buộc'); + const normalizedFlightLeg = normalizeFlightLeg(flight_leg); if (!['economy', 'business', 'first'].includes(new_seat_class)) { throw new Error('new_seat_class phải là: economy, business, hoặc first'); } @@ -234,26 +315,28 @@ const requestDateChange = async (userId, bookingCode, data) => { throw new Error('Bạn không có quyền thực hiện yêu cầu này'); } - const existingRequest = await client.query(QCD.CHECK_PENDING_DATE_CHANGE_FOR_BOOKING, [booking.id]); + const existingRequest = await client.query(QCD.CHECK_PENDING_DATE_CHANGE_FOR_BOOKING, [booking.id, normalizedFlightLeg]); if (existingRequest.rows.length > 0) { - throw new Error('Đã có yêu cầu đổi ngày đang chờ xử lý cho booking này'); + throw new Error(`Đã có yêu cầu đổi ngày đang chờ xử lý cho leg ${normalizedFlightLeg}`); } - const newFlight = await validateDateChangeRequest(booking, new_flight_id, new_seat_class); + const legContext = validateLegEligibility(booking, normalizedFlightLeg); + await validateApprovedDateChangeLimit(booking.id, normalizedFlightLeg, client); + + const newFlight = await validateDateChangeRequest(booking, new_flight_id, new_seat_class, normalizedFlightLeg); // Check seat availability if (DATE_CHANGE.checkSeatAvailability) { - const passengers = passenger_ids?.length || (parseInt(booking.total_adults) + parseInt(booking.total_children)); + const passengers = toPassengerCount(booking, passenger_ids); if (newFlight.available_seats < passengers) { throw new Error(`Chuyến bay mới không đủ ghế. Còn ${newFlight.available_seats} ghế, cần ${passengers}`); } } - const seatsNeeded = passenger_ids?.length || (parseInt(booking.total_adults) + parseInt(booking.total_children)); + const seatsNeeded = toPassengerCount(booking, passenger_ids); const newFlightPrice = parseFloat(newFlight.base_price); const newTotalPrice = newFlightPrice * seatsNeeded; - // So sánh đúng: giá vé outbound × số ghế (không bao gồm hành lý, ancillary, chuyến về) - const oldPrice = parseFloat(booking.base_price) * seatsNeeded; + const oldPrice = parseFloat(legContext.basePrice) * seatsNeeded; const priceDifference = newTotalPrice - oldPrice; let requestCode; @@ -267,9 +350,9 @@ const requestDateChange = async (userId, bookingCode, data) => { const requestResult = await client.query(QCD.INSERT_DATE_CHANGE, [ requestCode, booking.id, - booking.outbound_flight_id, + legContext.flightId, new_flight_id, - booking.outbound_seat_class, + legContext.seatClass, new_seat_class, passenger_ids ? JSON.stringify(passenger_ids) : null, oldPrice, @@ -278,6 +361,7 @@ const requestDateChange = async (userId, bookingCode, data) => { 'pending_otp', // Status: cho OTP verification reason, userId, + normalizedFlightLeg, ]); const request = requestResult.rows[0]; @@ -308,10 +392,11 @@ const requestDateChange = async (userId, bookingCode, data) => { request_code: request.request_code, status: request.status, old_flight: { - flight_id: booking.outbound_flight_id, - flight_number: booking.outbound_flight_number, - departure_time: booking.outbound_departure_time, - seat_class: booking.outbound_seat_class, + flight_leg: normalizedFlightLeg, + flight_id: legContext.flightId, + flight_number: legContext.currentFlightNumber, + departure_time: legContext.departureTime, + seat_class: legContext.seatClass, }, new_flight: { flight_id: new_flight_id, @@ -322,7 +407,8 @@ const requestDateChange = async (userId, bookingCode, data) => { price_difference: priceDifference, price_difference_label: priceDifference > 0 ? 'Ban phai tra them' : priceDifference < 0 ? 'Ban duoc hoan' : 'Khong phai tra them', message: `Ma OTP da gui den ${booking.contact_email || booking.guest_email}. Vui long xac thuc OTP de hoan tat yeu cau.`, - requires_otp: true + requires_otp: true, + next_action: 'verify_otp', }; } catch (err) { await client.query('ROLLBACK'); @@ -332,7 +418,7 @@ const requestDateChange = async (userId, bookingCode, data) => { } }; -// CONFIRM DATE CHANGE (After OTP Verification) +// ─── CONFIRM DATE CHANGE (sau khi verify OTP) ───────────────── const confirmDateChange = async (email, otp, requestCode) => { // 1. Verify OTP từ DB (Bug 3+4 Fix: dùng requestCode làm key) @@ -343,13 +429,9 @@ const confirmDateChange = async (email, otp, requestCode) => { if (requestResult.rows.length === 0) throw new Error('Không tìm thấy yêu cầu'); const request = requestResult.rows[0]; - if (request.status !== 'pending_otp') { - throw new Error(`Yêu cầu đã được xử lý (status: ${request.status})`); - } + ensureRequestStatus(request, ['pending_otp'], 'Xac thuc OTP'); - // 3. Kiểm tra payment có cần không (price_difference > 0) - const absDiff = Math.abs(parseFloat(request.price_difference)); - const requiresPayment = request.price_difference > 0 && DATE_CHANGE.priceDifference?.chargeIfPositive; + const requiresPayment = parseFloat(request.price_difference) > 0 && DATE_CHANGE.priceDifference?.chargeIfPositive; if (requiresPayment) { await pool.query(QCD.UPDATE_DATE_CHANGE_STATUS_SIMPLE, ['pending_payment', requestCode]); @@ -364,6 +446,7 @@ const confirmDateChange = async (email, otp, requestCode) => { // 4. Không cần payment → auto approve hoặc chờ admin const { AUTO_REFUND } = require('../config/refund.config'); + const absDiff = Math.abs(parseFloat(request.price_difference) || 0); const autoApprove = AUTO_REFUND.enabled && absDiff < AUTO_REFUND.threshold; // Fix Bug 2: luôn set 'pending' trước — approveDateChange yêu cầu status = 'pending' @@ -377,12 +460,16 @@ const confirmDateChange = async (email, otp, requestCode) => { return { success: true, status: 'pending', - auto_approved: false, - message: 'Yêu cầu đổi ngày bay đã được tiếp nhận, chờ admin duyệt', + auto_approved: autoApprove, + requires_payment: false, + message: autoApprove + ? 'Yêu cầu đổi ngày bay đã được tự động duyệt.' + : 'Yêu cầu đổi ngày bay đã được tiếp nhận và đang chờ admin xử lý.', + next_action: autoApprove ? 'completed' : 'wait_for_admin', }; }; -// CREATE PAYMENT FOR DATE CHANGE (When price_difference > 0) +// ─── CREATE PAYMENT CHO CHÊNH LỆCH GIÁ ─────────────────── const createDateChangePayment = async (requestCode, paymentMethod, userId = null) => { // 1. Validate payment method early @@ -408,9 +495,7 @@ const createDateChangePayment = async (requestCode, paymentMethod, userId = null if (lockedResult.rows.length === 0) throw new Error('Khong tim thay yeu cau doi ngay bay'); const request = lockedResult.rows[0]; - if (request.status !== 'pending_payment') { - throw new Error(`Khong the tao thanh toan cho yeu cau o trang thai "${request.status}"`); - } + ensureRequestStatus(request, ['pending_payment'], 'Tao thanh toan'); if (request.price_difference <= 0) { throw new Error('Yeu cau nay khong can thanh toan them'); @@ -419,7 +504,17 @@ const createDateChangePayment = async (requestCode, paymentMethod, userId = null // 3. Check if payment already exists (with lock held) if (request.payment_id && request.payment_code) { const existingPayment = await getDateChangePaymentByCodeRow(request.payment_code); - if (existingPayment && !isTerminalPaidStatus(existingPayment.status)) { + const existingGatewayResponse = existingPayment?.gateway_response || {}; + const isReusablePendingPayment = + existingPayment && + !isTerminalPaidStatus(existingPayment.status) && + ( + normalizedMethod !== 'PAYPAL' || + String(existingPayment.payment_method || '').toUpperCase() !== 'PAYPAL' || + Boolean(existingGatewayResponse.approve_url || existingGatewayResponse.redirect_url || existingGatewayResponse.order_id) + ); + + if (isReusablePendingPayment) { await client.query('COMMIT'); return { ...mapDateChangePayment(existingPayment), @@ -427,6 +522,20 @@ const createDateChangePayment = async (requestCode, paymentMethod, userId = null price_difference: parseFloat(request.price_difference), }; } + + if (existingPayment && !isTerminalPaidStatus(existingPayment.status)) { + await client.query(` + UPDATE payments + SET status = 'FAILED', + updated_at = NOW(), + gateway_response = COALESCE(gateway_response, '{}'::jsonb) || jsonb_build_object( + 'provider_retry_required', true, + 'provider_retry_reason', 'missing_checkout_url', + 'invalidated_at', NOW() + ) + WHERE id = $1 + `, [existingPayment.id]); + } } // 4. Create payment record @@ -529,46 +638,56 @@ const createDateChangePayment = async (requestCode, paymentMethod, userId = null } }; -// CONFIRM DATE CHANGE PAYMENT (After payment success) +// ─── CONFIRM PAYMENT ĐÃ THANH TOÁN ────────────────────────── -const confirmDateChangePayment = async (paymentCode) => { - // 1. Find payment - const payment = await getDateChangePaymentByCodeRow(paymentCode); - if (!payment) throw new Error('Khong tim thay thanh toan'); +const confirmDateChangePayment = async (paymentCode, options = {}) => { + const { trustedPaid = false, gatewayPayload = null } = options; + const client = await pool.connect(); - if (isTerminalPaidStatus(payment.status)) { - const requestResult = await pool.query(QCD.SELECT_DATE_CHANGE_BY_PAYMENT_CODE, [paymentCode]); - return { - success: true, - already_processed: true, - status: requestResult.rows[0]?.status, - message: 'Payment already processed', - }; - } + try { + await client.query('BEGIN'); - // 2. Find date change request - const requestResult = await pool.query(QCD.SELECT_DATE_CHANGE_BY_PAYMENT_CODE, [paymentCode]); - if (requestResult.rows.length === 0) throw new Error('Khong tim thay yeu cau doi ngay bay'); - const request = requestResult.rows[0]; + const paymentResult = await client.query( + 'SELECT * FROM payments WHERE payment_code = $1 LIMIT 1 FOR UPDATE', + [paymentCode] + ); + const payment = paymentResult.rows[0]; + if (!payment) throw new Error('Khong tim thay thanh toan'); - if (request.status !== 'pending_payment') { - throw new Error(`Yeu cau da duoc xu ly (status: ${request.status})`); - } + const requestResult = await client.query(QCD.SELECT_DATE_CHANGE_BY_PAYMENT_CODE, [paymentCode]); + if (requestResult.rows.length === 0) throw new Error('Khong tim thay yeu cau doi ngay bay'); + const request = requestResult.rows[0]; - // 3. Check payment expiry - if (payment.expires_at && new Date() > new Date(payment.expires_at)) { - await pool.query(` - UPDATE payments SET status = 'EXPIRED', updated_at = NOW() WHERE payment_code = $1 - `, [paymentCode]); - throw new Error('Payment da het han. Vui long tao thanh toan moi.'); - } + if (isTerminalPaidStatus(payment.status)) { + const normalizedRequestStatus = request.status === 'approved' ? 'approved' : 'pending'; + await client.query('COMMIT'); + return { + success: true, + already_processed: true, + request_code: request.request_code, + status: normalizedRequestStatus, + payment_status: 'SUCCESS', + message: normalizedRequestStatus === 'approved' + ? 'Thanh toan da duoc ghi nhan va yeu cau da duoc duyet truoc do.' + : 'Thanh toan da duoc ghi nhan. Yeu cau dang cho admin xu ly.', + }; + } - // 4. Validate amount matches - const expectedAmount = parseFloat(request.price_difference); - const receivedAmount = parseFloat(payment.amount); - if (receivedAmount !== expectedAmount) { - throw new Error(`So tien khong dung. Expected: ${expectedAmount}, Received: ${receivedAmount}`); - } + ensureRequestStatus(request, ['pending_payment'], 'Xac nhan thanh toan'); + + if (payment.expires_at && new Date() > new Date(payment.expires_at)) { + await client.query( + `UPDATE payments SET status = 'EXPIRED', updated_at = NOW() WHERE payment_code = $1`, + [paymentCode] + ); + throw new Error('Payment da het han. Vui long tao thanh toan moi.'); + } + + const expectedAmount = parseFloat(request.price_difference); + const receivedAmount = parseFloat(payment.amount); + if (receivedAmount !== expectedAmount) { + throw new Error(`So tien khong dung. Expected: ${expectedAmount}, Received: ${receivedAmount}`); + } // 5. Update payment to SUCCESS await pool.query(` @@ -587,11 +706,20 @@ const confirmDateChangePayment = async (paymentCode) => { // 8. Mark paid_at await pool.query(`UPDATE date_change_requests SET paid_at = NOW() WHERE request_code = $1`, [request.request_code]); - return { - success: true, - status: 'approved', - message: 'Thanh toan thanh cong. Yeu cau doi ngay bay da duoc duyet.', - }; + return { + success: true, + request_code: request.request_code, + status: 'pending', + payment_status: 'SUCCESS', + next_action: 'wait_for_admin', + message: 'Thanh toan thanh cong. Yeu cau doi ngay bay dang cho admin xu ly.', + }; + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } }; // GET DATE CHANGE PAYMENT STATUS @@ -663,27 +791,43 @@ const approveDateChange = async (adminId, requestCode, adminNotes = null) => { try { await client.query('BEGIN'); - const requestResult = await client.query(QCD.SELECT_DATE_CHANGE_BY_CODE, [requestCode]); + const requestResult = await client.query( + `${QCD.SELECT_DATE_CHANGE_BY_CODE.trim()} FOR UPDATE OF dcr`, + [requestCode] + ); if (requestResult.rows.length === 0) throw new Error('Không tìm thấy yêu cầu đổi ngày bay'); const request = requestResult.rows[0]; console.log(`[DateChange Approve] Request: ${requestCode}, Status: ${request.status}`); - // Block approval if request is waiting for payment - if (request.status === 'pending_payment') { - throw new Error('Yeu cau dang cho thanh toan. Vui long thanh toan truoc khi duyet.'); + ensureRequestStatus(request, ['pending'], 'Duyet yeu cau'); + await validateApprovedDateChangeLimit(request.booking_id, request.flight_leg || 'outbound', client); + + let passengers = 0; + try { + const parsedPassengerIds = JSON.parse(request.passenger_ids || 'null'); + passengers = Array.isArray(parsedPassengerIds) && parsedPassengerIds.length > 0 ? parsedPassengerIds.length : 0; + } catch (error) { + passengers = 0; } - if (request.status !== 'pending') { - throw new Error(`Không thể duyệt yêu cầu có trạng thái "${request.status}"`); + if (!passengers) { + const bookingPassengerResult = await client.query( + 'SELECT total_adults, total_children, total_infants FROM bookings WHERE id = $1 LIMIT 1', + [request.booking_id] + ); + const bookingPassengerRow = bookingPassengerResult.rows[0] || {}; + passengers = [bookingPassengerRow.total_adults, bookingPassengerRow.total_children, bookingPassengerRow.total_infants] + .map((value) => parseInt(value || 0, 10)) + .reduce((sum, value) => sum + (Number.isFinite(value) ? value : 0), 0); } - const passengers = request.passenger_ids?.length || 1; - console.log(`[DateChange Approve] Passengers: ${passengers}`); + if (!passengers) { + throw new Error('Khong xac dinh duoc so hanh khach cho yeu cau doi ngay bay'); + } - // === CHECK NEW FLIGHT === const newSeatCheck = await client.query( - 'SELECT available_seats, total_seats FROM flight_seats WHERE flight_id = $1 AND class = $2', + 'SELECT available_seats, total_seats FROM flight_seats WHERE flight_id = $1 AND class = $2 FOR UPDATE', [request.new_flight_id, request.new_seat_class] ); @@ -696,26 +840,6 @@ const approveDateChange = async (adminId, requestCode, adminNotes = null) => { throw new Error(`Chuyến bay mới chỉ còn ${newSeat.available_seats} ghế`); } - // === CHECK OLD FLIGHT (nếu có) === - if (request.old_flight_id && request.old_seat_class) { - const oldSeatCheck = await client.query( - 'SELECT available_seats, total_seats FROM flight_seats WHERE flight_id = $1 AND class = $2', - [request.old_flight_id, request.old_seat_class] - ); - - if (oldSeatCheck.rows.length > 0) { - const oldSeat = oldSeatCheck.rows[0]; - console.log(`[Old Flight] Current: ${oldSeat.available_seats}/${oldSeat.total_seats}`); - - const newAvailableOld = oldSeat.available_seats + passengers; - if (newAvailableOld > oldSeat.total_seats) { - console.warn(`[WARNING] Old flight available will exceed total: ${newAvailableOld} > ${oldSeat.total_seats}`); - // Tự động điều chỉnh không cho vượt total_seats - } - } - } - - // === RELEASE OLD SEATS (an toàn) === if (request.old_flight_id && request.old_seat_class) { await client.query( `UPDATE flight_seats @@ -727,7 +851,6 @@ const approveDateChange = async (adminId, requestCode, adminNotes = null) => { console.log(`[DateChange] Released ${passengers} seats from old flight`); } - // === RESERVE NEW SEATS === await client.query( `UPDATE flight_seats SET available_seats = available_seats - $1, @@ -808,12 +931,11 @@ const approveDateChange = async (adminId, requestCode, adminNotes = null) => { // === GHI NOTE PHỤ THU NẾU USER CẦN TRẢ THÊM === const surchargeAmount = Math.max(0, ticketDiff - cancelledAncillaryTotal); - const surchargeNote = surchargeAmount > 0 - ? `Phu thu: ${surchargeAmount.toLocaleString('vi-VN')} VND` - : null; - const finalAdminNotes = [adminNotes, surchargeNote].filter(Boolean).join(' | ') || null; + const finalAdminNotes = surchargeAmount > 0 + ? `${adminNotes ? adminNotes + ' | ' : ''}PHỤ THU: ${surchargeAmount.toLocaleString('vi-VN')} VND chưa thu từ khách` + : adminNotes; - // Update booking: chuyến mới + đổi status → date_changed + // Update booking flight & status await client.query(QCD.UPDATE_BOOKING_FLIGHT, [ request.new_flight_id, request.new_seat_class, @@ -841,25 +963,29 @@ const approveDateChange = async (adminId, requestCode, adminNotes = null) => { await client.query(QCD.UPDATE_DATE_CHANGE_STATUS, [ 'approved', adminId, - finalAdminNotes, + adminNotes, requestCode, ]); await client.query('COMMIT'); console.log(`[DateChange Approve] SUCCESS: ${requestCode}`); + try { + await createDateChangeNotification({ + event: 'DATE_CHANGE_APPROVED', + request: { ...request, status: 'approved', admin_notes: adminNotes }, + booking: { booking_code: request.booking_code }, + adminId, + }); + } catch (notifErr) { + console.error('[DateChange] Notification error:', notifErr.message); + } + return { success: true, request_code: requestCode, status: 'approved', message: 'Yêu cầu đổi ngày bay đã được duyệt thành công', - price_settled: { - ticket_difference: ticketDiff, - cancelled_ancillary_total: cancelledAncillaryTotal, - refund_amount: refundableAmount, - refund_code: relatedRefundCode, - surcharge_amount: surchargeAmount, - }, }; } catch (err) { @@ -872,7 +998,7 @@ const approveDateChange = async (adminId, requestCode, adminNotes = null) => { }; -// REJECT + CANCEL + GET (giữ nguyên) +// ─── ADMIN REJECT ────────────────────────────────────────── const rejectDateChange = async (adminId, requestCode, reason) => { if (!reason || reason.trim().length < 10) { @@ -888,8 +1014,8 @@ const rejectDateChange = async (adminId, requestCode, reason) => { const request = requestResult.rows[0]; - // Bug 5 Fix: cho phép reject cả 'pending_otp' (chưa xác nhận OTP) và 'pending' - if (!['pending', 'pending_otp'].includes(request.status)) { + // Cho phép reject các yêu cầu chưa hoàn tất xử lý cuối cùng + if (!ACTIVE_DATE_CHANGE_STATUSES.includes(request.status)) { throw new Error(`Không thể từ chối yêu cầu có trạng thái "${request.status}"`); } @@ -960,7 +1086,7 @@ const cancelDateChangeRequest = async (userId, requestCode) => { await client.query(QCD.UPDATE_DATE_CHANGE_STATUS, [ 'cancelled', userId, - 'User cancelled request', + 'User cancelled', requestCode, ]); @@ -1063,5 +1189,6 @@ module.exports = { confirmDateChangePayment, getDateChangePaymentStatus, cancelDateChangePayment, + getAdminDateChanges, }; \ No newline at end of file diff --git a/src/services/flight-combo.service.js b/src/services/flight-combo.service.js index 0b2a2d5..8c5ee66 100644 --- a/src/services/flight-combo.service.js +++ b/src/services/flight-combo.service.js @@ -1,37 +1,105 @@ -/** - * Flight Combo Service - Tìm kiếm vé multi-leg cross-airline - * - * Tim combos: one-way (direct/1-stop/2-stop) + roundtrip (cross-product) - * Ranking theo: gia + thoi gian + do tien loi - */ +/* +============================================================ +FLIGHT COMBO SERVICE - Tìm combos multi-leg cross-airline +============================================================ + +Tìm combos: one-way (direct/1-stop/2-stop) + roundtrip + +Features: +- Direct: Bay thẳng (0 stop) +- 1-stop: 1 điểm dừng (A → X → B) +- 2-stop: 2 điểm dừng (A → X → Y → B) + +Ranking theo: giá + thời gian + độ tiện lợi +============================================================ +*/ const pool = require('../config/db'); const QC = require('../queries/flight-combo.queries'); const { applyDynamicPricing } = require('../utils/pricing'); +const seasonService = require('./season.service'); + +// Helpers + +const VALID_SEAT_CLASSES = new Set(['economy', 'business', 'first']); +const VALID_SORT_OPTIONS = new Set(['recommended', 'price', 'duration']); + +const normalizePassengers = ({ adults = 1, children = 0, infants = 0 }) => { + const normalized = { + adults: Math.max(1, parseInt(adults, 10) || 1), + children: Math.max(0, parseInt(children, 10) || 0), + infants: Math.max(0, parseInt(infants, 10) || 0), + }; -// ============== HELPER FUNCTIONS ============== + if (normalized.infants > normalized.adults) { + throw new Error('Số em bé không được nhiều hơn số người lớn'); // check lỗi lại + } + + return normalized; +}; + +const validateMixedSearchParams = ({ from, to, outbound_date, return_date, seat_class, sort_by, max_stops }) => { + if (!from || !to || !outbound_date) { + throw new Error('Thieu tham so bat buoc: from, to, outbound_date'); + } -// Tinh thoi gian dung (layover) giua 2 chuyen bay (phut) + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(outbound_date)) { + throw new Error('outbound_date không hợp lệ (định dạng: YYYY-MM-DD)'); + } + if (return_date && !dateRegex.test(return_date)) { + throw new Error('return_date không hợp lệ (định dạng: YYYY-MM-DD)'); + } + if (return_date && new Date(return_date) < new Date(outbound_date)) { + throw new Error('return_date không được sớm hơn outbound_date'); + } + if (seat_class && !VALID_SEAT_CLASSES.has(String(seat_class).toLowerCase())) { + throw new Error('seat_class phải là economy, business hoặc first'); + } + if (sort_by && !VALID_SORT_OPTIONS.has(String(sort_by).toLowerCase())) { + throw new Error('sort_by phải là recommended, price hoặc duration'); + } + const stops = parseInt(max_stops, 10); + if (!Number.isNaN(stops) && (stops < 0 || stops > 2)) { + throw new Error('max_stops phải nằm trong khoảng 0 đến 2'); + } +}; + +const sortCombosByPreference = (combos, sortBy) => { + const normalized = String(sortBy || 'recommended').toLowerCase(); + if (normalized === 'price') { + return [...combos].sort((a, b) => a.total_price - b.total_price); + } + if (normalized === 'duration') { + return [...combos].sort((a, b) => a.total_duration_minutes - b.total_duration_minutes); + } + return rankCombos(combos); +}; + +// Tính thời gian dừng (layover) giữa 2 chuyến bay (phút) const calcLayoverMinutes = (arrivalTime, departureTime) => { const arrival = new Date(arrivalTime).getTime(); const departure = new Date(departureTime).getTime(); return Math.round((departure - arrival) / 60000); }; -// Kiem tra layover hop le +// Kiểm tra layover có hợp lệ không const isValidLayover = (minutes) => { return minutes >= QC.MIN_LAYOVER_MINUTES && minutes <= QC.MAX_LAYOVER_HOURS * 60; }; -// Format 1 flight row -> 1 leg object -const formatLeg = (row, adults, children, infants) => { +// Format 1 flight row → 1 leg object (with season pricing) +const formatLeg = async (row, adults, children, infants) => { const basePrice = parseFloat(row.base_price) || 0; + const seasonInfo = await seasonService.getSeasonInfo(row.departure_time); + const seasonMult = seasonInfo ? seasonInfo.multiplier : 1.0; const dynamicPrice = applyDynamicPricing( basePrice, row.available_seats, row.total_seats, - row.departure_time + row.departure_time, + seasonMult ); return { @@ -58,6 +126,7 @@ const formatLeg = (row, adults, children, infants) => { time: row.arrival_time, }, duration_minutes: row.duration_minutes, + season_info: seasonInfo, seat: { class: row.seat_class, available_seats: row.available_seats, @@ -67,7 +136,7 @@ const formatLeg = (row, adults, children, infants) => { }; }; -// Tinh tong gia theo loai hanh khach +// Tính tổng giá theo loại hành khách const calcTotalPrice = (basePrice, adults, children, infants) => { const adultTotal = basePrice * adults; const childTotal = basePrice * 0.75 * children; @@ -75,7 +144,7 @@ const calcTotalPrice = (basePrice, adults, children, infants) => { return Math.round(adultTotal + childTotal + infantTotal); }; -// Format 1 combo (goc la 1 flight hoac nhieu flights) +// Format 1 combo (gốc là 1 flight hoặc nhiều flights) const formatCombo = (legs, direction, adults, children, infants) => { // Tinh tong gia tat ca legs const totalPrice = legs.reduce((sum, leg) => { @@ -113,9 +182,9 @@ const formatCombo = (legs, direction, adults, children, infants) => { }; }; -// ============== TIM CHUYEN BAY ============== +// ─── Tìm chuyến bay ─────────────────────────────── -// Tim chuyen bay truc tiep (0 stop) +// Tìm chuyến bay trực tiếp (0 stop) const findDirectFlights = async (from, to, date, seatClass, passengers) => { const seatsNeeded = passengers.adults + passengers.children; @@ -127,16 +196,14 @@ const findDirectFlights = async (from, to, date, seatClass, passengers) => { date, ]); - return result.rows.map(row => formatCombo( - [formatLeg(row, passengers.adults, passengers.children, passengers.infants)], - 'outbound', - passengers.adults, - passengers.children, - passengers.infants - )); + const combos = await Promise.all(result.rows.map(async (row) => { + const leg = await formatLeg(row, passengers.adults, passengers.children, passengers.infants); + return formatCombo([leg], 'outbound', passengers.adults, passengers.children, passengers.infants); + })); + return combos; }; -// Tim chuyen bay 1 stop (A -> X -> B) +// Tìm chuyến bay 1 stop (A → X → B) const findOneStopFlights = async (from, to, date, seatClass, passengers) => { const seatsNeeded = passengers.adults + passengers.children; @@ -148,24 +215,9 @@ const findOneStopFlights = async (from, to, date, seatClass, passengers) => { date, ]); - // Buoc 2: Tim tat ca chuyen bay den B - const secondLegs = await pool.query(QC.FIND_SECOND_LEG, [ - 'DUMMY', // Se thay the trong loop - to.toUpperCase(), - seatClass.toLowerCase(), - seatsNeeded, - date, - ]); - // Map theo airport trung gian const intermediateAirports = [...new Set(firstLegs.rows.map(r => r.arrival_code))]; - const combos = []; - - for (const airport of intermediateAirports) { - // Tim chuyen bay buoc 1 den airport nay - const leg1Options = firstLegs.rows.filter(r => r.arrival_code === airport); - - // Tim chuyen bay buoc 2 tu airport nay den B + const secondLegResults = await Promise.all(intermediateAirports.map(async (airport) => { const leg2Result = await pool.query(QC.FIND_SECOND_LEG, [ airport, to.toUpperCase(), @@ -174,20 +226,29 @@ const findOneStopFlights = async (from, to, date, seatClass, passengers) => { date, ]); - if (leg2Result.rows.length === 0) continue; + return [airport, leg2Result.rows]; + })); + const secondLegMap = new Map(secondLegResults); + const combos = []; + + for (const airport of intermediateAirports) { + const leg1Options = firstLegs.rows.filter(r => r.arrival_code === airport); + const leg2Rows = secondLegMap.get(airport) || []; + + if (leg2Rows.length === 0) continue; for (const leg1 of leg1Options) { - for (const leg2 of leg2Result.rows) { + for (const leg2 of leg2Rows) { const layover = calcLayoverMinutes(leg1.arrival_time, leg2.departure_time); - - // Kiem tra thoi gian dung hop le if (!isValidLayover(layover)) continue; + const [formattedLeg1, formattedLeg2] = await Promise.all([ + formatLeg(leg1, passengers.adults, passengers.children, passengers.infants), + formatLeg(leg2, passengers.adults, passengers.children, passengers.infants), + ]); + combos.push(formatCombo( - [ - formatLeg(leg1, passengers.adults, passengers.children, passengers.infants), - formatLeg(leg2, passengers.adults, passengers.children, passengers.infants), - ], + [formattedLeg1, formattedLeg2], 'outbound', passengers.adults, passengers.children, @@ -200,7 +261,7 @@ const findOneStopFlights = async (from, to, date, seatClass, passengers) => { return combos; }; -// Tim chuyen bay 2 stop (A -> X -> Y -> B) +// Tìm chuyến bay 2 stop (A → X → Y → B) const findTwoStopFlights = async (from, to, date, seatClass, passengers) => { const seatsNeeded = passengers.adults + passengers.children; const combos = []; @@ -247,12 +308,13 @@ const findTwoStopFlights = async (from, to, date, seatClass, passengers) => { const layover2 = calcLayoverMinutes(leg2.arrival_time, leg3.departure_time); if (!isValidLayover(layover2)) continue; + // Await all legs + const formattedLeg1 = await formatLeg(leg1, passengers.adults, passengers.children, passengers.infants); + const formattedLeg2 = await formatLeg(leg2, passengers.adults, passengers.children, passengers.infants); + const formattedLeg3 = await formatLeg(leg3, passengers.adults, passengers.children, passengers.infants); + combos.push(formatCombo( - [ - formatLeg(leg1, passengers.adults, passengers.children, passengers.infants), - formatLeg(leg2, passengers.adults, passengers.children, passengers.infants), - formatLeg(leg3, passengers.adults, passengers.children, passengers.infants), - ], + [formattedLeg1, formattedLeg2, formattedLeg3], 'outbound', passengers.adults, passengers.children, @@ -265,9 +327,9 @@ const findTwoStopFlights = async (from, to, date, seatClass, passengers) => { return combos; }; -// ============== TIM COMBOS THEO CHIEU ============== +// ─── Tìm combos theo chiều ────────────────────── -// Tim tat ca combos cho 1 chieu (direct + 1-stop + 2-stop) +// Tìm tất cả combos cho 1 chiều (direct + 1-stop + 2-stop) const findAllCombosForDirection = async (from, to, date, seatClass, passengers, maxStops = 2) => { const combos = []; @@ -293,28 +355,26 @@ const findAllCombosForDirection = async (from, to, date, seatClass, passengers, return combos.sort((a, b) => a.total_price - b.total_price); }; -// ============== RANKING ============== +// ─── Ranking ────────────────────────────────── -// Cham diem combo +// Chấm điểm combo const scoreCombo = (combo) => { let score = 0; - // Gia (40%) - gia cao thi score cao (can convert ve negative) + // Giá thấp hơn tốt hơn score += combo.total_price * 0.4; - // Thoi gian bay (30%) + // Thời gian tổng thấp hơn tốt hơn score += combo.total_duration_minutes * 0.3; - // Thoi gian dung (20%) - gap doi neu qua lau + // Thời gian dừng thấp hơn tốt hơn, nhưng phạt mạnh nếu ngoài ngưỡng hợp lệ score += combo.total_layover_minutes * 0.2; - // Diem thuong cho nhieu hang (vi linh hoat hon) if (combo.airlines && combo.airlines.length > 1) { score -= 100; } - // Phat neu thoi gian dung qua ngan hoac qua lau - if (combo.total_layover_minutes < QC.MIN_LAYOVER_MINUTES) { + if (combo.stops > 0 && combo.total_layover_minutes < QC.MIN_LAYOVER_MINUTES) { score += 9999; } if (combo.total_layover_minutes > QC.MAX_LAYOVER_HOURS * 60) { @@ -324,31 +384,28 @@ const scoreCombo = (combo) => { return score; }; -// Rank danh sach combos +// Sắp xếp danh sách combos theo điểm const rankCombos = (combos) => { return combos .map(c => ({ ...c, score: scoreCombo(c) })) .sort((a, b) => a.score - b.score); }; -// ============== MAIN API ============== - -/** - * Tim kiem ve may bay - ket hop multi-leg + cross-airline - * - * @param {Object} params - * @param {string} params.from - Ma san bay di (VD: HAN) - * @param {string} params.to - Ma san bay den (VD: SGN) - * @param {string} params.outbound_date - Ngay di (YYYY-MM-DD) - * @param {string} params.return_date - Ngay ve (YYYY-MM-DD) - null neu one-way - * @param {number} params.adults - So nguoi lon - * @param {number} params.children - So tre em - * @param {number} params.infants - So em be - * @param {string} params.seat_class - economy | business | first - * @param {number} params.max_stops - So stop toi da (0, 1, 2) - * @param {number} params.limit - So ket qua toi da - * @param {string} params.sort_by - recommended | price | duration - */ +// ─── Main API ────────────────────────────────── + +// Tìm kiếm vé máy bay - kết hợp multi-leg + cross-airline +// @param {Object} params +// @param {string} params.from - Ma san bay di (VD: HAN) +// @param {string} params.to - Ma san bay den (VD: SGN) +// @param {string} params.outbound_date - Ngay di (YYYY-MM-DD) +// @param {string} params.return_date - Ngay ve (YYYY-MM-DD) - null neu one-way +// @param {number} params.adults - So nguoi lon +// @param {number} params.children - So tre em +// @param {number} params.infants - So em be +// @param {string} params.seat_class - economy | business | first +// @param {number} params.max_stops - So stop toi da (0, 1, 2) +// @param {number} params.limit - So ket qua toi da +// @param {string} params.sort_by - recommended | price | duration const mixedSearch = async (params) => { const { from, @@ -364,16 +421,10 @@ const mixedSearch = async (params) => { sort_by = 'recommended', } = params; - // Validate - if (!from || !to || !outbound_date) { - throw new Error('Thieu tham so bat buoc: from, to, outbound_date'); - } + validateMixedSearchParams({ from, to, outbound_date, return_date, seat_class, sort_by, max_stops }); - const passengers = { - adults: parseInt(adults) || 1, - children: parseInt(children) || 0, - infants: parseInt(infants) || 0, - }; + const passengers = normalizePassengers({ adults, children, infants }); + const parsedLimit = Math.max(1, parseInt(limit, 10) || 20); // ========== ONE-WAY ========== const oneWayOptions = await findAllCombosForDirection( @@ -384,26 +435,16 @@ const mixedSearch = async (params) => { let roundTripOptions = []; if (return_date) { - // Tim tat ca outbound combos - const outboundCombos = await findAllCombosForDirection( - from, to, outbound_date, seat_class, passengers, max_stops - ); - - // Tim tat ca return combos - const returnCombos = await findAllCombosForDirection( + const outboundCombos = sortCombosByPreference(oneWayOptions, 'recommended').slice(0, 15); + const returnCombosRaw = await findAllCombosForDirection( to, from, return_date, seat_class, passengers, max_stops ); - - // Cross-product: moi outbound × moi return - // Gioi han de tranh so luong qua lon - const topOutbound = outboundCombos.slice(0, 30); - const topReturn = returnCombos.slice(0, 30); + const returnCombos = sortCombosByPreference(returnCombosRaw, 'recommended').slice(0, 15); roundTripOptions = []; - for (const ob of topOutbound) { - for (const rt of topReturn) { - // Tinh tong gia & thoi gian + for (const ob of outboundCombos) { + for (const rt of returnCombos) { const totalPrice = ob.total_price + rt.total_price; const totalDuration = ob.total_duration_minutes + rt.total_duration_minutes; const totalLayover = ob.total_layover_minutes + rt.total_layover_minutes; @@ -416,43 +457,22 @@ const mixedSearch = async (params) => { total_duration_minutes: totalDuration, total_layover_minutes: totalLayover, airlines: allAirlines, + stops: ob.stops + rt.stops, stops_outbound: ob.stops, stops_return: rt.stops, }); } } - - // Rank roundtrip - roundTripOptions = roundTripOptions - .map(c => ({ - ...c, - score: scoreCombo({ - total_price: c.total_price, - total_duration_minutes: c.total_duration_minutes, - total_layover_minutes: c.total_layover_minutes, - airlines: c.airlines, - }), - })) - .sort((a, b) => a.score - b.score); } // ========== APPLY SORT ========== - let sortedOneWay = [...oneWayOptions]; - let sortedRoundTrip = [...roundTripOptions]; - - if (sort_by === 'price') { - sortedOneWay.sort((a, b) => a.total_price - b.total_price); - sortedRoundTrip.sort((a, b) => a.total_price - b.total_price); - } else if (sort_by === 'duration') { - sortedOneWay.sort((a, b) => a.total_duration_minutes - b.total_duration_minutes); - sortedRoundTrip.sort((a, b) => a.total_duration_minutes - b.total_duration_minutes); - } - // sort_by = 'recommended' da dung score sort o tren + const sortedOneWay = sortCombosByPreference(oneWayOptions, sort_by); + const sortedRoundTrip = sortCombosByPreference(roundTripOptions, sort_by); // ========== RETURN ========== return { - one_way_options: sortedOneWay.slice(0, parseInt(limit)), - roundtrip_combinations: sortedRoundTrip.slice(0, parseInt(limit)), + one_way_options: sortedOneWay.slice(0, parsedLimit), + roundtrip_combinations: sortedRoundTrip.slice(0, parsedLimit), summary: { one_way_count: oneWayOptions.length, roundtrip_count: roundTripOptions.length, diff --git a/src/services/flight.service.js b/src/services/flight.service.js index 8d97e16..03620f7 100644 --- a/src/services/flight.service.js +++ b/src/services/flight.service.js @@ -1,8 +1,25 @@ -// src/services/flight.service.js +/* +============================================================ +FLIGHT SERVICE - Tìm kiếm và quản lý chuyến bay +============================================================ + +Các chức năng chính: +- Browse: Xem các chuyến bay phổ biến (homepage) +- Search: Tìm kiếm theo route, ngày, hạng ghế +- Combo: Tìm combos multi-leg (direct, 1-stop, 2-stop) +- Seat Map: Xem sơ đồ ghế +- Flight Tracker: Theo dõi vị trí máy bay +- Price Calendar: Lịch giá theo tháng +============================================================ +*/ const pool = require('../config/db'); const QF = require('../queries/flight.queries'); -// Lấy chuyến bay đa dạng (không cần from/to cụ thể) — dùng cho Browse + homepage +// Import price alert service +const { generatePriceAlertsForFlights, getDetailedAnalysis } = require('./price-alert.service'); + +// Lấy danh sách chuyến bay để browse (homepage) +// Mỗi route chỉ lấy 1 chuyến bay sắp tới nhất, random order const browseFlights = async (limit = 40) => { const { rows } = await pool.query(` WITH ranked AS ( @@ -35,7 +52,7 @@ const browseFlights = async (limit = 40) => { ORDER BY RANDOM() LIMIT $1 `, [limit]); - return formatFlights(rows, 1, 0, 0); + return await formatFlights(rows, 1, 0, 0); }; const getFlightsByAirline = async (airlineCode, seatClass = 'economy') => { @@ -63,10 +80,11 @@ const getFlightsByAirline = async (airlineCode, seatClass = 'economy') => { ORDER BY f.departure_time ASC LIMIT 200 `, [airlineCode.toUpperCase(), cls]); - return formatFlights(rows, 1, 0, 0); + return await formatFlights(rows, 1, 0, 0); }; -const recommendFlights = async ({ userId, fromAirport, toAirport, limit = 15 }) => { +// Gợi ý chuyến bay cho user dựa trên route cụ thể +const suggestFlightsForRoute = async (limit, fromAirport, toAirport) => { const query = ` SELECT f.id AS flight_id, @@ -110,18 +128,17 @@ const recommendFlights = async ({ userId, fromAirport, toAirport, limit = 15 }) LIMIT $1`; const { rows } = await pool.query(query, [limit, fromAirport, toAirport]); - return formatFlights(rows, 1, 0, 0); + return await formatFlights(rows, 1, 0, 0); }; +// Format thời gian bay thành string (VD: "2h30m") const formatDuration = (minutes) => { const h = Math.floor(minutes / 60); const m = minutes % 60; return m > 0 ? `${h}h${m}m` : `${h}h`; }; -/** - * Tính tổng tiền vé theo loại hành khách - */ +// Tính tổng tiền vé theo loại hành khách const calcTotalPrice = (basePrice, adults, children, infants) => { const adultTotal = basePrice * adults; const childTotal = basePrice * 0.75 * children; @@ -129,9 +146,7 @@ const calcTotalPrice = (basePrice, adults, children, infants) => { return Math.round(adultTotal + childTotal + infantTotal); }; -/** - * Tạo danh sách lựa chọn hành lý thêm đã tính sẵn giá (per person) - */ +// Tạo danh sách lựa chọn hành lý thêm đã tính sẵn giá const buildBaggageOptions = (extraBaggagePrice) => { const pricePerKg = parseFloat(extraBaggagePrice) || 0; return [ @@ -143,16 +158,23 @@ const buildBaggageOptions = (extraBaggagePrice) => { ]; }; -// ── Dynamic pricing helpers (applied at search time) ────────────────────────── - -// Weekend premium: Fri/Sat/Sun cost more +// Format danh sách chuyến bay thành response +// Áp dụng dynamic pricing (theo ngày, weekend, demand, season) const { applyDynamicPricing } = require('../utils/pricing'); +const seasonService = require('./season.service'); + +const getSeasonAwarePrice = async (basePrice, availableSeats, totalSeats, departureTime) => { + const seasonMultiplier = await seasonService.getSeasonMultiplier(departureTime); + return applyDynamicPricing(basePrice, availableSeats, totalSeats, departureTime, seasonMultiplier); +}; -const formatFlights = (rows, adults, children, infants) => - rows.map((r) => { +const formatFlights = async (rows, adults, children, infants) => { + const results = await Promise.all(rows.map(async (r) => { const base = parseFloat(r.base_price) || 0; const extraPrice = parseFloat(r.extra_baggage_price) || 0; - const price = applyDynamicPricing(base, r.available_seats, r.total_seats, r.departure_time); + const seasonInfo = await seasonService.getSeasonInfo(r.departure_time); + const seasonMult = seasonInfo ? seasonInfo.multiplier : 1.0; + const price = applyDynamicPricing(base, r.available_seats, r.total_seats, r.departure_time, seasonMult); return { flight_id: r.flight_id, @@ -172,6 +194,7 @@ const formatFlights = (rows, adults, children, infants) => }, duration_minutes: r.duration_minutes, duration_label: formatDuration(r.duration_minutes), + season_info: seasonInfo, seat: { class: r.seat_class, available_seats: r.available_seats, @@ -189,9 +212,89 @@ const formatFlights = (rows, adults, children, infants) => total_price: calcTotalPrice(price, adults, children, infants), }, }; - }); + })); + return results; +}; + +// Validate tham số tìm kiếm + +const queryFlights = async ({ + departure_code, + arrival_code, + departure_date, + adults = 1, + children = 0, + seat_class, + sort_by, + min_price, + max_price, + airline_code, + departure_city, + arrival_city, +}) => { + const cls = (seat_class || 'economy').toLowerCase(); + const seatsNeeded = Math.max(1, (parseInt(adults, 10) || 1) + (parseInt(children, 10) || 0)); + + const conditions = [ + "f.status = 'scheduled'", + 'f.is_active = true', + 'f.departure_time > NOW()', + `dep.code = $1`, + `arr.code = $2`, + `fs.class = $3`, + `DATE(f.departure_time) = $4`, + `fs.available_seats >= $5`, + ]; + + const params = [ + String(departure_code).toUpperCase(), + String(arrival_code).toUpperCase(), + cls, + departure_date, + seatsNeeded, + ]; + + let paramIndex = params.length + 1; + + if (airline_code) { + conditions.push(`al.code = $${paramIndex++}`); + params.push(String(airline_code).toUpperCase()); + } + + if (departure_city) { + conditions.push(`LOWER(dep.city) = LOWER($${paramIndex++})`); + params.push(departure_city); + } + + if (arrival_city) { + conditions.push(`LOWER(arr.city) = LOWER($${paramIndex++})`); + params.push(arrival_city); + } -// ─── Validation ──────────────────────────────────────────────────────────────── + if (min_price) { + conditions.push(`fs.base_price >= $${paramIndex++}`); + params.push(parseFloat(min_price)); + } + + if (max_price) { + conditions.push(`fs.base_price <= $${paramIndex++}`); + params.push(parseFloat(max_price)); + } + + const orderByMap = { + price_asc: 'fs.base_price ASC, f.departure_time ASC', + price_desc: 'fs.base_price DESC, f.departure_time ASC', + departure_asc: 'f.departure_time ASC', + departure_desc: 'f.departure_time DESC', + duration_asc: 'f.duration_minutes ASC, f.departure_time ASC', + duration_desc: 'f.duration_minutes DESC, f.departure_time ASC', + }; + + const orderBy = orderByMap[String(sort_by || '').toLowerCase()] || 'f.departure_time ASC, fs.base_price ASC'; + const query = QF.SEARCH_FLIGHTS(conditions.join('\n AND '), orderBy); + const { rows } = await pool.query(query, params); + return rows; +}; const validateSearchParams = ({ departure_code, arrival_code, departure_date, adults, children, infants, seat_class }) => { if (!departure_code) throw new Error("Mã sân bay đi là bắt buộc"); @@ -212,56 +315,9 @@ const validateSearchParams = ({ departure_code, arrival_code, departure_date, ad } }; -const queryFlights = async ({ - departure_code, arrival_code, departure_date, - adults, children, infants, - seat_class, sort_by = "price_asc", - min_price, max_price, airline_code, - departure_city, arrival_city, -}) => { - const a = parseInt(adults) || 1; - const c = parseInt(children) || 0; - const cls = seat_class.toLowerCase(); - const seatsNeeded = a + c; - - const sortMap = { - price_asc: "fs.base_price ASC", - price_desc: "fs.base_price DESC", - duration_asc: "f.duration_minutes ASC", - departure_asc: "f.departure_time ASC", - }; - const orderBy = sortMap[sort_by] || sortMap["price_asc"]; - - const conditions = []; - const values = []; - let idx = 1; - - conditions.push(`dep.code = $${idx++}`); values.push(departure_code.toUpperCase()); - conditions.push(`arr.code = $${idx++}`); values.push(arrival_code.toUpperCase()); - conditions.push(`DATE(f.departure_time) = $${idx++}`); values.push(departure_date); - conditions.push(`fs.class = $${idx++}`); values.push(cls); - conditions.push(`fs.available_seats >= $${idx++}`); values.push(seatsNeeded); - conditions.push(`f.status = 'scheduled'`); - conditions.push(`f.is_active = TRUE`); - - if (min_price !== undefined && min_price !== "") { conditions.push(`fs.base_price >= $${idx++}`); values.push(parseFloat(min_price)); } - if (max_price !== undefined && max_price !== "") { conditions.push(`fs.base_price <= $${idx++}`); values.push(parseFloat(max_price)); } - if (airline_code) { conditions.push(`al.code = $${idx++}`); values.push(airline_code.toUpperCase()); } - if (departure_city) { conditions.push(`LOWER(dep.city) LIKE LOWER($${idx++})`); values.push(`%${departure_city}%`); } - if (arrival_city) { conditions.push(`LOWER(arr.city) LIKE LOWER($${idx++})`); values.push(`%${arrival_city}%`); } - - const result = await pool.query( - QF.SEARCH_FLIGHTS(conditions.join(" AND "), orderBy), - values - ); - return result.rows; -}; - -// ─── Exported Functions ─────────────────────────────────────────────────────── +// ─── Các functions chính ───────────────────────────────────────────── -/** - * GET /api/flights/search - */ +// Tìm kiếm chuyến bay - GET /api/flights/search const searchFlights = async (params) => { const { departure_code, arrival_code, departure_date, @@ -285,7 +341,10 @@ const searchFlights = async (params) => { }; const outboundRows = await queryFlights(baseParams); - const outboundFlights = formatFlights(outboundRows, a, c, i); + let outboundFlights = await formatFlights(outboundRows, a, c, i); + + // Generate price alerts for outbound flights + outboundFlights = await generatePriceAlertsForFlights(outboundFlights); let returnFlights = null; if (return_date) { @@ -295,7 +354,10 @@ const searchFlights = async (params) => { arrival_code: departure_code, departure_date: return_date, }); - returnFlights = formatFlights(returnRows, a, c, i); + returnFlights = await formatFlights(returnRows, a, c, i); + + // Generate price alerts for return flights + returnFlights = await generatePriceAlertsForFlights(returnFlights); } return { @@ -306,19 +368,17 @@ const searchFlights = async (params) => { }; }; -/** - * Lấy chuyến bay phổ biến nhất - */ +// Lấy chuyến bay phổ biến nhất theo route const getPopularFlights = async (fromAirport, toAirport, limit) => { const isGeneralMode = !fromAirport || !toAirport; let query, params; if (isGeneralMode) { - query = queries.GET_POPULAR_FLIGHTS_GENERAL; + query = QF.GET_POPULAR_FLIGHTS_GENERAL; params = [limit]; } else { - query = queries.GET_POPULAR_FLIGHTS_ROUTE; + query = QF.GET_POPULAR_FLIGHTS_ROUTE; params = [fromAirport, toAirport, limit]; } @@ -326,18 +386,14 @@ const getPopularFlights = async (fromAirport, toAirport, limit) => { return rows; }; -/** - * Tạo lưới ghế ảo dựa vào tổng số ghế của class - * Layout chuẩn: hàng × cột. Mỗi hàng có đến 6 ghế (A-F) với lối đi ở giữa. - * first → 4 ghế/hàng: A B _ C D (2-2) - * business → 4 ghế/hàng: A B _ C D (2-2) - * economy → 6 ghế/hàng: A B C _ D E F (3-3) - * - * @param {string} seatClass - "first" | "business" | "economy" - * @param {number} totalSeats - Tổng số ghế của class này trên chuyến bay - * @param {number} startRow - Số hàng bắt đầu (để ghép nhiều class) - * @returns {{ rows: object[], columns: string[], seatsPerRow: number, lastRow: number }} - */ +// Tạo lưới ghế ảo để hiển thị seat map +// Layout chuẩn: +// - first/business: 4 ghế/hàng (A B _ C D) +// - economy: 6 ghế/hàng (A B C _ D E F) +// @param {string} seatClass - "first" | "business" | "economy" +// @param {number} totalSeats - Tổng số ghế của class này trên chuyến bay +// @param {number} startRow - Số hàng bắt đầu (để ghép nhiều class) +// @returns {{ rows: object[], columns: string[], seatsPerRow: number, lastRow: number }} const buildSeatLayout = (seatClass, totalSeats, startRow = 1) => { const layoutMap = { first: { columns: ["A", "B", "C", "D"], seatsPerRow: 4 }, @@ -364,10 +420,8 @@ const buildSeatLayout = (seatClass, totalSeats, startRow = 1) => { return { rows, columns, seatsPerRow, lastRow: startRow + numRows - 1 }; }; -/** - * GET /api/flights/:id/seat-map - * SB-03: Xem sơ đồ ghế ngồi – trạng thái từng ghế (available / occupied) - */ +// Lấy sơ đồ ghế ngồi - GET /api/flights/:id/seat-map +// Trả về trạng thái từng ghế (available/occupied) const getSeatMap = async (flightId, params = {}) => { const { seat_class = null } = params; @@ -433,11 +487,9 @@ const getSeatMap = async (flightId, params = {}) => { }; -/** - * Tính vị trí máy bay hiện tại theo thời gian thực - * Dùng cho Flight Tracker - * Logic: lerp(dep_coords, arr_coords, progress) - */ +// Tính vị trí máy bay hiện tại (Flight Tracker) +// Sử dụng linear interpolation giữa 2 sân bay +// progress = 0 → vừa cất cánh, progress = 1 → vừa hạ cánh const getFlightPosition = async (flightId) => { // 1. Lấy thông tin chuyến bay + tọa độ 2 sân bay const result = await pool.query(QF.SELECT_FLIGHT_POSITION, [flightId]); @@ -511,14 +563,14 @@ const getAirports = async () => { return rows; }; -// ─── getAirlines ────────────────────────────────────────────────────────────── +// Lấy danh sách hãng bay const getAirlines = async () => { const { rows } = await pool.query(QF.SELECT_ALL_AIRLINES); return rows; }; -// ─── getFlightById ───────────────────────────────────────────────────────────── +// Lấy chi tiết 1 chuyến bay const getFlightById = async (flightId, params = {}) => { const { adults = 1, children = 0, infants = 0 } = params; @@ -534,16 +586,38 @@ const getFlightById = async (flightId, params = {}) => { SELECT * FROM flight_seats WHERE flight_id = $1 ORDER BY base_price `, [flightId]); + const seats = await Promise.all(seatsResult.rows.map(async (s) => { + const dynamicPrice = await getSeasonAwarePrice( + parseFloat(s.base_price) || 0, + s.available_seats, + s.total_seats, + flight.departure_time + ); + + return { + ...s, + base_price: dynamicPrice, + total_price: calcTotalPrice(dynamicPrice, a, c, i), + }; + })); + + const detailedAnalysis = await getDetailedAnalysis({ + id: flight.flight_id || flight.id, + departure_time: flight.departure_time, + available_seats: seats[0]?.available_seats, + total_seats: seats[0]?.total_seats, + base_price: parseFloat(seats[0]?.base_price) || 0, + }); + return { ...flight, - seats: seatsResult.rows.map(s => ({ - ...s, - total_price: calcTotalPrice(parseFloat(s.base_price), a, c, i), - })), + season_info: await seasonService.getSeasonInfo(flight.departure_time), + price_analysis: detailedAnalysis, + seats, }; }; -// ─── getAlternativeFlights ──────────────────────────────────────────────────── +// Lấy các chuyến bay thay thế cho 1 chuyến bay đã full const getAlternativeFlights = async (flightId, params = {}) => { const { seat_class = 'economy', adults = 1, children = 0, infants = 0 } = params; @@ -562,10 +636,11 @@ const getAlternativeFlights = async (flightId, params = {}) => { flightId, orig.departure_code, orig.arrival_code, seat_class, seatsNeeded, departureDate ]); - return formatFlights(result.rows, a, c, i); + return await formatFlights(result.rows, a, c, i); }; -// ─── getFlightCombos ─────────────────────────────────────────────────────────── +// Tìm combos chuyến bay (direct + 1-stop) +// Ranking theo: giá + thời gian + độ tiện lợi const DEFAULT_COMBO_RULES = { minLayoverMinutes: 45, @@ -597,9 +672,9 @@ const rankCombo = (combo) => { }; }; -const buildComboLeg = (row, adults, children, infants) => { +const buildComboLeg = async (row, adults, children, infants) => { const base = parseFloat(row.base_price) || 0; - const price = applyDynamicPricing(base, row.available_seats, row.total_seats, row.departure_time); + const price = await getSeasonAwarePrice(base, row.available_seats, row.total_seats, row.departure_time); return { flight_id: row.flight_id, @@ -629,6 +704,7 @@ const buildComboLeg = (row, adults, children, infants) => { }, duration_minutes: row.duration_minutes, duration_label: formatDuration(row.duration_minutes), + season_info: await seasonService.getSeasonInfo(row.departure_time), seat: { class: row.seat_class, available_seats: row.available_seats, @@ -674,11 +750,11 @@ const getFlightCombos = async (params = {}) => { departure_date, ]); - const directCombos = directRows.rows.map((row) => ({ + const directCombos = await Promise.all(directRows.rows.map(async (row) => ({ stops: 0, total_layover_minutes: 0, - legs: [buildComboLeg(row, a, c, i)], - })); + legs: [await buildComboLeg(row, a, c, i)], + }))); const combos = [...directCombos]; @@ -708,10 +784,13 @@ const getFlightCombos = async (params = {}) => { const layoverMinutes = minutesBetween(leg1.arrival_time, leg2.departure_time); if (layoverMinutes < parseInt(min_layover_minutes) || layoverMinutes > parseInt(max_layover_minutes)) continue; + const firstLeg = await buildComboLeg(leg1, a, c, i); + const secondLeg = await buildComboLeg(leg2, a, c, i); + combos.push({ stops: 1, total_layover_minutes: layoverMinutes, - legs: [buildComboLeg(leg1, a, c, i), buildComboLeg(leg2, a, c, i)], + legs: [firstLeg, secondLeg], }); } } @@ -723,7 +802,7 @@ const getFlightCombos = async (params = {}) => { .slice(0, parseInt(limit) || 10); }; -// ─── getPriceCalendar ────────────────────────────────────────────────────────── +// Lấy lịch giá theo tháng - giá thấp nhất mỗi ngày const getPriceCalendar = async (params = {}) => { const { from, to, month, seat_class = 'economy', adults = 1 } = params; @@ -744,7 +823,7 @@ const getPriceCalendar = async (params = {}) => { const dateMap = {}; for (const row of result.rows) { const date = new Date(row.flight_date).toISOString().slice(0, 10); - const dynamicPrice = applyDynamicPricing( + const dynamicPrice = await getSeasonAwarePrice( Number(row.base_price), row.available_seats, row.total_seats, @@ -760,13 +839,10 @@ const getPriceCalendar = async (params = {}) => { module.exports = { browseFlights, getFlightsByAirline, - recommendFlights, searchFlights, getAirports, getAirlines, getFlightById, - getAlternativeFlights, - getPriceCalendar, getSeatMap, getFlightPosition, }; \ No newline at end of file diff --git a/src/services/loyalty.service.js b/src/services/loyalty.service.js index 6d22e29..8235656 100644 --- a/src/services/loyalty.service.js +++ b/src/services/loyalty.service.js @@ -3,39 +3,29 @@ const crypto = require('crypto'); const queries = require('../queries/loyalty.queries'); /* -========================================================= -SERVICE: LOYALTY / MEMBERSHIP BUSINESS LOGIC -========================================================= +============================================================ +LOYALTY SERVICE - Hệ thống tích điểm & Membership +============================================================ -POINTS SYSTEM — 3 cột, mỗi cột 1 vai trò riêng: +Hệ thống điểm 3 cột: +- lifetime_points: Chỉ cộng, không bao giờ giảm (lịch sử) +- tier_points: Cộng khi earn, trừ khi cancel/refund (dùng xét tier) +- current_points: Cộng khi earn, trừ khi redeem/cancel/refund (để tiêu) - lifetime_points chỉ cộng, không bao giờ giảm - → lịch sử vĩnh viễn, dùng cho báo cáo - → KHÔNG dùng để xét tier +Tiers: Bronze → Silver → Gold → Platinum - tier_points cộng khi earn, trừ khi cancel/refund - → dùng để xét tier + cronjob penalty - → KHÔNG trừ khi user redeem - - current_points cộng khi earn, trừ khi redeem/cancel/refund - → điểm user có thể tiêu - → KHÔNG ảnh hưởng tier - -ACTION lifetime tier current -──────────────────────────────────────────────── +Action lifetime tier current +────────────────────────────────────────────── Earn (booking) + pts + pts + pts Redeem reward — — - pts -Cancel / refund — - pts - pts → check downgrade +Cancel / refund — - pts - pts → check downgrade Cron annual reset — - 20% — → check downgrade - -========================================================= +============================================================ */ -// ========================================================= -// TIER CONFIG -// Sửa mốc điểm tại đây — đồng bộ với loyalty.cron.js -// ========================================================= +// Tier config - sửa mốc điểm tại đây +// Đồng bộ với loyalty.cron.js const TIERS = [ { name: 'Bronze', min_points: 0, multiplier: 1.0 }, { name: 'Silver', min_points: 5000, multiplier: 1.25 }, @@ -43,7 +33,7 @@ const TIERS = [ { name: 'Platinum', min_points: 50000, multiplier: 1.75 }, ]; -// Tier phù hợp nhất theo tier_points +// Xác định tier phù hợp theo tier_points const resolveTier = (tierPoints) => { let resolved = TIERS[0]; for (const t of TIERS) { @@ -52,8 +42,7 @@ const resolveTier = (tierPoints) => { return resolved; }; -// Next tier + progress bar -// progress: 0–99 khi chưa max tier, 100 khi đã Platinum +// Tính next tier + progress bar (0-99, 100 = max tier) const calcNextTierAndProgress = (currentTierName, tierPoints) => { const idx = TIERS.findIndex(t => t.name === currentTierName); const isMax = idx === TIERS.length - 1; @@ -79,9 +68,7 @@ const calcNextTierAndProgress = (currentTierName, tierPoints) => { }; -// ========================================================= -// BENEFITS — song ngữ, fallback sang DB nếu tier không khớp -// ========================================================= +// Benefits theo tier (song ngữ) const TIER_BENEFITS = { Member: { vi: ["Tích điểm không giới hạn", "Ưu đãi 5% cho chuyến bay tiếp theo", "Truy cập ưu tiên vào khuyến mãi"], @@ -101,9 +88,7 @@ const TIER_BENEFITS = { }, }; -// ========================================================= -// GET MEMBERSHIP INFO -// ========================================================= +// ─── Lấy thông tin membership ──────────────── exports.getMembershipInfo = async (userId, lang = 'vi') => { let result = await db.query(queries.GET_USER_LOYALTY, [userId]); @@ -165,16 +150,8 @@ exports.getMembershipInfo = async (userId, lang = 'vi') => { }; -// ========================================================= -// EARN POINTS — sau khi booking confirmed -// ========================================================= -// Flow: -// 1. Lấy multiplier từ tier hiện tại -// 2. Tính điểm = floor(price / 10000) × multiplier -// 3. Cộng cả 3 cột trong 1 query -// 4. Ghi transaction -// 5. Check upgrade tier -// ========================================================= +// ─── Tích điểm sau booking ────────────────── +// Flow: Lấy multiplier → Tính điểm → Cộng 3 cột → Ghi transaction → Check upgrade tier exports.earnPointsAfterBooking = async (userId, bookingId, totalPrice) => { // Lấy multiplier trực tiếp — nhẹ hơn gọi getMembershipInfo @@ -229,18 +206,8 @@ exports.earnPointsAfterBooking = async (userId, bookingId, totalPrice) => { }; -// ========================================================= -// REVOKE POINTS — khi cancel hoặc refund booking -// ========================================================= -// Flow: -// 1. Tìm điểm đã earn từ booking này trong loyalty_transactions -// 2. Trừ tier_points + current_points (KHÔNG trừ lifetime) -// 3. Không để âm — trừ tối đa đến 0 -// 4. Ghi transaction type 'revoke' -// 5. Check downgrade tier → notify nếu tụt hạng -// -// Gọi từ: booking.service khi status → 'cancelled' hoặc 'refunded' -// ========================================================= +// ─── Trừ điểm khi hủy booking ───────────────── +// Flow: Tìm điểm đã earn → Trừ tier_points + current_points → Ghi transaction → Check downgrade exports.revokePointsOnCancel = async (userId, bookingId) => { // Lấy điểm đã earn từ booking này @@ -306,13 +273,7 @@ exports.revokePointsOnCancel = async (userId, bookingId) => { }; -// ========================================================= -// REVOKE POINTS FOR REFUND (FULL hoặc PARTIAL) -// ========================================================= -// Được gọi từ refund.service khi refund hoàn thành -// - Full refund: revoke toàn bộ điểm đã earn từ booking -// - Partial refund: revoke theo tỷ lệ % refund -// ========================================================= +// ─── Trừ điểm khi refund (full/partial) ──── exports.revokePointsForRefund = async (bookingId, userId, refundType = 'full', refundPercent = 100) => { // Lấy điểm đã earn từ booking này const txResult = await db.query(` @@ -381,19 +342,9 @@ exports.revokePointsForRefund = async (bookingId, userId, refundType = 'full', r return { pointsRevoked: safeRevokeTier }; }; -// ========================================================= -// REDEEM REWARD -// ========================================================= -// Flow: -// 1. Lock row (SELECT FOR UPDATE) → tránh race condition -// 2. Kiểm tra reward tồn tại -// 3. Kiểm tra đủ current_points từ locked row -// 4. Sinh voucher code -// 5. Trừ current_points, RETURNING để lấy số chính xác -// 6. Ghi transaction -// -// lifetime_points và tier_points KHÔNG đổi → không tụt tier -// ========================================================= +// ─── Đổi điểm lấy reward ────────────────── +// Flow: Lock row → Kiểm tra reward → Kiểm tra đủ điểm → Sinh voucher → Trừ current_points → Ghi transaction +// Chỉ trừ current_points, không ảnh hưởng tier exports.redeemReward = async (userId, rewardId) => { const client = await db.connect(); @@ -488,18 +439,14 @@ exports.redeemReward = async (userId, rewardId) => { }; -// ========================================================= -// GET AVAILABLE REWARDS -// ========================================================= +// ─── Lấy danh sách rewards ─────────────── exports.getAvailableRewards = async () => { const result = await db.query(queries.GET_AVAILABLE_REWARDS); return result.rows; }; -// ========================================================= -// CHECK ALREADY EARNED — idempotent check -// ========================================================= +// ─── Kiểm tra đã tích điểm chưa ───────── exports.checkAlreadyEarned = async (userId, bookingId) => { const result = await db.query(` SELECT id @@ -513,9 +460,7 @@ exports.checkAlreadyEarned = async (userId, bookingId) => { }; -// ========================================================= -// TRANSACTION HISTORY -// ========================================================= +// ─── Lịch sử giao dịch ─────────────────── exports.getTransactionHistory = async (userId, limit = 20, offset = 0) => { const result = await db.query(queries.GET_LOYALTY_HISTORY, [ userId, @@ -535,9 +480,7 @@ exports.getTransactionCount = async (userId) => { }; -// ========================================================= -// TRIGGER ANNUAL RESET (admin manual trigger) -// ========================================================= +// ─── Trigger annual reset (admin) ────────── exports.triggerAnnualReset = async () => { const { runAnnualReset } = require('../scripts/Loyalty.cron'); await runAnnualReset(); @@ -550,14 +493,11 @@ exports.triggerAnnualReset = async () => { }; -// ========================================================= -// SYNC TIER — dùng chung sau mọi thay đổi điểm -// ========================================================= +// ─── Sync tier sau thay đổi điểm ────────── // direction: // 'upgrade' → chỉ lên tier, không xuống (sau earn) // 'downgrade' → chỉ xuống tier, không lên (sau cancel/refund/cron) // 'both' → sync tuyệt đối (dùng khi cần force sync) -// ========================================================= const syncTierAfterChange = async (userId, direction = 'both') => { const result = await db.query(` @@ -645,11 +585,8 @@ const syncTierAfterChange = async (userId, direction = 'both') => { // Export để cron job gọi sau khi penalty tier_points exports.syncTierAfterChange = syncTierAfterChange; -// ========================================================= -// RECALCULATE ALL TIERS — sync tier_id cho toàn bộ user -// dựa trên tier_points hiện tại -// Dùng khi: admin chạy thủ công, sau khi có bug fix -// ========================================================= +// ─── Recalculate all tiers ───────────────── +// Sync tier_id cho toàn bộ user dựa trên tier_points hiện tại exports.recalculateAllTiers = async () => { const client = await db.connect(); try { @@ -690,9 +627,7 @@ exports.recalculateAllTiers = async () => { } }; -// ========================================================= -// TEST HELPERS — TẠO BOOKING GIẢ ĐỂ TEST -// ========================================================= +// ─── Test helpers: Tạo booking giả để test ── exports.createFakeBooking = async (userId, totalPrice) => { const bookingId = Math.floor(Date.now() / 1000); const bookingCode = String(bookingId).slice(-8); diff --git a/src/services/notification.service.js b/src/services/notification.service.js index 8326132..0a6e4ed 100644 --- a/src/services/notification.service.js +++ b/src/services/notification.service.js @@ -1,15 +1,15 @@ 'use strict'; /* -========================================================= +============================================================ NOTIFICATION SERVICE - Email Notifications -========================================================= +============================================================ -Gửi email khi có sự kiện liên quan đến: -- Refunds -- Date Changes +Gửi email khi có sự kiện: +- Refunds (yêu cầu, duyệt, từ chối, hoàn thành) +- Date Changes (yêu cầu, duyệt, từ chối) - Airline Cancellations -========================================================= +============================================================ */ const { NOTIFICATIONS } = require('../config/refund.config'); @@ -18,9 +18,7 @@ const { NOTIFICATIONS } = require('../config/refund.config'); const FROM_EMAIL = NOTIFICATIONS.email.from || 'no-reply@n4minhlong.io.vn'; const FROM_NAME = NOTIFICATIONS.email.fromName || 'Airline Booking System'; -// ========================================================= -// EMAIL TEMPLATES -// ========================================================= +// Email templates const EMAIL_TEMPLATES = { // Refund Templates @@ -70,9 +68,7 @@ const EMAIL_TEMPLATES = { }, }; -// ========================================================= -// EMAIL CONTENT GENERATORS -// ========================================================= +// Tạo nội dung email cho refund const generateRefundEmailContent = (event, data) => { const { refund, booking, adminId } = data; @@ -282,9 +278,7 @@ const generateFlightCancellationEmailContent = (data) => { }; }; -// ========================================================= -// SEND EMAIL (MOCK - Cần implement với provider thực tế) -// ========================================================= +// Gửi email (mock - cần implement với provider thực tế) const sendEmail = async (to, subject, body, options = {}) => { // TODO: Implement với email provider thực tế @@ -308,9 +302,7 @@ const sendEmail = async (to, subject, body, options = {}) => { return true; }; -// ========================================================= -// NOTIFICATION FUNCTIONS -// ========================================================= +// Các function gửi notification const createRefundNotification = async (data) => { const { event, refund, booking, userId, guestEmail } = data; @@ -398,9 +390,7 @@ const createFlightCancellationNotification = async (data) => { } }; -// ========================================================= -// ADMIN NOTIFICATIONS -// ========================================================= +// Admin notifications const notifyAdminNewRefund = async (refund) => { if (!NOTIFICATIONS.admin.alertOnNewRefund) return; @@ -418,9 +408,7 @@ const checkAndAlertSLABreach = async () => { console.log('[Admin Notification] Checking SLA breaches...'); }; -// ========================================================= -// UTILITIES -// ========================================================= +// Utilities const formatCurrency = (amount) => { return new Intl.NumberFormat('vi-VN', { diff --git a/src/services/payment.service.js b/src/services/payment.service.js index 8c08b6f..3cdb2c6 100644 --- a/src/services/payment.service.js +++ b/src/services/payment.service.js @@ -1,7 +1,22 @@ -/** - * Payment Service - Tích hợp đa cổng thanh toán - * Hỗ trợ: BANK_QR (VietQR), PayOS, MoMo, PayPal - */ +/* +============================================================ +PAYMENT SERVICE - Thanh toán đa cổng +============================================================ + +Hỗ trợ thanh toán qua: +- BANK_QR (VietQR / PayOS) +- MoMo +- PayPal + +Các chức năng chính: +- Preview: Xem trước số tiền cần thanh toán +- Create: Tạo payment record +- Init: Tạo + khởi tạo thanh toán với gateway +- Confirm: Xác nhận thanh toán thành công +- Cancel: Hủy payment +- Webhooks: Xử lý callback từ các cổng thanh toán +============================================================ +*/ const pool = require("../config/db"); const QP = require("../queries/payment.queries"); @@ -46,7 +61,7 @@ const generatePaymentCode = () => { const toNumber = (value) => Number(value || 0); -// ── Helpers ─────────────────────────────────────────────────────────────────── +// Helpers để format, validate payment const isTerminalPaidStatus = (status) => ['PAID', 'SUCCESS', 'COMPLETED', 'CONFIRMED'].includes(String(status || '').toUpperCase()); @@ -237,7 +252,7 @@ const rollbackReservedVoucherUsageForBooking = async (client, bookingId) => { await client.query(QC.ROLLBACK_RESERVED_COUPON_USAGE, [bookingId]); }; -// ── Preview Payment ─────────────────────────────────────────────────────────── +// Preview thanh toán - xem trước số tiền, áp dụng voucher nếu có const previewPayment = async (data, userId = null) => { const bookingCode = String(data.booking_code || "").trim().toUpperCase(); @@ -270,7 +285,7 @@ const previewPayment = async (data, userId = null) => { } }; -// ── Create Payment ──────────────────────────────────────────────────────────── +// Tạo payment record mới trong DB const createPayment = async (data, userId = null) => { const bookingCode = String(data.booking_code || "").trim().toUpperCase(); @@ -329,7 +344,8 @@ const createPayment = async (data, userId = null) => { } }; -// ── Init Payment (với gateway integration) ───────────────────────────────────── +// Khởi tạo thanh toán với gateway (PayOS, MoMo, PayPal) +// Tạo payment record + lấy payment URL/QR từ gateway const initPayment = async ({ booking_code, payment_method, voucher_code, userId }) => { // Tạo payment record @@ -459,7 +475,9 @@ const initPayment = async ({ booking_code, payment_method, voucher_code, userId return mapPayment(paymentForEmail, providerPayload); }; -// ── Confirm Payment ──────────────────────────────────────────────────────────── +// Xác nhận thanh toán thành công +// Update booking status = 'confirmed', payment status = 'SUCCESS' +// Gửi email xác nhận (async) const confirmPayment = async (paymentCode, userId = null, bypassAuth = false) => { const normalizedPaymentCode = String(paymentCode || "").trim().toUpperCase(); @@ -572,7 +590,7 @@ const confirmPayment = async (paymentCode, userId = null, bypassAuth = false) => } }; -// ── Cancel Payment ───────────────────────────────────────────────────────────── +// Hủy payment (chưa thanh toán hoặc hết hạn) const cancelPayment = async ({ payment_code }) => { const payment = await getPaymentByCodeRow(payment_code); @@ -593,7 +611,7 @@ const cancelPayment = async ({ payment_code }) => { return mapPayment(rows[0]); }; -// ── Get Payment by Code ──────────────────────────────────────────────────────── +// Lấy thông tin payment theo code const getPaymentByCode = async (paymentCode) => { const payment = await getPaymentByCodeRow(paymentCode); @@ -601,7 +619,7 @@ const getPaymentByCode = async (paymentCode) => { return mapPayment(payment); }; -// ── Webhook Handlers ────────────────────────────────────────────────────────── +// Xử lý webhooks từ các cổng thanh toán const handlePayosWebhook = async (payload = {}) => { const webhookData = await verifyPayosWebhookData(payload); @@ -793,7 +811,7 @@ const handlePaypalCancel = async (query = {}) => { }; }; -// ── Checkout URL Getters ──────────────────────────────────────────────────────── +// Lấy checkout URL để redirect user đến trang thanh toán const getPayosCheckoutUrl = async (paymentCode) => { const payment = await getPaymentByCodeRow(paymentCode); @@ -871,7 +889,7 @@ const getPaypalCheckoutUrl = async (paymentCode) => { return checkout.approve_url; }; -// ── PayOS Return Handler ─────────────────────────────────────────────────────── +// Xử lý khi user return từ PayOS const handlePayosReturn = async (returnStatus = 'success', query = {}) => { const paymentCode = String(query.payment_code || query.paymentCode || '').trim(); diff --git a/src/services/price-alert.service.js b/src/services/price-alert.service.js new file mode 100644 index 0000000..d47fe93 --- /dev/null +++ b/src/services/price-alert.service.js @@ -0,0 +1,302 @@ +'use strict'; + +const seasonService = require('./season.service'); +const { + getDayOfWeekMult, + getAdvanceMult, + getDemandMult, + applyDynamicPricing, +} = require('../utils/pricing'); + +/* +============================================================ +PRICE ALERT SERVICE - Engine phân tích & generate alert +============================================================ + +Chỉ generate alert khi giá TĂNG so với base price + +Alert Levels: +- high: > 20% increase +- medium: 10-20% increase +- low: 5-10% increase +- none: ≤ 5% change +============================================================ +*/ + +// Threshold để trigger alert (% change) +const ALERT_THRESHOLD_PERCENT = 5; + +const buildDynamicPrice = async (basePrice, availableSeats, totalSeats, departureTime) => { + const seasonMultiplier = await seasonService.getSeasonMultiplier(departureTime); + return applyDynamicPricing(basePrice, availableSeats, totalSeats, departureTime, seasonMultiplier); +}; + +/** + * Tính price breakdown chi tiết + */ +async function calculatePriceBreakdown(basePrice, availableSeats, totalSeats, departureTime) { + const dayOfWeekMult = getDayOfWeekMult(departureTime); + const advanceMult = getAdvanceMult(departureTime); + const demandMult = getDemandMult(availableSeats, totalSeats); + const seasonMult = await seasonService.getSeasonMultiplier(departureTime); + const calculatedPrice = applyDynamicPricing(basePrice, availableSeats, totalSeats, departureTime, seasonMult); + + return { + basePrice, + dayOfWeekMult, + advanceMult, + demandMult, + seasonMult, + totalMultiplier: dayOfWeekMult * advanceMult * demandMult * seasonMult, + calculatedPrice, + }; +} + +// Xác định level dựa trên % change +function getAlertLevel(percentChange) { + if (percentChange > 20) return 'high'; + if (percentChange > 10) return 'medium'; + if (percentChange > ALERT_THRESHOLD_PERCENT) return 'low'; + return 'none'; +} + +// Tạo message dựa trên level và season +function generateAlertMessage(level, percentChange, seasonInfo, pricingBreakdown) { + const roundedPercent = Math.round(percentChange); + + // Base message + let message = `Giá tăng ${roundedPercent}%`; + + if (seasonInfo) { + message += ` do đang ${seasonInfo.reason}`; + } else if (pricingBreakdown.demandMult > 1.20) { + message += ` do nhu cầu cao`; + } else if (pricingBreakdown.advanceMult > 1.20) { + message += ` do đặt gần ngày`; + } + + return message; +} + +// Tạo recommendation dựa trên level +function generateRecommendation(level, seasonInfo) { + const recommendations = { + high: 'Nên đặt ngay, giá có thể tăng thêm trong 24h!', + medium: 'Nên đặt sớm, giá có khả năng tăng trong 24h tới', + low: 'Giá có xu hướng tăng nhẹ, có thể đặt sớm', + none: null + }; + + // Custom message cho holiday + if (seasonInfo?.isHoliday && level !== 'none') { + return `Hôm nay là ngày lễ ${seasonInfo.name}. Nên đặt ngay!`; + } + + return recommendations[level] || null; +} + +/*============================================================ +MAIN FUNCTION: Generate price alert cho 1 flight +============================================================ + +Ví dụ: + const alert = await generatePriceAlert(flight, '2026-06-15'); + // Returns: { type: 'PRICE_INCREASE', level: 'high', percentage: 30, message: '...' } +============================================================*/ +async function generatePriceAlert(flight, departureDate = null, currentDate = null) { + try { + // 1. Xác định departure date + const depDate = departureDate || flight.departure_time || flight.departure_date; + + if (!depDate) { + console.warn('[PriceAlertService] No departure date provided'); + return null; + } + + // 2. Tính current price sử dụng pricing utils hiện tại + const availableSeats = flight.available_seats ?? flight.seats_available ?? 0; + const totalSeats = flight.total_seats ?? flight.seats_total ?? 1; + const basePrice = flight.base_price || flight.price || 0; + + if (basePrice <= 0) { + return null; + } + + // 3. Tính price breakdown chi tiết + const breakdown = await calculatePriceBreakdown(basePrice, availableSeats, totalSeats, depDate); + + // 4. Tính % change so với base price + const currentPrice = breakdown.calculatedPrice; + const percentChange = ((currentPrice - basePrice) / basePrice) * 100; + + // 5. Lấy season info + const seasonInfo = await seasonService.getSeasonInfo(depDate); + + // 6. CHỈ alert khi giá TĂNG > threshold + if (percentChange <= ALERT_THRESHOLD_PERCENT) { + return null; // Giá ổn định hoặc giảm → không alert + } + + // 7. Xác định level + const level = getAlertLevel(percentChange); + + // 8. Generate message và recommendation + const message = generateAlertMessage(level, percentChange, seasonInfo, breakdown); + const recommendation = generateRecommendation(level, seasonInfo); + + // 9. Build alert object + const alert = { + type: 'PRICE_INCREASE', + level, + percentage: Math.round(percentChange), + basePrice, + currentPrice, + basePriceFormatted: formatCurrency(basePrice), + currentPriceFormatted: formatCurrency(currentPrice), + message, + recommendation, + seasonMultiplier: seasonInfo?.multiplier || 1.0, + seasonName: seasonInfo?.name || null, + seasonReason: seasonInfo?.reason || null, + pricingFactors: { + dayOfWeek: breakdown.dayOfWeekMult, + advanceBooking: breakdown.advanceMult, + demand: breakdown.demandMult, + season: breakdown.seasonMult, + totalMultiplier: parseFloat(breakdown.totalMultiplier.toFixed(2)) + }, + generatedAt: new Date().toISOString() + }; + + return alert; + + } catch (error) { + console.error('[PriceAlertService] Error generating price alert:', error); + return null; + } +} + +/*============================================================ +BATCH FUNCTION: Generate alerts cho nhiều flights +============================================================ +Dùng trong search results +============================================================*/ +async function generatePriceAlertsForFlights(flights, currentDate = null) { + if (!flights || flights.length === 0) { + return []; + } + + // Promise.all để query season info cho tất cả flights + const alerts = await Promise.all( + flights.map(flight => generatePriceAlert(flight, null, currentDate)) + ); + + // Merge alerts vào flights + return flights.map((flight, index) => ({ + ...flight, + price_alert: alerts[index] // null nếu không có alert + })); +} + +/*============================================================ +ANALYSIS FUNCTION: Chi tiết phân tích giá +Dùng cho detail page +@param {object} flight - Flight object +@returns {Promise} +============================================================*/ +async function getDetailedAnalysis(flight) { + const departureDate = flight.departure_time || flight.departure_date; + + // Get all data + const [seasonInfo, priceAlert] = await Promise.all([ + seasonService.getSeasonInfo(departureDate), + generatePriceAlert(flight) + ]); + + // Calculate base breakdown + const availableSeats = flight.available_seats ?? flight.seats_available ?? 0; + const totalSeats = flight.total_seats ?? flight.seats_total ?? 1; + const basePrice = flight.base_price || flight.price || 0; + + const breakdown = await calculatePriceBreakdown(basePrice, availableSeats, totalSeats, departureDate); + + return { + flightId: flight.id, + departureDate, + basePrice, + currentPrice: breakdown.calculatedPrice, + percentChange: priceAlert?.percentage || 0, + + season: seasonInfo ? { + name: seasonInfo.name, + multiplier: seasonInfo.multiplier, + reason: seasonInfo.reason, + isHoliday: seasonInfo.isHoliday || false, + isPeak: seasonInfo.isPeak || false + } : null, + + pricingBreakdown: { + basePrice, + dayOfWeek: { + multiplier: breakdown.dayOfWeekMult, + label: getDayOfWeekLabel(departureDate) + }, + advanceBooking: { + multiplier: breakdown.advanceMult, + daysUntilDeparture: getDaysUntilDeparture(departureDate) + }, + demand: { + multiplier: breakdown.demandMult, + occupancyRate: totalSeats > 0 ? ((totalSeats - availableSeats) / totalSeats * 100).toFixed(0) + '%' : 'N/A' + }, + finalMultiplier: parseFloat(breakdown.totalMultiplier.toFixed(2)) + }, + + alert: priceAlert, + + recommendation: priceAlert ? { + action: priceAlert.level === 'high' ? 'BUY_NOW' : 'BOOK_SOON', + urgency: priceAlert.level, + message: priceAlert.recommendation + } : null + }; +} + +// Helpers + +function formatCurrency(amount) { + if (!amount) return '0 VND'; + return new Intl.NumberFormat('vi-VN', { + style: 'currency', + currency: 'VND', + maximumFractionDigits: 0 + }).format(amount); +} + +function getDayOfWeekLabel(date) { + const days = ['Chủ Nhật', 'Thứ Hai', 'Thứ Ba', 'Thứ Tư', 'Thứ Năm', 'Thứ Sáu', 'Thứ Bảy']; + return days[new Date(date).getDay()]; +} + +function getDaysUntilDeparture(date) { + const now = new Date(); + now.setHours(0, 0, 0, 0); + const dep = new Date(date); + dep.setHours(0, 0, 0, 0); + return Math.max(0, Math.round((dep - now) / (1000 * 60 * 60 * 24))); +} + +module.exports = { + // Main functions + generatePriceAlert, + generatePriceAlertsForFlights, + getDetailedAnalysis, + + // Helpers + calculatePriceBreakdown, + getAlertLevel, + formatCurrency, + + // Constants + ALERT_THRESHOLD_PERCENT +}; diff --git a/src/services/refund.service.js b/src/services/refund.service.js index 3832413..e441178 100644 --- a/src/services/refund.service.js +++ b/src/services/refund.service.js @@ -1,9 +1,31 @@ 'use strict'; /* -========================================================= -REFUND SERVICE - Business Logic -========================================================= +============================================================ +REFUND SERVICE - Hoàn tiền booking +============================================================ + +Các chức năng chính: +- Request: User yêu cầu hoàn tiền +- Approve: Admin duyệt yêu cầu +- Process: Gọi payment gateway để hoàn tiền +- Reject: Admin từ chối yêu cầu + +OTP System: +- Yêu cầu xác thực OTP cho hóa đơn >= threshold (mặc định 1M VND) +- OTP được gửi qua email +- Lưu trong memory (không persist) + +Auto-Approve: +- Refund < 1M VND → auto approve +- Refund >= 1M VND → cần admin duyệt + +Refund Policies: +- > 72h trước giờ bay: hoàn 100% +- 24-72h: hoàn 80% +- 12-24h: hoàn 50% +- < 12h: không hoàn +============================================================ */ const pool = require('../config/db'); @@ -29,9 +51,7 @@ const { refundPayPalCapture } = require('../providers/paypal.provider'); const { getPayosClient } = require('../providers/payos.provider'); const config = require('../config/payment.config'); -// ========================================================= -// HELPERS -// ========================================================= +// Helpers const generateRefundCode = () => { const date = new Date().toISOString().slice(0, 10).replace(/-/g, ''); @@ -85,9 +105,7 @@ const validateRefundRequest = (booking, payment, userId) => { } }; -// ========================================================= -// OTP STORE (In-Memory) -// ========================================================= +// OTP Store - lưu trong memory cho guest refunds /** * In-memory OTP store for guest refunds @@ -399,9 +417,7 @@ const calculateRefundAmount = (booking, payment, policy, refundType = 'full', re }; }; -// ========================================================= -// REQUEST REFUND (USER) -// ========================================================= +// ─── USER REQUEST REFUND ──────────────────────────────────────── const requestRefund = async (userId, bookingCode, data) => { const { @@ -612,9 +628,7 @@ const getUserRefunds = async (userId, page = 1, limit = 10) => { }; }; -// ========================================================= -// APPROVE REFUND (ADMIN) -// ========================================================= +// ─── ADMIN APPROVE REFUND ─────────────────────────────────────── const approveRefund = async (adminId, refundCode, adminNotes = null) => { const client = await pool.connect(); @@ -667,9 +681,7 @@ const approveRefund = async (adminId, refundCode, adminNotes = null) => { } }; -// ========================================================= -// REJECT REFUND (ADMIN) -// ========================================================= +// ─── ADMIN REJECT REFUND ─────────────────────────────────────── const rejectRefund = async (adminId, refundCode, reason) => { if (!reason || reason.trim().length < 10) { @@ -729,9 +741,7 @@ const rejectRefund = async (adminId, refundCode, reason) => { } }; -// ========================================================= -// PROCESS REFUND (ADMIN) - Gọi payment gateway để hoàn tiền -// ========================================================= +// ─── ADMIN PROCESS REFUND (gọi payment gateway) ──────────────── const processRefund = async (adminId, refundCode) => { const client = await pool.connect(); diff --git a/src/services/season.service.js b/src/services/season.service.js new file mode 100644 index 0000000..12b6163 --- /dev/null +++ b/src/services/season.service.js @@ -0,0 +1,533 @@ +'use strict'; + +const pool = require('../config/db'); +const solarLunar = require('solarlunar'); + +/* +============================================================ +SEASON SERVICE - Mùa cao điểm & Ngày lễ +============================================================ + +Cung cấp các function để: +- Detect mùa cao điểm dựa trên departure date +- Check ngày lễ +- Lấy season multiplier cho pricing + +Chỉ return season info khi CÓ mùa cao điểm/ngày lễ + +Cache: 1 giờ để tránh query DB quá nhiều +============================================================ +*/ + +const CACHE_TTL_MS = 60 * 60 * 1000; + +// Cache để tránh query DB quá nhiều (invalid sau 1 giờ) +let seasonCache = null; +let seasonCacheTime = 0; +let holidayCache = null; +let holidayCacheTime = 0; +let holidayRuleCache = null; +let holidayRuleCacheTime = 0; +const overrideCache = new Map(); +const OVERRIDE_CACHE_TTL_MS = 60 * 60 * 1000; + +const getDateParts = (date) => { + const d = new Date(date); + return { + year: d.getFullYear(), + month: d.getMonth() + 1, + day: d.getDate(), + }; +}; + +const normalizeDate = (date) => { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; +}; + +const toDateKey = (date) => normalizeDate(date).toISOString().split('T')[0]; + +const compareMonthDay = (leftMonth, leftDay, rightMonth, rightDay) => { + if (leftMonth !== rightMonth) { + return leftMonth - rightMonth; + } + return leftDay - rightDay; +}; + +const createSeasonBoundaryDate = (year, month, day) => { + return new Date(year, month - 1, day || 1); +}; + +const createResolvedDateFromParts = (year, month, day) => new Date(year, month - 1, day); + +const parseBoolean = (value) => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + return ['true', '1', 'yes'].includes(value.trim().toLowerCase()); + } + return Boolean(value); +}; + +const resolveSolarHolidayDate = (rule, year) => { + return createResolvedDateFromParts(year, Number(rule.anchor_month), Number(rule.anchor_day)); +}; + +const resolveLunarHolidayDate = (rule, year) => { + const solar = solarLunar.lunar2solar( + Number(year), + Number(rule.anchor_month), + Number(rule.anchor_day), + parseBoolean(rule.anchor_is_leap_month) + ); + + return createResolvedDateFromParts(solar.cYear, solar.cMonth, solar.cDay); +}; + +const resolveRuleAnchorDate = (rule, year) => { + if (rule.calendar_type === 'lunar') { + return resolveLunarHolidayDate(rule, year); + } + return resolveSolarHolidayDate(rule, year); +}; + +const resolveHolidayRuleForYear = (rule, year) => { + try { + const anchorDate = resolveRuleAnchorDate(rule, year); + const offsetDays = Number(rule.offset_days || 0); + const resolvedDate = new Date(anchorDate); + resolvedDate.setDate(resolvedDate.getDate() + offsetDays); + + return { + ...rule, + resolved_year: year, + resolved_date: resolvedDate, + resolved_date_key: toDateKey(resolvedDate), + resolved_anchor_date: anchorDate, + multiplier: parseFloat(rule.multiplier), + priority: Number(rule.priority || 0), + is_resolved_from_rule: true, + }; + } catch (error) { + console.error(`[SeasonService] Failed to resolve holiday rule ${rule.name} for year ${year}:`, error.message); + return null; + } +}; + +const buildResolvedHolidayRuleMap = (rules, years) => { + const ruleMap = new Map(); + + for (const rule of rules) { + for (const year of years) { + const resolvedRule = resolveHolidayRuleForYear(rule, year); + if (!resolvedRule) continue; + + const existing = ruleMap.get(resolvedRule.resolved_date_key); + if (!existing) { + ruleMap.set(resolvedRule.resolved_date_key, resolvedRule); + continue; + } + + if (resolvedRule.priority > existing.priority || ( + resolvedRule.priority === existing.priority && resolvedRule.multiplier > existing.multiplier + )) { + ruleMap.set(resolvedRule.resolved_date_key, resolvedRule); + } + } + } + + return ruleMap; +}; + +const buildHolidayInfoFromRule = (rule, departureDate) => ({ + id: rule.id, + rule_id: rule.id, + name: rule.name, + multiplier: parseFloat(rule.multiplier), + reason: rule.reason, + priority: Number(rule.priority || 0), + calendar_type: rule.calendar_type, + rule_type: rule.rule_type, + offset_days: Number(rule.offset_days || 0), + group_key: rule.group_key, + date: rule.resolved_date, + year: rule.resolved_year, + is_resolved_from_rule: true, + isHoliday: true, + type: 'holiday', + daysUntil: daysUntil(departureDate), +}); + +const getSeasonWindowForReferenceYear = (season, referenceYear) => { + const start = createSeasonBoundaryDate(referenceYear, season.start_month, season.start_day); + const crossesYear = season.start_month > season.end_month || ( + season.start_month === season.end_month && season.start_day > season.end_day + ); + const endYear = crossesYear ? referenceYear + 1 : referenceYear; + const end = createSeasonBoundaryDate(endYear, season.end_month, season.end_day || 28); + + return { + start, + end, + crossesYear, + }; +}; + +const getSeasonWindowsForDate = (season, date) => { + const { year } = getDateParts(date); + return [ + getSeasonWindowForReferenceYear(season, year - 1), + getSeasonWindowForReferenceYear(season, year), + ]; +}; + +const getUpcomingSeasonStart = (season, fromDate) => { + const now = normalizeDate(fromDate); + + return [ + getSeasonWindowForReferenceYear(season, now.getFullYear() - 1), + getSeasonWindowForReferenceYear(season, now.getFullYear()), + getSeasonWindowForReferenceYear(season, now.getFullYear() + 1), + ] + .map(window => window.start) + .filter(start => start >= now) + .sort((a, b) => a - b)[0] || null; +}; + +// Lấy tất cả season periods đang active (có cache) +async function getActiveSeasons() { + const now = Date.now(); + + if (seasonCache && (now - seasonCacheTime) < CACHE_TTL_MS) { + return seasonCache; + } + + const result = await pool.query(` + SELECT * FROM season_periods + WHERE is_active = true + ORDER BY priority DESC + `); + + seasonCache = result.rows; + seasonCacheTime = now; + + return seasonCache; +} + +// Lấy tất cả holidays đang active (có cache) +async function getActiveHolidays() { + const now = Date.now(); + + if (holidayCache && (now - holidayCacheTime) < CACHE_TTL_MS) { + return holidayCache; + } + + const result = await pool.query(` + SELECT * FROM holidays + WHERE is_active = true + ORDER BY date ASC + `); + + holidayCache = result.rows; + holidayCacheTime = now; + + return holidayCache; +} + +async function getActiveHolidayRules() { + const now = Date.now(); + + if (holidayRuleCache && (now - holidayRuleCacheTime) < CACHE_TTL_MS) { + return holidayRuleCache; + } + + const result = await pool.query(` + SELECT * FROM holiday_rules + WHERE is_active = true + ORDER BY priority DESC, name ASC + `); + + holidayRuleCache = result.rows; + holidayRuleCacheTime = now; + + return holidayRuleCache; +} + +async function getResolvedHolidayRuleMap(targetDate) { + const rules = await getActiveHolidayRules(); + const { year } = getDateParts(targetDate); + return buildResolvedHolidayRuleMap(rules, [year - 1, year, year + 1]); +} + +// Kiểm tra xem một date có nằm trong season period không +function isDateInSeason(date, season) { + const target = normalizeDate(date); + + return getSeasonWindowsForDate(season, target).some(({ start, end }) => { + const normalizedStart = normalizeDate(start); + const normalizedEnd = normalizeDate(end); + return target >= normalizedStart && target <= normalizedEnd; + }); +} + +// Kiểm tra xem một date có phải là ngày lễ không +function isHoliday(date, holidays) { + const d = new Date(date); + const month = d.getMonth() + 1; + const day = d.getDate(); + const year = d.getFullYear(); + + for (const holiday of holidays) { + const holidayDate = new Date(holiday.date); + const holidayMonth = holidayDate.getMonth() + 1; + const holidayDay = holidayDate.getDate(); + + if (holiday.year) { + if (month === holidayMonth && day === holidayDay && year === holiday.year) { + return holiday; + } + } else if (month === holidayMonth && day === holidayDay) { + return holiday; + } + } + + return null; +} + +function getHolidayFromRule(date, resolvedRuleMap) { + const targetKey = toDateKey(date); + const resolvedRule = resolvedRuleMap.get(targetKey); + if (!resolvedRule) return null; + return buildHolidayInfoFromRule(resolvedRule, date); +} + +// Tính số ngày từ date hiện tại đến target date +function daysUntil(targetDate) { + const now = normalizeDate(new Date()); + const target = normalizeDate(targetDate); + return Math.round((target - now) / (1000 * 60 * 60 * 24)); +} + +async function isApproachingPeakSeason(departureDate, thresholdDays = 30) { + const seasons = await getActiveSeasons(); + const departure = normalizeDate(departureDate); + + for (const season of seasons) { + if (isDateInSeason(departure, season)) { + return { + isApproaching: true, + isInside: true, + season, + daysUntilSeasonStart: 0, + reason: `đang trong ${season.name}`, + }; + } + + const upcomingStart = getUpcomingSeasonStart(season, new Date()); + if (!upcomingStart) continue; + + const daysUntilSeason = daysUntil(upcomingStart); + const departureDiff = Math.round((departure - upcomingStart) / (1000 * 60 * 60 * 24)); + + if (daysUntilSeason >= 0 && daysUntilSeason <= thresholdDays && departureDiff >= 0) { + return { + isApproaching: true, + isInside: false, + season, + daysUntilSeasonStart: daysUntilSeason, + reason: `sắp vào ${season.name}`, + }; + } + } + + return { + isApproaching: false, + isInside: false, + season: null, + daysUntilSeasonStart: null, + reason: null, + }; +} + +async function getOverrideForDate(date) { + const d = new Date(date); + const dateStr = d.toISOString().split('T')[0]; + const cached = overrideCache.get(dateStr); + const now = Date.now(); + + if (cached && (now - cached.cachedAt) < OVERRIDE_CACHE_TTL_MS) { + return cached.value; + } + + const result = await pool.query( + `SELECT * FROM price_overrides WHERE date = $1 AND is_active = true`, + [dateStr] + ); + + const override = result.rows[0] || null; + overrideCache.set(dateStr, { + value: override, + cachedAt: now, + }); + + return override; +} + +function clearOverrideCache() { + overrideCache.clear(); +} + +async function refreshCache() { + seasonCache = null; + seasonCacheTime = 0; + holidayCache = null; + holidayCacheTime = 0; + holidayRuleCache = null; + holidayRuleCacheTime = 0; + overrideCache.clear(); + return getSeasonInfo(new Date().toISOString()); +} + +async function getSeasonInfo(departureDate) { + try { + const [override, seasons, holidays, resolvedHolidayRules] = await Promise.all([ + getOverrideForDate(departureDate), + getActiveSeasons(), + getActiveHolidays(), + getResolvedHolidayRuleMap(departureDate), + ]); + + if (override) { + return { + isPeak: parseFloat(override.multiplier) >= 1.20, + isOverride: true, + isHoliday: false, + name: override.reason || 'Admin Override', + multiplier: parseFloat(override.multiplier), + reason: override.reason || 'Điều chỉnh giá thủ công', + type: 'override', + daysUntil: daysUntil(departureDate), + }; + } + + const ruleHoliday = getHolidayFromRule(departureDate, resolvedHolidayRules); + if (ruleHoliday) { + return { + isPeak: true, + isHoliday: true, + name: ruleHoliday.name, + multiplier: parseFloat(ruleHoliday.multiplier), + reason: ruleHoliday.reason, + type: 'holiday', + daysUntil: ruleHoliday.daysUntil, + calendar_type: ruleHoliday.calendar_type, + rule_type: ruleHoliday.rule_type, + group_key: ruleHoliday.group_key, + }; + } + + const holiday = isHoliday(departureDate, holidays); + if (holiday) { + return { + isPeak: true, + isHoliday: true, + name: holiday.name, + multiplier: parseFloat(holiday.multiplier), + reason: holiday.reason, + type: 'holiday', + daysUntil: daysUntil(departureDate), + }; + } + + let matchedSeason = null; + let matchedMultiplier = 0; + + for (const season of seasons) { + if (!isDateInSeason(departureDate, season)) continue; + + const multiplier = parseFloat(season.multiplier); + if (!matchedSeason) { + matchedSeason = season; + matchedMultiplier = multiplier; + continue; + } + + const currentPriority = Number(season.priority || 0); + const matchedPriority = Number(matchedSeason.priority || 0); + if (currentPriority > matchedPriority || (currentPriority === matchedPriority && multiplier > matchedMultiplier)) { + matchedSeason = season; + matchedMultiplier = multiplier; + } + } + + if (matchedSeason) { + const approaching = await isApproachingPeakSeason(departureDate); + + return { + isPeak: matchedMultiplier >= 1.20, + isApproaching: approaching.isApproaching && !approaching.isInside, + isInside: approaching.isInside, + name: matchedSeason.name, + multiplier: matchedMultiplier, + reason: matchedSeason.reason, + type: 'season', + daysUntil: daysUntil(departureDate), + approachingInfo: approaching, + }; + } + + return null; + } catch (error) { + console.error('[SeasonService] Error getting season info:', error); + return null; + } +} + +async function getSeasonMultiplier(departureDate) { + const info = await getSeasonInfo(departureDate); + return info ? info.multiplier : 1.0; +} + +async function shouldAlert(departureDate) { + const info = await getSeasonInfo(departureDate); + + if (!info) return false; + + return info.isHoliday || + info.isPeak || + (info.isApproaching && info.multiplier >= 1.15); +} + +async function getAllSeasonsAndHolidays() { + const [seasons, holidays, holidayRules] = await Promise.all([ + pool.query('SELECT * FROM season_periods ORDER BY priority DESC, name ASC'), + pool.query('SELECT * FROM holidays ORDER BY date ASC'), + pool.query('SELECT * FROM holiday_rules ORDER BY priority DESC, name ASC'), + ]); + + return { + seasons: seasons.rows, + holidays: holidays.rows, + holidayRules: holidayRules.rows, + }; +} + +module.exports = { + getSeasonInfo, + getSeasonMultiplier, + shouldAlert, + isApproachingPeakSeason, + refreshCache, + clearOverrideCache, + getActiveSeasons, + getActiveHolidays, + getActiveHolidayRules, + getAllSeasonsAndHolidays, + getOverrideForDate, + isDateInSeason, + isHoliday, + getHolidayFromRule, + resolveHolidayRuleForYear, + buildResolvedHolidayRuleMap, + daysUntil, +}; diff --git a/src/services/seat.service.js b/src/services/seat.service.js index 402495b..7e387eb 100644 --- a/src/services/seat.service.js +++ b/src/services/seat.service.js @@ -1,19 +1,20 @@ 'use strict'; /* -========================================================= -SEAT SERVICE - Xu ly chon ghe va seat selection -========================================================= -Hai luong: -1. Random Seat (free) - He thong tu dong assign -2. Choose Seat (tra phi) - Khach chon ghe cu the +============================================================ +SEAT SERVICE - Chọn ghế và seat selection +============================================================ -Chi tiet: -- First class: Luon duoc chon ghe (khong can tra phi) -- Business/Economy: - - Khong chon: Random (free) - - Chon ghe: Tra phi them -========================================================= +Hai luồng: +1. Random Seat (free) - Hệ thống tự động assign +2. Choose Seat (trả phí) - Khách chọn ghế cụ thể + +Chi tiết: +- First class: Luôn được chọn ghế (không cần trả phí) +- Business/Economy: + - Không chọn: Random (free) + - Chọn ghế: Trả phí thêm +============================================================ */ // db = pool directly @@ -21,13 +22,9 @@ const db = require('../config/db'); const SQ = require('../queries/seat.queries'); const QB = require('../queries/booking.queries'); -// ========================================================= -// HELPER FUNCTIONS -// ========================================================= +// Helpers -/** - * Lay vi tri ghe: window (A, F) hoac standard - */ +// Lấy vị trí ghế: window (A, F) hoặc standard const getSeatPosition = (seatNumber) => { if (seatNumber.endsWith('A') || seatNumber.endsWith('F')) { return 'window'; @@ -35,9 +32,7 @@ const getSeatPosition = (seatNumber) => { return 'standard'; }; -/** - * Tinh phi them cho ghe da chon - */ +// Tính phí thêm cho ghế đã chọn const calculateExtraFee = async (flightId, seatClass, position) => { const pool = db; const result = await pool.query(SQ.SELECT_SEAT_PRICING_BY_CLASS, [flightId, seatClass]); @@ -46,9 +41,8 @@ const calculateExtraFee = async (flightId, seatClass, position) => { return pricing ? parseFloat(pricing.extra_price) : 0; }; -/** - * Get random available seat - */ +// Lấy ghế ngẫu nhiên còn trống +// Ưu tiên ghế standard, không phải window const getRandomAvailableSeat = async (flightId, seatClass) => { const pool = db; @@ -71,15 +65,9 @@ const getRandomAvailableSeat = async (flightId, seatClass) => { return seatPool[randomIndex]; }; -// ========================================================= -// SEAT MAP FUNCTIONS -// ========================================================= +// ─── Seat Map Functions ────────────────────────── -/** - * Lay seat map cua 1 chuyen bay - * @param {number} flightId - ID cua chuyen bay - * @param {string} seatClass - Loai ghe (economy, business, first) - */ +// Lấy seat map của 1 chuyến bay const getSeatMap = async (flightId, seatClass) => { const pool = db; @@ -117,9 +105,7 @@ const getSeatMap = async (flightId, seatClass) => { }; }; -/** - * Lay tat ca seat classes cua 1 chuyen bay - */ +// Lấy seat maps cho tất cả classes const getAllSeatMaps = async (flightId) => { const classes = ['economy', 'business', 'first']; const results = {}; @@ -131,13 +117,9 @@ const getAllSeatMaps = async (flightId) => { return results; }; -// ========================================================= -// SEAT SELECTION FUNCTIONS -// ========================================================= +// ─── Seat Selection Functions ───────────────────── -/** - * Validate seat selection - */ +// Validate ghế trước khi assign const validateSeatSelection = async (flightId, seatClass, seatNumber) => { const pool = db; @@ -167,9 +149,7 @@ const validateSeatSelection = async (flightId, seatClass, seatNumber) => { return true; }; -/** - * Assign seat cho 1 passenger - */ +// Assign ghế cho 1 passenger const assignSeat = async (flightId, seatClass, seatNumber, passengerId, bookingId) => { const pool = db; const client = await pool.connect(); @@ -204,11 +184,7 @@ const assignSeat = async (flightId, seatClass, seatNumber, passengerId, bookingI } }; -/** - * Select multiple seats cho 1 booking - * @param {string} bookingCode - Ma booking - * @param {Array} selections - [{ passenger_id, flight_type, seat_number }] - */ +// Chọn nhiều ghế cho 1 booking const selectSeats = async (bookingCode, selections) => { const pool = db; const client = await pool.connect(); @@ -289,9 +265,7 @@ const selectSeats = async (bookingCode, selections) => { } }; -/** - * Auto-assign random seats cho tat ca passengers cua 1 booking - */ +// Tự động assign ghế ngẫu nhiên cho tất cả passengers const autoAssignSeats = async (bookingCode) => { const pool = db; const client = await pool.connect(); @@ -393,9 +367,7 @@ const autoAssignSeats = async (bookingCode) => { } }; -/** - * Release a seat (when cancelling booking) - */ +// Giải phóng ghế (khi hủy booking) const releaseSeat = async (flightId, seatNumber) => { const pool = db; diff --git a/src/services/wishlist.service.js b/src/services/wishlist.service.js index d050cd1..98add94 100644 --- a/src/services/wishlist.service.js +++ b/src/services/wishlist.service.js @@ -28,9 +28,7 @@ const formatItem = (row) => ({ }, }); -/** - * CU-02: Thêm chuyến bay vào wishlist (chỉ user đã login) - */ +// Thêm chuyến bay vào wishlist (chỉ user đã login) const addToWishlist = async (userId, flightId, seatClass = "economy") => { const flightIdInt = parseInt(flightId, 10); const normalizedClass = String(seatClass).toLowerCase(); @@ -54,9 +52,7 @@ const addToWishlist = async (userId, flightId, seatClass = "economy") => { return { message: "Đã thêm vào danh sách yêu thích", item: result.rows[0] }; }; -/** - * Xóa chuyến bay khỏi wishlist (chỉ user đã login) - */ +// Xóa chuyến bay khỏi wishlist const removeFromWishlist = async (userId, flightId, seatClass = "economy") => { const flightIdInt = parseInt(flightId, 10); const normalizedClass = String(seatClass).toLowerCase(); @@ -67,11 +63,8 @@ const removeFromWishlist = async (userId, flightId, seatClass = "economy") => { return { message: "Đã xóa khỏi danh sách yêu thích" }; }; -/** - * CU-04: Xem wishlist (chỉ user đã login) - * - Kiểm tra chuyến bay còn vé / hết vé - * - Hiển thị giá hiện tại so với giá lúc lưu - */ +// Xem wishlist của user +// Hiển thị giá hiện tại so với giá lúc lưu const getWishlist = async (userId) => { const result = await pool.query(Q.SELECT_WISHLIST_BY_USER, [userId]); @@ -81,11 +74,8 @@ const getWishlist = async (userId) => { }; }; -/** - * CU-03: Sync wishlist từ localStorage (guest) → server sau khi đăng nhập - * Frontend gửi lên mảng items từ localStorage - * Backend merge vào database, loại bỏ trùng lặp - */ +// Sync wishlist từ localStorage (guest) → server sau khi đăng nhập +// Frontend gửi mảng items từ localStorage, backend merge vào DB const syncWishlist = async (userId, localItems = []) => { if (!Array.isArray(localItems) || localItems.length === 0) { return { synced: 0, skipped: 0, message: "Không có dữ liệu để sync" }; diff --git a/src/utils/pricing.js b/src/utils/pricing.js index 1b2e916..f018e77 100644 --- a/src/utils/pricing.js +++ b/src/utils/pricing.js @@ -1,5 +1,7 @@ 'use strict'; +const seasonService = require('../services/season.service'); + // Day-of-week multiplier const getDayOfWeekMult = (depTime) => { const day = new Date(depTime).getDay(); @@ -36,9 +38,22 @@ const getDemandMult = (availableSeats, totalSeats) => { return 0.97; }; -const applyDynamicPricing = (basePrice, availableSeats, totalSeats, depTime) => { - const mult = getDayOfWeekMult(depTime) * getAdvanceMult(depTime) * getDemandMult(availableSeats, totalSeats); +const applyDynamicPricing = (basePrice, availableSeats, totalSeats, depTime, seasonMultiplier = 1.0) => { + const mult = getDayOfWeekMult(depTime) * getAdvanceMult(depTime) * getDemandMult(availableSeats, totalSeats) * seasonMultiplier; return Math.round(basePrice * mult / 1000) * 1000; }; -module.exports = { getDayOfWeekMult, getAdvanceMult, getDemandMult, applyDynamicPricing }; +// Async version that fetches season multiplier automatically +const applyDynamicPricingWithSeason = async (basePrice, availableSeats, totalSeats, depTime) => { + const seasonMultiplier = await seasonService.getSeasonMultiplier(depTime); + return applyDynamicPricing(basePrice, availableSeats, totalSeats, depTime, seasonMultiplier); +}; + +module.exports = { + getDayOfWeekMult, + getAdvanceMult, + getDemandMult, + applyDynamicPricing, + applyDynamicPricingWithSeason, + seasonService, +}; diff --git a/tests/unit/HuongDanChayTest_FlightTrackerAndMemberShip.md b/tests/unit/HuongDanChayTest_FlightTrackerAndMemberShip.md deleted file mode 100644 index 9228c41..0000000 --- a/tests/unit/HuongDanChayTest_FlightTrackerAndMemberShip.md +++ /dev/null @@ -1,154 +0,0 @@ -# Hướng Dẫn Chạy Unit Test — Flight Tracker & Membership - -## Tổng quan - -Bài lab này áp dụng đúng theo mô hình AAA (Arrange – Act – Assert) -như file hướng dẫn C#, nhưng viết bằng **Node.js** và dùng -**node:test** (built-in từ Node 18+, không cần cài thêm gì). - -``` -tests/ -└── unit/ - ├── flight.tracker.test.js ← 7 test cases: getFlightPosition - └── membership.service.test.js ← 10 test cases: getMembershipInfo, - earnPointsAfterBooking, - revokePointsOnCancel -``` - ---- - -## Bước 1 — Copy file test vào project - -Chép 2 file vào thư mục `tests/unit/` trong project của bạn: - -``` -backend-log-function/ -└── tests/ - └── unit/ - ├── flight.tracker.test.js ← file mới - ├── membership.service.test.js ← file mới - ├── flight.service.test.js (có sẵn) - └── ... -``` - ---- - -## Bước 2 — Kiểm tra Node.js version - -```bash -node --version -``` - -Cần **Node 18 trở lên**. Nếu thấp hơn thì nâng Node: - -```bash -# Dùng nvm (nếu đã cài) -nvm install 20 -nvm use 20 -``` - ---- - -## Bước 3 — Chạy từng file test riêng - -```bash -cd backend-log-function - -# Chỉ chạy flight tracker -node --test tests/unit/flight.tracker.test.js - -# Chỉ chạy membership -node --test tests/unit/membership.service.test.js -``` - ---- - -## Bước 4 — Chạy tất cả test cùng lúc - -```bash -node --test tests/unit/*.test.js -``` - -Hoặc dùng script đã có trong package.json: - -```bash -npm test -``` - ---- - -## Bước 5 — Đọc kết quả - -Khi test **PASS**: -``` -✔ getFlightPosition: ném lỗi khi flight_id không tồn tại (3.12ms) -✔ getFlightPosition: status = scheduled khi chưa đến giờ bay (0.45ms) -✔ getMembershipInfo: tier Member khi có 0 điểm (1.02ms) -... -ℹ tests 17 -ℹ pass 17 -ℹ fail 0 -``` - -Khi test **FAIL** (ví dụ logic tính điểm sai): -``` -✖ earnPointsAfterBooking: tính đúng điểm với multiplier Member (x1.0) - AssertionError: pointsEarned phải = 50, thực tế: 45 - Expected: 50 - Actual: 45 -``` - -→ Đọc dòng **Expected** và **Actual** để biết sai ở đâu, rồi sửa code trong `src/services/`. - ---- - -## Giải thích kỹ thuật Mock - -Vì service dùng `pool.query()` để truy vấn database, -ta **không cần chạy DB thật** khi test. Thay vào đó, -inject hàm giả vào `require.cache`: - -``` -┌─────────────────────┐ require.cache[db.js] -│ flight.service.js │ ──→ { query: fakeQuery } ← stub -│ loyalty.service.js │ -└─────────────────────┘ - ↓ - Hàm fakeQuery trả về data mẫu do mình kiểm soát -``` - -Mỗi test có `fakeQuery` riêng → **isolate hoàn toàn**, không phụ thuộc nhau. - ---- - -## Sơ đồ AAA của từng test (ví dụ) - -``` -TEST: "earnPointsAfterBooking với Silver x1.25" - -ARRANGE: fakeQuery trả về multiplier = 1.25 - totalPrice = 1,000,000 VNĐ - -ACT: earnPointsAfterBooking(userId=1, bookingId=103, totalPrice=1_000_000) - -ASSERT: result.pointsEarned === 125 - (floor(1_000_000 / 10_000) * 1.25 = 100 * 1.25 = 125) -``` - ---- - -## Chạy với output dạng TAP (dễ đọc hơn) - -```bash -node --test --test-reporter=tap tests/unit/flight.tracker.test.js -``` - ---- - -## Lưu ý khi bị lỗi - -| Lỗi | Nguyên nhân | Cách sửa | -|-----|-------------|----------| -| `Cannot find module '../../src/...'` | Đặt file test sai thư mục | Đảm bảo file nằm trong `tests/unit/` | -| `TypeError: service.getFlightPosition is not a function` | Flight service chưa export hàm | Thêm `module.exports = { getFlightPosition, ... }` vào cuối service | -| `node:test` not found | Node version < 18 | Nâng Node lên 18+ |