Compare commits
156 Commits
b4ef2da167
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83c7b45e89 | ||
|
|
dc55df42bc | ||
|
|
189fdb33de | ||
|
|
924bf2c614 | ||
|
|
cac3f272a2 | ||
|
|
e15c72a159 | ||
|
|
c5dc32d9af | ||
|
|
1f770c37e2 | ||
|
|
0441683d55 | ||
|
|
c39188d453 | ||
|
|
71c10193a3 | ||
|
|
a0e354b464 | ||
|
|
1153193723 | ||
|
|
8d1d80bb60 | ||
|
|
f46893e0c3 | ||
|
|
cb6fb94bf7 | ||
|
|
2950af3332 | ||
|
|
0a65e022e0 | ||
|
|
bc13816cbf | ||
|
|
c549418c42 | ||
|
|
59a50d4313 | ||
|
|
002f11240a | ||
|
|
f1ee4584a4 | ||
|
|
0694cf0b8a | ||
|
|
ff911661a4 | ||
|
|
5254254b6d | ||
|
|
1f9fedc3e1 | ||
|
|
d94b9dc337 | ||
|
|
8ba0fedd8b | ||
|
|
776ed6dc4e | ||
|
|
734591807f | ||
|
|
4c20cbd7ca | ||
|
|
30c37affb4 | ||
|
|
247d6e4961 | ||
|
|
e3563dd259 | ||
|
|
7ec818714c | ||
|
|
7defe26cc9 | ||
|
|
4747352e21 | ||
|
|
a7aa842cbb | ||
|
|
e81a656254 | ||
|
|
c081f21688 | ||
|
|
e77ae6396e | ||
|
|
240e8fca25 | ||
|
|
f33d0f1f39 | ||
|
|
ba5bd0ca02 | ||
|
|
d01b853f5e | ||
|
|
4bcac9cb6a | ||
|
|
36b2d04de4 | ||
|
|
0a11e765ad | ||
|
|
5df019ed6e | ||
|
|
7aca2361ca | ||
|
|
4fc9be14c6 | ||
|
|
f9929064fb | ||
|
|
c4b83d2122 | ||
|
|
fbb06ca709 | ||
|
|
82486bac53 | ||
|
|
9621c2d652 | ||
|
|
9076ae6c93 | ||
|
|
04d41979d7 | ||
|
|
d98f634f02 | ||
|
|
5d4748043f | ||
|
|
758919c969 | ||
|
|
ab7d13c1ad | ||
|
|
b7a76a8daa | ||
|
|
5e8651d82f | ||
|
|
66fc03d855 | ||
|
|
c8bea2bcc7 | ||
|
|
14c86210c2 | ||
|
|
2421ba5edf | ||
|
|
06ffbd86f5 | ||
|
|
5e9e0d0742 | ||
|
|
4e89336652 | ||
|
|
ab2330b593 | ||
|
|
42d91cc09a | ||
|
|
673a43db7d | ||
|
|
35b15f37ec | ||
|
|
c74944711e | ||
|
|
cf0c38deb5 | ||
|
|
9c80a15130 | ||
|
|
a86b8ef88d | ||
|
|
168fa7af23 | ||
|
|
08a7aaee1f | ||
|
|
e17eea741a | ||
|
|
357f5337cd | ||
|
|
5e8b8438d9 | ||
|
|
d53d179963 | ||
|
|
132c3b19c0 | ||
|
|
c0d1ea2b0c | ||
|
|
10a82f544f | ||
|
|
2b0e24e613 | ||
|
|
66d5684752 | ||
|
|
13a2eb8afd | ||
|
|
cc402067c7 | ||
|
|
58e8cffe9b | ||
|
|
2deecf1bf2 | ||
|
|
0624e6bcc8 | ||
|
|
ebaef53f98 | ||
|
|
93ac0dbc9e | ||
|
|
e68e7c685c | ||
|
|
8e73217792 | ||
|
|
683b0f133e | ||
|
|
1b7ee1004c | ||
|
|
f51a1a80e8 | ||
|
|
aab9b02659 | ||
|
|
f5c6406e31 | ||
|
|
b6d613b743 | ||
|
|
92cc07186e | ||
|
|
8c2117d369 | ||
|
|
25003dbc0c | ||
|
|
34fc7ca7d2 | ||
|
|
d692deb402 | ||
|
|
d44ae36473 | ||
|
|
69df1429fa | ||
|
|
db6ea5d529 | ||
|
|
7469d8824b | ||
|
|
3b2733e7cb | ||
|
|
d594e40051 | ||
|
|
901247a920 | ||
|
|
1b45a38c78 | ||
|
|
e9e39e645c | ||
|
|
796d9f35b0 | ||
|
|
6202a0e814 | ||
|
|
0771631b5a | ||
|
|
b7b1975c5c | ||
|
|
41452ccd3d | ||
|
|
8e547570e1 | ||
|
|
c21bb53382 | ||
|
|
1d1cb9c040 | ||
|
|
e148549b6c | ||
|
|
366673c690 | ||
|
|
9299d22c5b | ||
|
|
12d1327525 | ||
|
|
9f28580cc9 | ||
|
|
c2d786e4e0 | ||
|
|
b1efb2828f | ||
|
|
cba6f25ff2 | ||
|
|
a6684a8bb4 | ||
|
|
ab5a8c0154 | ||
|
|
ced92c34f8 | ||
|
|
f7c0cf5c18 | ||
|
|
812211f62b | ||
|
|
5b7e2d4393 | ||
|
|
dd20571679 | ||
|
|
9ec9ac86c9 | ||
|
|
e60e70a6b0 | ||
|
|
124945dddb | ||
|
|
7914da0e99 | ||
|
|
f743d50d7f | ||
|
|
cf82446641 | ||
|
|
616a06387b | ||
|
|
bd2bca9f33 | ||
|
|
d90b0bb260 | ||
|
|
c04acb972f | ||
|
|
9c1952cc7a | ||
|
|
2b52d70696 | ||
|
|
122619cab1 |
46
AGENTS.md
46
AGENTS.md
@@ -40,6 +40,13 @@ Repository
|
|||||||
- Root: /home/androidlover5842/IdeaProjects/TrisolarisServer
|
- Root: /home/androidlover5842/IdeaProjects/TrisolarisServer
|
||||||
- Entry: src/main/kotlin/com/android/trisolarisserver/TrisolarisServerApplication.kt
|
- Entry: src/main/kotlin/com/android/trisolarisserver/TrisolarisServerApplication.kt
|
||||||
- Scheduling enabled (@EnableScheduling)
|
- Scheduling enabled (@EnableScheduling)
|
||||||
|
- Package layout (domain subpackages; keep top-level grouping):
|
||||||
|
- controller/{auth,booking,guest,room,rate,property,payment,card,email,document,common,system,assets,transport,razorpay}
|
||||||
|
- controller/dto/{booking,guest,payment,property,rate,room,razorpay}
|
||||||
|
- repo/{booking,guest,room,rate,property,card,email,razorpay}
|
||||||
|
- component/{ai,auth,booking,document,geo,room,sse,storage,razorpay}
|
||||||
|
- config/{core,db,booking,room,rate,guest,payment,card,razorpay}
|
||||||
|
- service/email
|
||||||
|
|
||||||
Security/Auth
|
Security/Auth
|
||||||
- Firebase Admin auth for every request; Firebase UID required.
|
- Firebase Admin auth for every request; Firebase UID required.
|
||||||
@@ -99,12 +106,15 @@ Properties
|
|||||||
|
|
||||||
Booking flow
|
Booking flow
|
||||||
- POST /properties/{propertyId}/bookings (create booking)
|
- POST /properties/{propertyId}/bookings (create booking)
|
||||||
- /properties/{propertyId}/bookings/{bookingId}/check-in (creates RoomStay rows)
|
- /properties/{propertyId}/bookings/{bookingId}/check-in/bulk (creates RoomStay rows with per-stay rates)
|
||||||
- /properties/{propertyId}/bookings/{bookingId}/check-out (closes RoomStay)
|
- /properties/{propertyId}/bookings/{bookingId}/check-out (closes RoomStay)
|
||||||
|
- /properties/{propertyId}/bookings/{bookingId}/room-stays/{roomStayId}/check-out (closes specific stay; single-stay booking auto-closes booking)
|
||||||
- /properties/{propertyId}/bookings/{bookingId}/cancel
|
- /properties/{propertyId}/bookings/{bookingId}/cancel
|
||||||
- /properties/{propertyId}/bookings/{bookingId}/no-show
|
- /properties/{propertyId}/bookings/{bookingId}/no-show
|
||||||
- /properties/{propertyId}/bookings/{bookingId}/room-stays (pre-assign RoomStay with date range)
|
- /properties/{propertyId}/bookings/{bookingId}/room-requests (room-type quantity reservation)
|
||||||
- /properties/{propertyId}/room-stays/{roomStayId}/change-room (idempotent via RoomStayChange)
|
- /properties/{propertyId}/bookings/{bookingId}/room-requests/{requestId} (cancel reservation)
|
||||||
|
- /properties/{propertyId}/room-stays/{roomStayId}/void (soft-void active stay)
|
||||||
|
- /properties/{propertyId}/cancellation-policy (get/update policy)
|
||||||
|
|
||||||
Card issuing
|
Card issuing
|
||||||
- /properties/{propertyId}/room-stays/{roomStayId}/cards/prepare -> returns cardIndex + sector0 payload
|
- /properties/{propertyId}/room-stays/{roomStayId}/cards/prepare -> returns cardIndex + sector0 payload
|
||||||
@@ -185,3 +195,33 @@ Notes / constraints
|
|||||||
- Super admin can create properties and assign users to properties.
|
- Super admin can create properties and assign users to properties.
|
||||||
- Admin can assign ADMIN/MANAGER/STAFF/AGENT; Manager can assign STAFF/AGENT.
|
- Admin can assign ADMIN/MANAGER/STAFF/AGENT; Manager can assign STAFF/AGENT.
|
||||||
- Agents can only see free rooms.
|
- Agents can only see free rooms.
|
||||||
|
- Role hierarchy for visibility/management: SUPER_ADMIN > ADMIN > MANAGER > STAFF/HOUSEKEEPING/FINANCE/SUPERVISOR/GUIDE > AGENT. Users cannot see anyone above their rank in property user lists. Access code invites cannot assign ADMIN.
|
||||||
|
- Property code is auto-generated (7-char random, no fixed prefix). Property create no longer accepts `code` in request. Join-by-code uses property code, not propertyId.
|
||||||
|
- Property access codes: 6-digit PIN, 1-minute expiry, single-use. Admin generates; staff joins with property code + PIN.
|
||||||
|
- Property user disable is property-scoped (not global); hierarchy applies for who can disable.
|
||||||
|
- Room stay lifecycle: `RoomStay` now supports soft void (`is_voided`), and room-stay audit events are written to `room_stay_audit_log`.
|
||||||
|
- Checkout supports both booking-level and specific room-stay checkout; specific checkout endpoint: `POST /properties/{propertyId}/bookings/{bookingId}/room-stays/{roomStayId}/check-out`.
|
||||||
|
- Staff can change/void stays only before first payment on booking; manager/admin can act after payments.
|
||||||
|
- Checkout validation: nightly rate must be within +/-20% of room type default rate (when default rate exists), and minimum stay duration must be at least 1 hour.
|
||||||
|
- Room-type reservations: use booking room requests (`booking_room_request`) for quantity holds without room numbers; availability checks include active requests + occupied stays.
|
||||||
|
- Cancellation policy engine (advance bookings): policy per property with `freeDaysBeforeCheckin` + `penaltyMode` (`NO_CHARGE`, `ONE_NIGHT`, `FULL_STAY`). On cancel/no-show, penalty charge ledger rows are auto-created (`CANCELLATION_PENALTY` / `NO_SHOW_PENALTY`) when within penalty window.
|
||||||
|
|
||||||
|
Operational notes
|
||||||
|
- Payment provider migrated: PayU removed; Razorpay now used for settings, QR, payment links, and webhooks.
|
||||||
|
- Server access: SSH host alias `hotel` is available for server operations (e.g., `ssh hotel`). Use carefully; DB changes were done via `sudo -u postgres psql` on the server when needed.
|
||||||
|
- Schema changes: schema fix classes have been removed. If a new column/table is required, apply it manually on the server using `ssh hotel` and `sudo -u postgres psql -d trisolaris`, e.g. `alter table ... add column ...`. Keep a note of the exact SQL applied.
|
||||||
|
- Agent workflow expectation: when schema/runtime issues require server-side SQL or service checks, execute the required `ssh hotel` operations directly and report what was changed; do not block on asking for confirmation in normal flow.
|
||||||
|
|
||||||
|
Access / ops notes (prod)
|
||||||
|
- Service: `TrisolarisServer.service` (systemd). `systemctl cat TrisolarisServer.service`.
|
||||||
|
- Deploy path: `/opt/deploy/TrisolarisServer` (runs `build/libs/*.jar`).
|
||||||
|
- Active profile: `prod` (see service Environment=SPRING_PROFILES_ACTIVE=prod).
|
||||||
|
- DB (prod): PostgreSQL `trisolaris` on `localhost:5432` (see `/opt/deploy/TrisolarisServer/src/main/resources/application-prod.properties` on the server).
|
||||||
|
- DB (dev): PostgreSQL `trisolaris` on `192.168.1.53:5432` (see `application-dev.properties` in the repo).
|
||||||
|
- DB access (server): `sudo -u postgres psql -d trisolaris`.
|
||||||
|
- Workflow: Always run build, commit, and push for changes unless explicitly told otherwise.
|
||||||
|
- API docs policy (mandatory):
|
||||||
|
- For every API change (`add`, `update`, `delete`, path change, request/response change, role change, validation/error change), update `docs/API_REFERENCE.txt` in the same endpoint-by-endpoint text format already used there.
|
||||||
|
- Keep each API block in this style: `"<Name> API is this one:"` -> `METHOD /path` -> `What it does` -> `Request body` -> `Allowed roles` -> `Error Codes`.
|
||||||
|
- Script-generated API docs are forbidden. Documentation updates must be manual edits only.
|
||||||
|
- Android workflow note: user always runs Shift+F10 in Android Studio to deploy updates.
|
||||||
|
|||||||
3208
docs/API_REFERENCE.txt
Normal file
3208
docs/API_REFERENCE.txt
Normal file
File diff suppressed because it is too large
Load Diff
2353
razoryPayDocs.txt
Normal file
2353
razoryPayDocs.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,81 +0,0 @@
|
|||||||
package com.android.trisolarisserver.component
|
|
||||||
|
|
||||||
import com.android.trisolarisserver.controller.GuestDocumentResponse
|
|
||||||
import com.android.trisolarisserver.controller.toResponse
|
|
||||||
import com.android.trisolarisserver.db.repo.GuestDocumentRepo
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
|
||||||
import org.springframework.scheduling.annotation.Scheduled
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
|
|
||||||
import java.io.IOException
|
|
||||||
import java.util.UUID
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class GuestDocumentEvents(
|
|
||||||
private val guestDocumentRepo: GuestDocumentRepo,
|
|
||||||
private val objectMapper: ObjectMapper
|
|
||||||
) {
|
|
||||||
private val emitters: MutableMap<GuestDocKey, CopyOnWriteArrayList<SseEmitter>> = ConcurrentHashMap()
|
|
||||||
|
|
||||||
fun subscribe(propertyId: UUID, guestId: UUID): SseEmitter {
|
|
||||||
val key = GuestDocKey(propertyId, guestId)
|
|
||||||
val emitter = SseEmitter(0L)
|
|
||||||
emitters.computeIfAbsent(key) { CopyOnWriteArrayList() }.add(emitter)
|
|
||||||
emitter.onCompletion { emitters[key]?.remove(emitter) }
|
|
||||||
emitter.onTimeout { emitters[key]?.remove(emitter) }
|
|
||||||
emitter.onError { emitters[key]?.remove(emitter) }
|
|
||||||
try {
|
|
||||||
emitter.send(SseEmitter.event().name("guest-documents").data(buildSnapshot(propertyId, guestId)))
|
|
||||||
} catch (_: IOException) {
|
|
||||||
emitters[key]?.remove(emitter)
|
|
||||||
}
|
|
||||||
return emitter
|
|
||||||
}
|
|
||||||
|
|
||||||
fun emit(propertyId: UUID, guestId: UUID) {
|
|
||||||
val key = GuestDocKey(propertyId, guestId)
|
|
||||||
val list = emitters[key] ?: return
|
|
||||||
val data = buildSnapshot(propertyId, guestId)
|
|
||||||
val dead = mutableListOf<SseEmitter>()
|
|
||||||
for (emitter in list) {
|
|
||||||
try {
|
|
||||||
emitter.send(SseEmitter.event().name("guest-documents").data(data))
|
|
||||||
} catch (_: IOException) {
|
|
||||||
dead.add(emitter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (dead.isNotEmpty()) {
|
|
||||||
list.removeAll(dead.toSet())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Scheduled(fixedDelayString = "25000")
|
|
||||||
fun heartbeat() {
|
|
||||||
emitters.forEach { (_, list) ->
|
|
||||||
val dead = mutableListOf<SseEmitter>()
|
|
||||||
for (emitter in list) {
|
|
||||||
try {
|
|
||||||
emitter.send(SseEmitter.event().name("ping").data("ok"))
|
|
||||||
} catch (_: IOException) {
|
|
||||||
dead.add(emitter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (dead.isNotEmpty()) {
|
|
||||||
list.removeAll(dead.toSet())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildSnapshot(propertyId: UUID, guestId: UUID): List<GuestDocumentResponse> {
|
|
||||||
return guestDocumentRepo
|
|
||||||
.findByPropertyIdAndGuestIdOrderByUploadedAtDesc(propertyId, guestId)
|
|
||||||
.map { it.toResponse(objectMapper) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class GuestDocKey(
|
|
||||||
val propertyId: UUID,
|
|
||||||
val guestId: UUID
|
|
||||||
)
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package com.android.trisolarisserver.component
|
|
||||||
|
|
||||||
import com.android.trisolarisserver.controller.dto.RoomBoardResponse
|
|
||||||
import com.android.trisolarisserver.controller.dto.RoomBoardStatus
|
|
||||||
import com.android.trisolarisserver.repo.RoomRepo
|
|
||||||
import com.android.trisolarisserver.repo.RoomStayRepo
|
|
||||||
import org.springframework.scheduling.annotation.Scheduled
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
|
|
||||||
import java.io.IOException
|
|
||||||
import java.util.UUID
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class RoomBoardEvents(
|
|
||||||
private val roomRepo: RoomRepo,
|
|
||||||
private val roomStayRepo: RoomStayRepo
|
|
||||||
) {
|
|
||||||
private val emitters: MutableMap<UUID, CopyOnWriteArrayList<SseEmitter>> = ConcurrentHashMap()
|
|
||||||
|
|
||||||
fun subscribe(propertyId: UUID): SseEmitter {
|
|
||||||
val emitter = SseEmitter(0L)
|
|
||||||
emitters.computeIfAbsent(propertyId) { CopyOnWriteArrayList() }.add(emitter)
|
|
||||||
emitter.onCompletion { emitters[propertyId]?.remove(emitter) }
|
|
||||||
emitter.onTimeout { emitters[propertyId]?.remove(emitter) }
|
|
||||||
emitter.onError { emitters[propertyId]?.remove(emitter) }
|
|
||||||
try {
|
|
||||||
emitter.send(SseEmitter.event().name("room-board").data(buildSnapshot(propertyId)))
|
|
||||||
} catch (_: IOException) {
|
|
||||||
emitters[propertyId]?.remove(emitter)
|
|
||||||
}
|
|
||||||
return emitter
|
|
||||||
}
|
|
||||||
|
|
||||||
fun emit(propertyId: UUID) {
|
|
||||||
val data = buildSnapshot(propertyId)
|
|
||||||
val list = emitters[propertyId] ?: return
|
|
||||||
val dead = mutableListOf<SseEmitter>()
|
|
||||||
for (emitter in list) {
|
|
||||||
try {
|
|
||||||
emitter.send(SseEmitter.event().name("room-board").data(data))
|
|
||||||
} catch (_: IOException) {
|
|
||||||
dead.add(emitter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (dead.isNotEmpty()) {
|
|
||||||
list.removeAll(dead.toSet())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Scheduled(fixedDelayString = "25000")
|
|
||||||
fun heartbeat() {
|
|
||||||
emitters.forEach { (_, list) ->
|
|
||||||
val dead = mutableListOf<SseEmitter>()
|
|
||||||
for (emitter in list) {
|
|
||||||
try {
|
|
||||||
emitter.send(SseEmitter.event().name("ping").data("ok"))
|
|
||||||
} catch (_: IOException) {
|
|
||||||
dead.add(emitter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (dead.isNotEmpty()) {
|
|
||||||
list.removeAll(dead.toSet())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildSnapshot(propertyId: UUID): List<RoomBoardResponse> {
|
|
||||||
val rooms = roomRepo.findByPropertyIdOrderByRoomNumber(propertyId)
|
|
||||||
val occupiedRoomIds = roomStayRepo.findOccupiedRoomIds(propertyId).toHashSet()
|
|
||||||
return rooms.map { room ->
|
|
||||||
val status = when {
|
|
||||||
room.maintenance -> RoomBoardStatus.MAINTENANCE
|
|
||||||
!room.active -> RoomBoardStatus.INACTIVE
|
|
||||||
occupiedRoomIds.contains(room.id) -> RoomBoardStatus.OCCUPIED
|
|
||||||
else -> RoomBoardStatus.FREE
|
|
||||||
}
|
|
||||||
RoomBoardResponse(
|
|
||||||
roomNumber = room.roomNumber,
|
|
||||||
roomTypeName = room.roomType.name,
|
|
||||||
status = status
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.android.trisolarisserver.component.ai
|
||||||
|
import com.android.trisolarisserver.component.ai.formatAadhaar
|
||||||
|
import com.android.trisolarisserver.component.ai.isValidAadhaar
|
||||||
|
|
||||||
|
internal fun formatAadhaar(value: String): String {
|
||||||
|
if (value.length != 12) return value
|
||||||
|
return value.chunked(4).joinToString(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun isValidAadhaar(value: String): Boolean {
|
||||||
|
if (value.length != 12 || !value.all { it.isDigit() }) return false
|
||||||
|
var c = 0
|
||||||
|
val reversed = value.reversed()
|
||||||
|
for (i in reversed.indices) {
|
||||||
|
val digit = reversed[i].digitToInt()
|
||||||
|
c = aadhaarMultiplication[c][aadhaarPermutation[i % 8][digit]]
|
||||||
|
}
|
||||||
|
return c == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private val aadhaarMultiplication = arrayOf(
|
||||||
|
intArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9),
|
||||||
|
intArrayOf(1, 2, 3, 4, 0, 6, 7, 8, 9, 5),
|
||||||
|
intArrayOf(2, 3, 4, 0, 1, 7, 8, 9, 5, 6),
|
||||||
|
intArrayOf(3, 4, 0, 1, 2, 8, 9, 5, 6, 7),
|
||||||
|
intArrayOf(4, 0, 1, 2, 3, 9, 5, 6, 7, 8),
|
||||||
|
intArrayOf(5, 9, 8, 7, 6, 0, 4, 3, 2, 1),
|
||||||
|
intArrayOf(6, 5, 9, 8, 7, 1, 0, 4, 3, 2),
|
||||||
|
intArrayOf(7, 6, 5, 9, 8, 2, 1, 0, 4, 3),
|
||||||
|
intArrayOf(8, 7, 6, 5, 9, 3, 2, 1, 0, 4),
|
||||||
|
intArrayOf(9, 8, 7, 6, 5, 4, 3, 2, 1, 0)
|
||||||
|
)
|
||||||
|
private val aadhaarPermutation = arrayOf(
|
||||||
|
intArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9),
|
||||||
|
intArrayOf(1, 5, 7, 6, 2, 8, 3, 0, 9, 4),
|
||||||
|
intArrayOf(5, 8, 0, 3, 7, 9, 6, 1, 4, 2),
|
||||||
|
intArrayOf(8, 9, 1, 6, 0, 4, 3, 5, 2, 7),
|
||||||
|
intArrayOf(9, 4, 5, 3, 1, 2, 6, 8, 7, 0),
|
||||||
|
intArrayOf(4, 2, 8, 6, 5, 7, 3, 9, 0, 1),
|
||||||
|
intArrayOf(2, 7, 9, 3, 8, 0, 6, 4, 1, 5),
|
||||||
|
intArrayOf(7, 0, 4, 6, 9, 1, 3, 2, 5, 8)
|
||||||
|
)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.android.trisolarisserver.component
|
package com.android.trisolarisserver.component.ai
|
||||||
|
|
||||||
import jakarta.annotation.PreDestroy
|
import jakarta.annotation.PreDestroy
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.android.trisolarisserver.component
|
package com.android.trisolarisserver.component.ai
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
@@ -23,17 +23,19 @@ class LlamaClient(
|
|||||||
@Value("\${ai.llama.repeatPenalty:1.0}")
|
@Value("\${ai.llama.repeatPenalty:1.0}")
|
||||||
private val repeatPenalty: Double,
|
private val repeatPenalty: Double,
|
||||||
@Value("\${ai.llama.topK:40}")
|
@Value("\${ai.llama.topK:40}")
|
||||||
private val topK: Int
|
private val topK: Int,
|
||||||
|
@Value("\${ai.llama.model}")
|
||||||
|
private val model: String
|
||||||
) {
|
) {
|
||||||
private val systemPrompt =
|
private val systemPrompt =
|
||||||
"Look only at visible text. " +
|
"Read extremely carefully. Look only at visible text. " +
|
||||||
"Return the exact text you can read verbatim. " +
|
"Return the exact text you can read verbatim. " +
|
||||||
"If the text is unclear, partial, or inferred, return NOT CLEARLY VISIBLE. " +
|
"If the text is unclear, partial, or inferred, return NOT CLEARLY VISIBLE. " +
|
||||||
"Do not guess. Do not explain."
|
"Do not guess. Do not explain."
|
||||||
|
|
||||||
fun ask(imageUrl: String, question: String): String {
|
fun ask(imageUrl: String, question: String): String {
|
||||||
val payload = mapOf(
|
val payload = mapOf(
|
||||||
"model" to "qwen",
|
"model" to model,
|
||||||
"temperature" to temperature,
|
"temperature" to temperature,
|
||||||
"top_p" to topP,
|
"top_p" to topP,
|
||||||
"min_p" to minP,
|
"min_p" to minP,
|
||||||
@@ -56,9 +58,37 @@ class LlamaClient(
|
|||||||
return post(payload)
|
return post(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun askWithOcr(imageUrl: String, ocrText: String, question: String): String {
|
||||||
|
val payload = mapOf(
|
||||||
|
"model" to model,
|
||||||
|
"temperature" to temperature,
|
||||||
|
"top_p" to topP,
|
||||||
|
"min_p" to minP,
|
||||||
|
"repeat_penalty" to repeatPenalty,
|
||||||
|
"top_k" to topK,
|
||||||
|
"messages" to listOf(
|
||||||
|
mapOf(
|
||||||
|
"role" to "system",
|
||||||
|
"content" to systemPrompt
|
||||||
|
),
|
||||||
|
mapOf(
|
||||||
|
"role" to "user",
|
||||||
|
"content" to listOf(
|
||||||
|
mapOf(
|
||||||
|
"type" to "text",
|
||||||
|
"text" to "${question}\n\nOCR:\n${ocrText}"
|
||||||
|
),
|
||||||
|
mapOf("type" to "image_url", "image_url" to mapOf("url" to imageUrl))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return post(payload)
|
||||||
|
}
|
||||||
|
|
||||||
fun askText(content: String, question: String): String {
|
fun askText(content: String, question: String): String {
|
||||||
val payload = mapOf(
|
val payload = mapOf(
|
||||||
"model" to "qwen",
|
"model" to model,
|
||||||
"temperature" to temperature,
|
"temperature" to temperature,
|
||||||
"top_p" to topP,
|
"top_p" to topP,
|
||||||
"min_p" to minP,
|
"min_p" to minP,
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package com.android.trisolarisserver.component.ai
|
||||||
|
import com.android.trisolarisserver.component.ai.formatAadhaar
|
||||||
|
import com.android.trisolarisserver.component.ai.isValidAadhaar
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.core.io.FileSystemResource
|
||||||
|
import org.springframework.http.HttpEntity
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.util.LinkedMultiValueMap
|
||||||
|
import org.springframework.web.client.RestTemplate
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class PaddleOcrClient(
|
||||||
|
private val restTemplate: RestTemplate,
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
|
@Value("\${ocr.paddle.enabled:false}")
|
||||||
|
private val enabled: Boolean,
|
||||||
|
@Value("\${ocr.paddle.baseUrl:https://ocr.hoteltrisolaris.in}")
|
||||||
|
private val baseUrl: String,
|
||||||
|
@Value("\${ocr.paddle.minScore:0.9}")
|
||||||
|
private val minScore: Double,
|
||||||
|
@Value("\${ocr.paddle.minAverageScore:0.8}")
|
||||||
|
private val minAverageScore: Double,
|
||||||
|
@Value("\${ocr.paddle.minTextLength:4}")
|
||||||
|
private val minTextLength: Int
|
||||||
|
) {
|
||||||
|
private val logger = LoggerFactory.getLogger(PaddleOcrClient::class.java)
|
||||||
|
private val aadhaarRegex = Regex("\\b(?:\\d[\\s-]?){12}\\b")
|
||||||
|
|
||||||
|
fun extract(filePath: String): PaddleOcrResult? {
|
||||||
|
if (!enabled) return null
|
||||||
|
val path = Path.of(filePath)
|
||||||
|
if (!Files.exists(path)) return null
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val sizeBytes = Files.size(path)
|
||||||
|
logger.debug("PaddleOCR extract path={} sizeBytes={}", path, sizeBytes)
|
||||||
|
val output = callOcr(path)
|
||||||
|
val average = averageScore(output.scores)
|
||||||
|
val rawCandidates = extractCandidates(output.texts)
|
||||||
|
val filtered = filterByScore(output.texts, output.scores, minScore, minTextLength)
|
||||||
|
val filteredCandidates = extractCandidates(filtered)
|
||||||
|
val aadhaar = extractAadhaar(filtered)
|
||||||
|
if (rawCandidates.isNotEmpty() || filteredCandidates.isNotEmpty() || aadhaar != null) {
|
||||||
|
logger.debug(
|
||||||
|
"PaddleOCR candidates path={} raw={} filtered={} selected={}",
|
||||||
|
path,
|
||||||
|
rawCandidates.map { maskAadhaar(it) },
|
||||||
|
filteredCandidates.map { maskAadhaar(it) },
|
||||||
|
aadhaar?.let { maskAadhaar(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val rejected = average != null && average < minAverageScore
|
||||||
|
PaddleOcrResult(filtered, aadhaar, average, rejected)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
logger.warn("PaddleOCR failed: {}", ex.message)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun callOcr(path: Path): OcrPayload {
|
||||||
|
val headers = HttpHeaders()
|
||||||
|
headers.contentType = MediaType.MULTIPART_FORM_DATA
|
||||||
|
val body = LinkedMultiValueMap<String, Any>().apply {
|
||||||
|
add("file", FileSystemResource(path.toFile()))
|
||||||
|
}
|
||||||
|
val entity = HttpEntity(body, headers)
|
||||||
|
val response = restTemplate.postForEntity(baseUrl, entity, String::class.java)
|
||||||
|
val raw = response.body ?: return OcrPayload(emptyList(), emptyList())
|
||||||
|
val node = objectMapper.readTree(raw)
|
||||||
|
val texts = node.path("texts")
|
||||||
|
val scores = node.path("scores")
|
||||||
|
if (!texts.isArray) return OcrPayload(emptyList(), emptyList())
|
||||||
|
val parsedTexts = texts.mapNotNull { it.asText(null) }
|
||||||
|
val parsedScores = if (scores.isArray) {
|
||||||
|
scores.mapNotNull { if (it.isNumber) it.asDouble() else null }
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
return OcrPayload(parsedTexts, parsedScores)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun filterByScore(texts: List<String>, scores: List<Double>, min: Double, minLen: Int): List<String> {
|
||||||
|
if (scores.size != texts.size || scores.isEmpty()) return texts
|
||||||
|
return texts.mapIndexedNotNull { index, text ->
|
||||||
|
if (scores[index] >= min && text.trim().length >= minLen) text else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun averageScore(scores: List<Double>): Double? {
|
||||||
|
if (scores.isEmpty()) return null
|
||||||
|
return scores.sum() / scores.size
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractAadhaar(texts: List<String>): String? {
|
||||||
|
val candidates = extractCandidates(texts)
|
||||||
|
val valid = candidates.firstOrNull { isValidAadhaar(it) } ?: return null
|
||||||
|
return formatAadhaar(valid)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractCandidates(texts: List<String>): List<String> {
|
||||||
|
val joined = texts.joinToString(" ")
|
||||||
|
val candidates = mutableListOf<String>()
|
||||||
|
aadhaarRegex.findAll(joined).forEach { match ->
|
||||||
|
val digits = match.value.filter { it.isDigit() }
|
||||||
|
if (digits.length == 12) {
|
||||||
|
candidates.add(digits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maskAadhaar(value: String): String {
|
||||||
|
val digits = value.filter { it.isDigit() }
|
||||||
|
if (digits.length != 12) return value
|
||||||
|
return "XXXXXXXX" + digits.takeLast(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PaddleOcrResult(
|
||||||
|
val texts: List<String>,
|
||||||
|
val aadhaar: String?,
|
||||||
|
val averageScore: Double?,
|
||||||
|
val rejected: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class OcrPayload(
|
||||||
|
val texts: List<String>,
|
||||||
|
val scores: List<Double>
|
||||||
|
)
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
package com.android.trisolarisserver.component
|
package com.android.trisolarisserver.component.auth
|
||||||
|
import com.android.trisolarisserver.controller.common.requireMember
|
||||||
|
|
||||||
import com.android.trisolarisserver.repo.AppUserRepo
|
import com.android.trisolarisserver.repo.property.AppUserRepo
|
||||||
import com.android.trisolarisserver.repo.PropertyUserRepo
|
import com.android.trisolarisserver.repo.property.PropertyUserRepo
|
||||||
import com.android.trisolarisserver.models.property.Role
|
import com.android.trisolarisserver.models.property.Role
|
||||||
import org.springframework.security.access.AccessDeniedException
|
import org.springframework.security.access.AccessDeniedException
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.android.trisolarisserver.component.booking
|
||||||
|
import com.android.trisolarisserver.component.sse.SseHub
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.controller.booking.BookingSnapshotBuilder
|
||||||
|
import com.android.trisolarisserver.controller.dto.booking.BookingDetailResponse
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class BookingEvents(
|
||||||
|
private val bookingSnapshotBuilder: BookingSnapshotBuilder
|
||||||
|
) {
|
||||||
|
private val hub = SseHub<BookingKey>("booking") { key ->
|
||||||
|
bookingSnapshotBuilder.build(key.propertyId, key.bookingId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun subscribe(propertyId: UUID, bookingId: UUID): SseEmitter {
|
||||||
|
return hub.subscribe(BookingKey(propertyId, bookingId))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emit(propertyId: UUID, bookingId: UUID) {
|
||||||
|
hub.emit(BookingKey(propertyId, bookingId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "25000")
|
||||||
|
fun heartbeat() {
|
||||||
|
hub.heartbeat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class BookingKey(
|
||||||
|
val propertyId: UUID,
|
||||||
|
val bookingId: UUID
|
||||||
|
)
|
||||||
@@ -0,0 +1,834 @@
|
|||||||
|
package com.android.trisolarisserver.component.document
|
||||||
|
import com.android.trisolarisserver.component.ai.LlamaClient
|
||||||
|
import com.android.trisolarisserver.component.ai.PaddleOcrClient
|
||||||
|
import com.android.trisolarisserver.component.ai.PaddleOcrResult
|
||||||
|
import com.android.trisolarisserver.component.ai.formatAadhaar
|
||||||
|
import com.android.trisolarisserver.component.ai.isValidAadhaar
|
||||||
|
import com.android.trisolarisserver.component.booking.BookingEvents
|
||||||
|
import com.android.trisolarisserver.component.geo.PincodeResolver
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.controller.document.DocumentPrompts
|
||||||
|
import com.android.trisolarisserver.repo.guest.GuestRepo
|
||||||
|
import com.android.trisolarisserver.repo.guest.GuestDocumentRepo
|
||||||
|
import com.android.trisolarisserver.repo.booking.BookingRepo
|
||||||
|
import com.android.trisolarisserver.models.booking.GuestDocument
|
||||||
|
import com.android.trisolarisserver.models.booking.GuestVehicle
|
||||||
|
import com.android.trisolarisserver.repo.guest.GuestVehicleRepo
|
||||||
|
import com.android.trisolarisserver.repo.property.PropertyRepo
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.Period
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.DateTimeFormatterBuilder
|
||||||
|
import java.time.format.ResolverStyle
|
||||||
|
import java.util.UUID
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
|
@org.springframework.stereotype.Component
|
||||||
|
class DocumentExtractionService(
|
||||||
|
private val llamaClient: LlamaClient,
|
||||||
|
private val guestRepo: GuestRepo,
|
||||||
|
private val guestDocumentRepo: GuestDocumentRepo,
|
||||||
|
private val guestVehicleRepo: GuestVehicleRepo,
|
||||||
|
private val propertyRepo: PropertyRepo,
|
||||||
|
private val paddleOcrClient: PaddleOcrClient,
|
||||||
|
private val bookingRepo: BookingRepo,
|
||||||
|
private val pincodeResolver: PincodeResolver,
|
||||||
|
private val bookingEvents: BookingEvents,
|
||||||
|
private val objectMapper: ObjectMapper
|
||||||
|
) {
|
||||||
|
private val logger = LoggerFactory.getLogger(DocumentExtractionService::class.java)
|
||||||
|
private val aadhaarRegex = Regex("\\b\\d{4}\\s?\\d{4}\\s?\\d{4}\\b")
|
||||||
|
|
||||||
|
fun extractAndApply(localImageUrl: String, publicImageUrl: String, document: GuestDocument, propertyId: UUID): ExtractionResult {
|
||||||
|
val results = linkedMapOf<String, String>()
|
||||||
|
val ocrResult = paddleOcrClient.extract(document.storagePath)
|
||||||
|
if (ocrResult?.texts?.isNotEmpty() == true) {
|
||||||
|
val preview = ocrResult.texts.take(30).joinToString(" | ") { it.take(80) }
|
||||||
|
logger.debug("OCR texts preview docId={}: {}", document.id, preview)
|
||||||
|
}
|
||||||
|
if (ocrResult?.rejected == true) {
|
||||||
|
results["docType"] = "REJECTED"
|
||||||
|
results["rejectReason"] = "LOW_OCR_SCORE"
|
||||||
|
results["ocrAverage"] = ocrResult.averageScore?.toString() ?: "UNKNOWN"
|
||||||
|
return ExtractionResult(results, false)
|
||||||
|
}
|
||||||
|
val ocrText = ocrResult?.texts?.takeIf { it.isNotEmpty() }?.joinToString("\n")
|
||||||
|
if (!ocrText.isNullOrBlank()) {
|
||||||
|
val candidates = aadhaarRegex.findAll(ocrText).map { it.value }.toList()
|
||||||
|
if (candidates.isNotEmpty()) {
|
||||||
|
val normalized = candidates.map { it.replace(Regex("\\s+"), "") }
|
||||||
|
val valid = normalized.filter { isValidAadhaar(it) }.map { maskAadhaar(it) }
|
||||||
|
logger.debug(
|
||||||
|
"OCR Aadhaar candidates docId={} candidates={} valid={}",
|
||||||
|
document.id,
|
||||||
|
normalized.map { maskAadhaar(it) },
|
||||||
|
valid
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val detections = listOf(
|
||||||
|
Detection(
|
||||||
|
detect = {
|
||||||
|
results["isVehiclePhoto"] = askWithContext(
|
||||||
|
ocrText,
|
||||||
|
localImageUrl,
|
||||||
|
"IS THIS A VEHICLE NUMBER PLATE PHOTO? Answer YES or NO only."
|
||||||
|
)
|
||||||
|
if (!isYes(results["isVehiclePhoto"])) return@Detection false
|
||||||
|
val candidate = askWithContext(
|
||||||
|
ocrText,
|
||||||
|
localImageUrl,
|
||||||
|
"VEHICLE NUMBER PLATE? Reply only number or NONE."
|
||||||
|
)
|
||||||
|
val cleaned = cleanedValue(candidate)
|
||||||
|
if (cleaned != null && isLikelyVehicleNumber(cleaned)) {
|
||||||
|
results["vehicleNumber"] = cleaned
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
results["vehicleNumber"] = "NONE"
|
||||||
|
results["isVehiclePhoto"] = "NO"
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handle = {}
|
||||||
|
),
|
||||||
|
Detection(
|
||||||
|
detect = {
|
||||||
|
results["hasAadhar"] = askWithContext(
|
||||||
|
ocrText,
|
||||||
|
localImageUrl,
|
||||||
|
"CONTAINS AADHAAR? Answer YES or NO only."
|
||||||
|
)
|
||||||
|
val hasUidai = isYes(askWithContext(ocrText,localImageUrl,"CONTAINS UIDAI? Answer YES or NO only.")) ||
|
||||||
|
isYes(askWithContext(ocrText,localImageUrl,"CONTAINS Unique Identification Authority of India? Answer YES or NO only."))
|
||||||
|
|
||||||
|
isYes(results["hasAadhar"]) || hasUidai
|
||||||
|
},
|
||||||
|
handle = {
|
||||||
|
val aadharQuestions = linkedMapOf(
|
||||||
|
"hasAddress" to "POSTAL ADDRESS PRESENT? Answer YES or NO only.",
|
||||||
|
"hasDob" to "DOB? Reply YES or NO.",
|
||||||
|
"hasGenderMentioned" to "GENDER MENTIONED? Reply YES or NO."
|
||||||
|
)
|
||||||
|
for ((key, question) in aadharQuestions) {
|
||||||
|
results[key] = askWithContext(ocrText, localImageUrl, question)
|
||||||
|
}
|
||||||
|
val hasAddress = isYes(results["hasAddress"])
|
||||||
|
if (hasAddress) {
|
||||||
|
val addressQuestions = linkedMapOf(
|
||||||
|
DocumentPrompts.PIN_CODE,
|
||||||
|
DocumentPrompts.ADDRESS,
|
||||||
|
DocumentPrompts.ID_NUMBER)
|
||||||
|
for ((key, question) in addressQuestions) {
|
||||||
|
results[key] = askWithContext(ocrText, localImageUrl, question)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val hasDob = isYes(results["hasDob"])
|
||||||
|
val hasGender = isYes(results["hasGenderMentioned"])
|
||||||
|
if (hasDob && hasGender) {
|
||||||
|
val aadharFrontQuestions = linkedMapOf(
|
||||||
|
DocumentPrompts.NAME,
|
||||||
|
DocumentPrompts.DOB,
|
||||||
|
DocumentPrompts.ID_NUMBER,
|
||||||
|
DocumentPrompts.GENDER
|
||||||
|
)
|
||||||
|
for ((key, question) in aadharFrontQuestions) {
|
||||||
|
results[key] = askWithContext(ocrText, localImageUrl, question)
|
||||||
|
}
|
||||||
|
ensureAadhaarId(localImageUrl, publicImageUrl, document, results, ocrResult, ocrText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Detection(
|
||||||
|
detect = {
|
||||||
|
results["hasDrivingLicence"] = askWithContext(
|
||||||
|
ocrText,
|
||||||
|
localImageUrl,
|
||||||
|
"CONTAINS DRIVING LICENCE? Answer YES or NO only."
|
||||||
|
)
|
||||||
|
results["hasTransportDept"] = askWithContext(
|
||||||
|
ocrText,
|
||||||
|
localImageUrl,
|
||||||
|
"CONTAINS TRANSPORT DEPARTMENT? Answer YES or NO only."
|
||||||
|
)
|
||||||
|
isYes(results["hasDrivingLicence"]) || isYes(results["hasTransportDept"])
|
||||||
|
},
|
||||||
|
handle = {
|
||||||
|
val drivingQuestions = linkedMapOf(
|
||||||
|
DocumentPrompts.NAME,
|
||||||
|
DocumentPrompts.DOB,
|
||||||
|
"idNumber" to "DL NUMBER? Reply only number or NONE.",
|
||||||
|
DocumentPrompts.ADDRESS,
|
||||||
|
DocumentPrompts.PIN_CODE,
|
||||||
|
DocumentPrompts.CITY,
|
||||||
|
DocumentPrompts.GENDER,
|
||||||
|
DocumentPrompts.NATIONALITY
|
||||||
|
)
|
||||||
|
for ((key, question) in drivingQuestions) {
|
||||||
|
results[key] = askWithContext(ocrText, localImageUrl, question)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Detection(
|
||||||
|
detect = {
|
||||||
|
results["hasElectionCommission"] = askWithContext(
|
||||||
|
ocrText,
|
||||||
|
localImageUrl,
|
||||||
|
"CONTAINS ELECTION COMMISSION OF INDIA? Answer YES or NO only."
|
||||||
|
)
|
||||||
|
isYes(results["hasElectionCommission"])
|
||||||
|
},
|
||||||
|
handle = {
|
||||||
|
val voterQuestions = linkedMapOf(
|
||||||
|
DocumentPrompts.NAME,
|
||||||
|
DocumentPrompts.DOB,
|
||||||
|
"idNumber" to "VOTER ID NUMBER? Reply only number or NONE.",
|
||||||
|
DocumentPrompts.ADDRESS,
|
||||||
|
DocumentPrompts.PIN_CODE,
|
||||||
|
DocumentPrompts.CITY,
|
||||||
|
DocumentPrompts.GENDER,
|
||||||
|
DocumentPrompts.NATIONALITY
|
||||||
|
)
|
||||||
|
for ((key, question) in voterQuestions) {
|
||||||
|
results[key] = askWithContext(ocrText, localImageUrl, question)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Detection(
|
||||||
|
detect = {
|
||||||
|
results["hasIncomeTaxDept"] = askWithContext(
|
||||||
|
ocrText,
|
||||||
|
localImageUrl,
|
||||||
|
"CONTAINS INCOME TAX DEPARTMENT? Answer YES or NO only."
|
||||||
|
)
|
||||||
|
isYes(results["hasIncomeTaxDept"])
|
||||||
|
},
|
||||||
|
handle = {
|
||||||
|
val panQuestions = linkedMapOf(
|
||||||
|
DocumentPrompts.NAME,
|
||||||
|
DocumentPrompts.DOB,
|
||||||
|
"idNumber" to "PAN NUMBER? Reply only number or NONE.",
|
||||||
|
DocumentPrompts.ADDRESS,
|
||||||
|
DocumentPrompts.PIN_CODE,
|
||||||
|
DocumentPrompts.CITY,
|
||||||
|
DocumentPrompts.GENDER,
|
||||||
|
DocumentPrompts.NATIONALITY
|
||||||
|
)
|
||||||
|
for ((key, question) in panQuestions) {
|
||||||
|
results[key] = askWithContext(ocrText, localImageUrl, question)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Detection(
|
||||||
|
detect = {
|
||||||
|
results["hasPassport"] = askWithContext(
|
||||||
|
ocrText,
|
||||||
|
localImageUrl,
|
||||||
|
"CONTAINS PASSPORT? Answer YES or NO only."
|
||||||
|
)
|
||||||
|
isYes(results["hasPassport"])
|
||||||
|
},
|
||||||
|
handle = {
|
||||||
|
val passportQuestions = linkedMapOf(
|
||||||
|
DocumentPrompts.NAME,
|
||||||
|
DocumentPrompts.DOB,
|
||||||
|
"idNumber" to "PASSPORT NUMBER? Reply only number or NONE.",
|
||||||
|
DocumentPrompts.ADDRESS,
|
||||||
|
DocumentPrompts.PIN_CODE,
|
||||||
|
DocumentPrompts.CITY,
|
||||||
|
DocumentPrompts.GENDER,
|
||||||
|
DocumentPrompts.NATIONALITY
|
||||||
|
)
|
||||||
|
for ((key, question) in passportQuestions) {
|
||||||
|
results[key] = askWithContext(ocrText, localImageUrl, question)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
var handled = false
|
||||||
|
for (detection in detections) {
|
||||||
|
if (detection.detect()) {
|
||||||
|
detection.handle()
|
||||||
|
handled = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!handled) {
|
||||||
|
val generalQuestions = linkedMapOf(
|
||||||
|
DocumentPrompts.NAME,
|
||||||
|
DocumentPrompts.DOB,
|
||||||
|
DocumentPrompts.ID_NUMBER,
|
||||||
|
DocumentPrompts.ADDRESS,
|
||||||
|
DocumentPrompts.VEHICLE_NUMBER,
|
||||||
|
DocumentPrompts.PIN_CODE,
|
||||||
|
DocumentPrompts.CITY,
|
||||||
|
DocumentPrompts.GENDER,
|
||||||
|
DocumentPrompts.NATIONALITY
|
||||||
|
)
|
||||||
|
for ((key, question) in generalQuestions) {
|
||||||
|
results[key] = askWithContext(ocrText, localImageUrl, question)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizePinCode(results)
|
||||||
|
logIdNumber("before-normalize-id", document.id, results)
|
||||||
|
normalizeIdNumber(results)
|
||||||
|
logIdNumber("after-normalize-id-digits", document.id, results)
|
||||||
|
normalizeAddress(results)
|
||||||
|
computeAgeIfDobPresent(results, propertyId)
|
||||||
|
applyBookingCityUpdates(document, results)
|
||||||
|
// Final Aadhaar checksum pass before doc type decision.
|
||||||
|
markAadhaarIfValid(results)
|
||||||
|
applyAadhaarVerificationAndMatching(document, results)
|
||||||
|
logIdNumber("after-aadhaar-checksum", document.id, results)
|
||||||
|
results["docType"] = computeDocType(results, handled)
|
||||||
|
applyGuestUpdates(document, propertyId, results)
|
||||||
|
return ExtractionResult(results, handled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isYes(value: String?): Boolean {
|
||||||
|
return value.orEmpty().contains("YES", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isLikelyVehicleNumber(value: String): Boolean {
|
||||||
|
val normalized = value.uppercase().replace(Regex("[\\s-]"), "")
|
||||||
|
if (normalized.length == 12 && normalized.all { it.isDigit() }) return false
|
||||||
|
if (normalized.length < 6) return false
|
||||||
|
return standardPlateRegex.matches(normalized) || bhPlateRegex.matches(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizePinCode(results: MutableMap<String, String>) {
|
||||||
|
val pinKey = DocumentPrompts.PIN_CODE.first
|
||||||
|
val rawPin = cleanedValue(results[pinKey])
|
||||||
|
val address = cleanedValue(results[DocumentPrompts.ADDRESS.first])
|
||||||
|
|
||||||
|
val fromPin = extractPinFromValue(rawPin)
|
||||||
|
val fromAddress = extractPinFromAddress(address)
|
||||||
|
|
||||||
|
val chosen = fromPin ?: fromAddress
|
||||||
|
results[pinKey] = if (isValidPin(chosen)) chosen!! else "NONE"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeIdNumber(results: MutableMap<String, String>) {
|
||||||
|
val idKey = DocumentPrompts.ID_NUMBER.first
|
||||||
|
val raw = cleanedValue(results[idKey])
|
||||||
|
val digits = normalizeDigits(raw)
|
||||||
|
if (digits != null && isValidAadhaar(digits)) {
|
||||||
|
results[idKey] = digits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeAddress(results: MutableMap<String, String>) {
|
||||||
|
val key = DocumentPrompts.ADDRESS.first
|
||||||
|
val raw = cleanedValue(results[key]) ?: return
|
||||||
|
val normalized = cleanAddress(raw) ?: return
|
||||||
|
results[key] = normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeAgeIfDobPresent(results: MutableMap<String, String>, propertyId: UUID) {
|
||||||
|
val dobRaw = cleanedValue(results[DocumentPrompts.DOB.first]) ?: return
|
||||||
|
val dob = parseDob(dobRaw) ?: return
|
||||||
|
val property = propertyRepo.findById(propertyId).orElse(null) ?: return
|
||||||
|
val zone = runCatching { ZoneId.of(property.timezone) }.getOrNull() ?: ZoneId.systemDefault()
|
||||||
|
val today = LocalDate.now(zone)
|
||||||
|
val years = Period.between(dob, today).years
|
||||||
|
if (years in 0..120) {
|
||||||
|
results["age"] = years.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun markAadhaarIfValid(results: MutableMap<String, String>) {
|
||||||
|
val idKey = DocumentPrompts.ID_NUMBER.first
|
||||||
|
val digits = normalizeDigits(cleanedValue(results[idKey]))
|
||||||
|
if (digits != null && isValidAadhaar(digits)) {
|
||||||
|
results["hasAadhar"] = "YES"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyAadhaarVerificationAndMatching(document: GuestDocument, results: MutableMap<String, String>) {
|
||||||
|
val bookingId = document.booking?.id ?: return
|
||||||
|
val idKey = DocumentPrompts.ID_NUMBER.first
|
||||||
|
val digits = normalizeDigits(cleanedValue(results[idKey]))
|
||||||
|
val hasDigits = digits != null && digits.length == 12
|
||||||
|
val isValid = hasDigits && isValidAadhaar(digits!!)
|
||||||
|
if (hasDigits) {
|
||||||
|
results["aadhaarVerified"] = if (isValid) "YES" else "NO"
|
||||||
|
}
|
||||||
|
|
||||||
|
val docs = guestDocumentRepo.findByBookingIdOrderByUploadedAtDesc(bookingId)
|
||||||
|
val verified = if (isValid) {
|
||||||
|
VerifiedAadhaar(document.id, digits!!)
|
||||||
|
} else {
|
||||||
|
docs.firstNotNullOfOrNull { existing ->
|
||||||
|
extractVerified(existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verified == null) return
|
||||||
|
|
||||||
|
if (!isValid && hasDigits) {
|
||||||
|
val match = computeAadhaarMatch(digits!!, verified.digits)
|
||||||
|
applyMatchResults(results, match, verified)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (existing in docs) {
|
||||||
|
if (existing.id == document.id) continue
|
||||||
|
val existingDigits = extractAadhaarDigits(existing) ?: continue
|
||||||
|
if (isValidAadhaar(existingDigits)) continue
|
||||||
|
val match = computeAadhaarMatch(existingDigits, verified.digits)
|
||||||
|
val updated = updateExtractedData(existing, match, verified)
|
||||||
|
if (updated) {
|
||||||
|
guestDocumentRepo.save(existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractVerified(document: GuestDocument): VerifiedAadhaar? {
|
||||||
|
val digits = extractAadhaarDigits(document) ?: return null
|
||||||
|
if (!isValidAadhaar(digits)) return null
|
||||||
|
return VerifiedAadhaar(document.id, digits)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractAadhaarDigits(document: GuestDocument): String? {
|
||||||
|
val raw = extractFromDocument(document, DocumentPrompts.ID_NUMBER.first) ?: return null
|
||||||
|
val digits = normalizeDigits(cleanedValue(raw)) ?: return null
|
||||||
|
return if (digits.length == 12) digits else null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractFromDocument(document: GuestDocument, key: String): String? {
|
||||||
|
val data = document.extractedData ?: return null
|
||||||
|
return try {
|
||||||
|
val parsed: Map<String, String> = objectMapper.readValue(
|
||||||
|
data,
|
||||||
|
object : TypeReference<Map<String, String>>() {}
|
||||||
|
)
|
||||||
|
parsed[key]
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateExtractedData(
|
||||||
|
document: GuestDocument,
|
||||||
|
match: AadhaarMatch,
|
||||||
|
verified: VerifiedAadhaar
|
||||||
|
): Boolean {
|
||||||
|
val raw = document.extractedData ?: return false
|
||||||
|
val parsed = try {
|
||||||
|
objectMapper.readValue(raw, object : TypeReference<MutableMap<String, String>>() {})
|
||||||
|
} catch (_: Exception) {
|
||||||
|
mutableMapOf()
|
||||||
|
}
|
||||||
|
val changed = applyMatchResults(parsed, match, verified)
|
||||||
|
if (!changed) return false
|
||||||
|
document.extractedData = objectMapper.writeValueAsString(parsed)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyMatchResults(
|
||||||
|
results: MutableMap<String, String>,
|
||||||
|
match: AadhaarMatch,
|
||||||
|
verified: VerifiedAadhaar
|
||||||
|
): Boolean {
|
||||||
|
var changed = false
|
||||||
|
val targetMasked = maskAadhaar(verified.digits)
|
||||||
|
changed = setIfChanged(results, "aadhaarMatchOrdered", match.ordered.toString()) || changed
|
||||||
|
changed = setIfChanged(results, "aadhaarMatchUnordered", match.unordered.toString()) || changed
|
||||||
|
changed = setIfChanged(results, "aadhaarMatchSimilar", if (match.similar) "YES" else "NO") || changed
|
||||||
|
changed = setIfChanged(results, "aadhaarMatchWith", targetMasked) || changed
|
||||||
|
verified.id?.let {
|
||||||
|
changed = setIfChanged(results, "aadhaarMatchWithDocId", it.toString()) || changed
|
||||||
|
}
|
||||||
|
if (match.similar) {
|
||||||
|
val formatted = formatAadhaar(verified.digits)
|
||||||
|
changed = setIfChanged(results, DocumentPrompts.ID_NUMBER.first, formatted) || changed
|
||||||
|
changed = setIfChanged(results, "aadhaarVerified", "YES") || changed
|
||||||
|
changed = setIfChanged(results, "hasAadhar", "YES") || changed
|
||||||
|
val recomputed = computeDocType(results, true)
|
||||||
|
changed = setIfChanged(results, "docType", recomputed) || changed
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setIfChanged(results: MutableMap<String, String>, key: String, value: String): Boolean {
|
||||||
|
val current = results[key]
|
||||||
|
if (current == value) return false
|
||||||
|
results[key] = value
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeDocType(results: Map<String, String>, handled: Boolean): String {
|
||||||
|
if (!handled && !(isYes(results["hasAadhar"]) || isYes(results["hasUidai"]))) {
|
||||||
|
return "GENERAL"
|
||||||
|
}
|
||||||
|
return when {
|
||||||
|
isYes(results["hasCourt"]) ||
|
||||||
|
isYes(results["hasHighCourt"]) ||
|
||||||
|
isYes(results["hasSupremeCourt"]) ||
|
||||||
|
isYes(results["hasJudiciary"]) -> "COURT_ID"
|
||||||
|
isYes(results["hasPolice"]) -> "POLICE_ID"
|
||||||
|
isYes(results["hasPassport"]) -> "PASSPORT"
|
||||||
|
isYes(results["hasTransportDept"]) ||
|
||||||
|
isYes(results["hasDrivingLicence"]) -> "TRANSPORT"
|
||||||
|
isYes(results["hasIncomeTaxDept"]) -> "PAN"
|
||||||
|
isYes(results["hasElectionCommission"]) -> "VOTER_ID"
|
||||||
|
isYes(results["hasAadhar"]) ||
|
||||||
|
isYes(results["hasUidai"]) -> {
|
||||||
|
if (isYes(results["hasAddress"])) "AADHAR_BACK" else "AADHAR_FRONT"
|
||||||
|
}
|
||||||
|
results["vehicleNumber"].orEmpty().isNotBlank() &&
|
||||||
|
!results["vehicleNumber"]!!.contains("NONE", true) -> "VEHICLE"
|
||||||
|
isYes(results["isVehiclePhoto"]) -> "VEHICLE_PHOTO"
|
||||||
|
else -> "UNKNOWN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureAadhaarId(
|
||||||
|
localImageUrl: String,
|
||||||
|
publicImageUrl: String,
|
||||||
|
document: GuestDocument,
|
||||||
|
results: MutableMap<String, String>,
|
||||||
|
ocrResult: PaddleOcrResult?,
|
||||||
|
ocrText: String?
|
||||||
|
) {
|
||||||
|
val key = DocumentPrompts.ID_NUMBER.first
|
||||||
|
val current = cleanedValue(results[key])
|
||||||
|
val normalized = normalizeDigits(current)
|
||||||
|
if (normalized != null && isValidAadhaar(normalized)) {
|
||||||
|
results[key] = normalized
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val retry = askWithContext(
|
||||||
|
ocrText,
|
||||||
|
localImageUrl,
|
||||||
|
"AADHAAR NUMBER (12 digits). Read extremely carefully. Reply ONLY the 12 digits or NONE."
|
||||||
|
)
|
||||||
|
val retryNormalized = normalizeDigits(cleanedValue(retry))
|
||||||
|
if (retryNormalized != null && isValidAadhaar(retryNormalized)) {
|
||||||
|
results[key] = retryNormalized
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ocrResult != null) {
|
||||||
|
val ocrCandidate = ocrResult.aadhaar
|
||||||
|
if (ocrCandidate != null) {
|
||||||
|
val ocrDigits = ocrCandidate.replace(" ", "")
|
||||||
|
if (isValidAadhaar(ocrDigits)) {
|
||||||
|
results[key] = ocrDigits
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ocrResult.texts.isNotEmpty()) {
|
||||||
|
val ocrText = ocrResult.texts.joinToString("\n")
|
||||||
|
val ocrAsk = askWithContext(
|
||||||
|
ocrText,
|
||||||
|
localImageUrl,
|
||||||
|
"AADHAAR NUMBER (12 digits). Reply ONLY the 12 digits or NONE."
|
||||||
|
)
|
||||||
|
val ocrAskNormalized = normalizeDigits(cleanedValue(ocrAsk))
|
||||||
|
if (ocrAskNormalized != null && isValidAadhaar(ocrAskNormalized)) {
|
||||||
|
results[key] = ocrAskNormalized
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn("Aadhaar retry failed; setting idNumber=NONE")
|
||||||
|
results[key] = "NONE"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun askWithContext(ocrText: String?, imageUrl: String, question: String): String {
|
||||||
|
return if (ocrText != null) {
|
||||||
|
llamaClient.askWithOcr(imageUrl, ocrText, question)
|
||||||
|
} else {
|
||||||
|
llamaClient.ask(imageUrl, question)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyGuestUpdates(
|
||||||
|
document: GuestDocument,
|
||||||
|
propertyId: UUID,
|
||||||
|
results: Map<String, String>
|
||||||
|
) {
|
||||||
|
val extractedName = cleanedValue(results[DocumentPrompts.NAME.first])
|
||||||
|
val extractedAddress = cleanedValue(results[DocumentPrompts.ADDRESS.first])
|
||||||
|
val extractedDob = cleanedValue(results[DocumentPrompts.DOB.first])
|
||||||
|
val resolvedCountry = if (results["geoResolved"] != null) "India" else null
|
||||||
|
val guestIdValue = document.guest.id
|
||||||
|
if (guestIdValue != null && (extractedName != null || extractedAddress != null)) {
|
||||||
|
val guestEntity = guestRepo.findById(guestIdValue).orElse(null)
|
||||||
|
if (guestEntity != null) {
|
||||||
|
var updated = false
|
||||||
|
if (guestEntity.name.isNullOrBlank() && extractedName != null) {
|
||||||
|
guestEntity.name = extractedName
|
||||||
|
updated = true
|
||||||
|
}
|
||||||
|
if (guestEntity.addressText.isNullOrBlank() && extractedAddress != null) {
|
||||||
|
guestEntity.addressText = extractedAddress
|
||||||
|
updated = true
|
||||||
|
}
|
||||||
|
if (guestEntity.age.isNullOrBlank() && extractedDob != null) {
|
||||||
|
val dob = parseDob(extractedDob)
|
||||||
|
if (dob != null) {
|
||||||
|
guestEntity.age = dob.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"))
|
||||||
|
updated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (guestEntity.nationality.isNullOrBlank() && resolvedCountry != null) {
|
||||||
|
guestEntity.nationality = resolvedCountry
|
||||||
|
updated = true
|
||||||
|
}
|
||||||
|
if (updated) {
|
||||||
|
guestEntity.updatedAt = OffsetDateTime.now()
|
||||||
|
guestRepo.save(guestEntity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val extractedVehicle = cleanedValue(results["vehicleNumber"])
|
||||||
|
if (isYes(results["isVehiclePhoto"]) && extractedVehicle != null) {
|
||||||
|
val guestIdSafe = document.guest.id
|
||||||
|
if (guestIdSafe != null &&
|
||||||
|
!guestVehicleRepo.existsByPropertyIdAndVehicleNumberIgnoreCase(propertyId, extractedVehicle)
|
||||||
|
) {
|
||||||
|
val property = propertyRepo.findById(propertyId).orElse(null)
|
||||||
|
val guestEntity = guestRepo.findById(guestIdSafe).orElse(null)
|
||||||
|
if (property != null && guestEntity != null) {
|
||||||
|
guestVehicleRepo.save(
|
||||||
|
GuestVehicle(
|
||||||
|
property = property,
|
||||||
|
guest = guestEntity,
|
||||||
|
booking = document.booking,
|
||||||
|
vehicleNumber = extractedVehicle
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyBookingCityUpdates(document: GuestDocument, results: MutableMap<String, String>) {
|
||||||
|
val bookingId = document.booking?.id ?: return
|
||||||
|
val booking = bookingRepo.findById(bookingId).orElse(null) ?: return
|
||||||
|
if (booking.fromCity?.isNotBlank() == true && booking.toCity?.isNotBlank() == true) return
|
||||||
|
val pin = cleanedValue(results[DocumentPrompts.PIN_CODE.first]) ?: return
|
||||||
|
if (!isValidPin(pin)) return
|
||||||
|
val resolvedResult = pincodeResolver.resolve(pin)
|
||||||
|
val primary = resolvedResult.primary
|
||||||
|
results["geoPrimarySource"] = primary.source
|
||||||
|
primary.status?.let { results["geoPrimaryStatus"] = it }
|
||||||
|
primary.rawResponse?.let { results["geoPrimaryResponse"] = it.take(4000) }
|
||||||
|
primary.errorMessage?.let { results["geoPrimaryError"] = it.take(300) }
|
||||||
|
primary.requestUrl?.let { results["geoPrimaryUrl"] = it.take(500) }
|
||||||
|
resolvedResult.secondary?.let { secondary ->
|
||||||
|
results["geoSecondarySource"] = secondary.source
|
||||||
|
secondary.status?.let { results["geoSecondaryStatus"] = it }
|
||||||
|
secondary.rawResponse?.let { results["geoSecondaryResponse"] = it.take(4000) }
|
||||||
|
secondary.errorMessage?.let { results["geoSecondaryError"] = it.take(300) }
|
||||||
|
secondary.requestUrl?.let { results["geoSecondaryUrl"] = it.take(500) }
|
||||||
|
}
|
||||||
|
resolvedResult.tertiary?.let { tertiary ->
|
||||||
|
results["geoTertiarySource"] = tertiary.source
|
||||||
|
tertiary.status?.let { results["geoTertiaryStatus"] = it }
|
||||||
|
tertiary.rawResponse?.let { results["geoTertiaryResponse"] = it.take(4000) }
|
||||||
|
tertiary.errorMessage?.let { results["geoTertiaryError"] = it.take(300) }
|
||||||
|
tertiary.requestUrl?.let { results["geoTertiaryUrl"] = it.take(500) }
|
||||||
|
}
|
||||||
|
resolvedResult.quaternary?.let { quaternary ->
|
||||||
|
results["geoQuaternarySource"] = quaternary.source
|
||||||
|
quaternary.status?.let { results["geoQuaternaryStatus"] = it }
|
||||||
|
quaternary.rawResponse?.let { results["geoQuaternaryResponse"] = it.take(4000) }
|
||||||
|
quaternary.errorMessage?.let { results["geoQuaternaryError"] = it.take(300) }
|
||||||
|
quaternary.requestUrl?.let { results["geoQuaternaryUrl"] = it.take(500) }
|
||||||
|
}
|
||||||
|
val resolved = resolvedResult.resolved()?.resolvedCityState ?: return
|
||||||
|
results["geoResolved"] = resolved
|
||||||
|
results["geoSource"] = resolvedResult.resolved()?.source ?: ""
|
||||||
|
var updated = false
|
||||||
|
if (booking.fromCity.isNullOrBlank()) {
|
||||||
|
booking.fromCity = resolved
|
||||||
|
updated = true
|
||||||
|
}
|
||||||
|
if (booking.toCity.isNullOrBlank()) {
|
||||||
|
booking.toCity = resolved
|
||||||
|
updated = true
|
||||||
|
}
|
||||||
|
if (updated) {
|
||||||
|
booking.updatedAt = OffsetDateTime.now()
|
||||||
|
bookingRepo.save(booking)
|
||||||
|
val propertyId = booking.property.id ?: return
|
||||||
|
val bookingId = booking.id ?: return
|
||||||
|
bookingEvents.emit(propertyId, bookingId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ExtractionResult(
|
||||||
|
val results: LinkedHashMap<String, String>,
|
||||||
|
val handled: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class Detection(
|
||||||
|
val detect: () -> Boolean,
|
||||||
|
val handle: () -> Unit
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun cleanedValue(value: String?): String? {
|
||||||
|
val trimmed = value?.trim().orEmpty()
|
||||||
|
if (trimmed.isBlank()) return null
|
||||||
|
val upper = trimmed.uppercase()
|
||||||
|
if (upper == "NONE" || upper == "N/A" || upper == "NA" || upper == "NULL") return null
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maskAadhaar(value: String): String {
|
||||||
|
val digits = value.filter { it.isDigit() }
|
||||||
|
if (digits.length != 12) return value
|
||||||
|
return "XXXXXXXX" + digits.takeLast(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logIdNumber(stage: String, documentId: UUID?, results: Map<String, String>) {
|
||||||
|
val raw = results[DocumentPrompts.ID_NUMBER.first]
|
||||||
|
val digits = normalizeDigits(cleanedValue(raw))
|
||||||
|
val masked = digits?.let { maskAadhaar(it) } ?: raw
|
||||||
|
LoggerFactory.getLogger(DocumentExtractionService::class.java).debug(
|
||||||
|
"ID number {} docId={} raw={} normalized={}",
|
||||||
|
stage,
|
||||||
|
documentId,
|
||||||
|
raw,
|
||||||
|
masked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val standardPlateRegex = Regex("^[A-Z]{2}\\d{1,2}[A-Z]{1,3}\\d{3,4}$")
|
||||||
|
private val bhPlateRegex = Regex("^\\d{2}BH\\d{4}[A-Z]{1,2}$")
|
||||||
|
private val pinCodeRegex = Regex("\\b\\d{6}\\b")
|
||||||
|
private data class AadhaarMatch(
|
||||||
|
val ordered: Int,
|
||||||
|
val unordered: Int,
|
||||||
|
val similar: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class VerifiedAadhaar(
|
||||||
|
val id: UUID?,
|
||||||
|
val digits: String
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun computeAadhaarMatch(candidate: String, verified: String): AadhaarMatch {
|
||||||
|
val ordered = candidate.zip(verified).count { it.first == it.second }
|
||||||
|
val unordered = unorderedMatchCount(candidate, verified)
|
||||||
|
val similar = ordered >= 8 || unordered >= 8
|
||||||
|
return AadhaarMatch(ordered, unordered, similar)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unorderedMatchCount(a: String, b: String): Int {
|
||||||
|
val countsA = IntArray(10)
|
||||||
|
val countsB = IntArray(10)
|
||||||
|
a.forEach { if (it.isDigit()) countsA[it - '0']++ }
|
||||||
|
b.forEach { if (it.isDigit()) countsB[it - '0']++ }
|
||||||
|
var total = 0
|
||||||
|
for (i in 0..9) {
|
||||||
|
total += minOf(countsA[i], countsB[i])
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDob(value: String): LocalDate? {
|
||||||
|
val cleaned = value.trim().replace(Regex("\\s+"), "")
|
||||||
|
val formatters = listOf(
|
||||||
|
DateTimeFormatterBuilder().parseCaseInsensitive().appendPattern("dd/MM/uuuu").toFormatter(),
|
||||||
|
DateTimeFormatterBuilder().parseCaseInsensitive().appendPattern("dd-MM-uuuu").toFormatter(),
|
||||||
|
DateTimeFormatterBuilder().parseCaseInsensitive().appendPattern("uuuu-MM-dd").toFormatter(),
|
||||||
|
DateTimeFormatterBuilder().parseCaseInsensitive().appendPattern("uuuu/MM/dd").toFormatter()
|
||||||
|
).map { it.withResolverStyle(ResolverStyle.STRICT) }
|
||||||
|
|
||||||
|
for (formatter in formatters) {
|
||||||
|
try {
|
||||||
|
return LocalDate.parse(cleaned, formatter)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
private fun extractPinFromValue(value: String?): String? {
|
||||||
|
if (value.isNullOrBlank()) return null
|
||||||
|
val compact = value.replace(Regex("\\s+"), "")
|
||||||
|
if (compact.length == 12 && compact.all { it.isDigit() }) return null
|
||||||
|
val match = pinCodeRegex.find(value) ?: return null
|
||||||
|
return match.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractPinFromAddress(value: String?): String? {
|
||||||
|
if (value.isNullOrBlank()) return null
|
||||||
|
val hasPinLabel = value.contains("PIN", ignoreCase = true) || value.contains("PINCODE", ignoreCase = true)
|
||||||
|
if (!hasPinLabel) return null
|
||||||
|
val match = pinCodeRegex.find(value) ?: return null
|
||||||
|
return match.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeDigits(value: String?): String? {
|
||||||
|
if (value.isNullOrBlank()) return null
|
||||||
|
val digits = value.filter { it.isDigit() }
|
||||||
|
return digits.ifBlank { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isValidPin(value: String?): Boolean {
|
||||||
|
if (value.isNullOrBlank()) return false
|
||||||
|
return pinCodeRegex.matches(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cleanAddress(raw: String): String? {
|
||||||
|
val relationRegex = Regex("^\\s*(S/O|D/O|W/O|C/O|H/O|F/O)\\b", RegexOption.IGNORE_CASE)
|
||||||
|
val prefixRegexes = listOf(
|
||||||
|
Regex("^\\s*ADDRESS\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*HOUSE\\s*/\\s*BLDG\\.?\\s*/\\s*APT\\.?\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*HOUSE-?BLDG\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*HOUSE\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*H\\s*NO\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*HOUSE\\s*NO\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*STREET/ROAD/LANE\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*STREET\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*ROAD\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*LANE\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*AREA\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*SECTOR\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*COLONY\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*VILLAGE/TOWN/CITY\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*VILLAGE\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*TOWN\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*CITY\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*P\\.?\\s*O\\.?\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*POST\\s*OFFICE\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*P\\.?\\s*DIST\\.?\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*DISTRICT\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*DIST\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*STATE\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*PIN\\s*CODE\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*PINCODE\\b[:\\-\\s]*", RegexOption.IGNORE_CASE),
|
||||||
|
Regex("^\\s*PIN\\b[:\\-\\s]*", RegexOption.IGNORE_CASE)
|
||||||
|
)
|
||||||
|
val dropPhrases = setOf(
|
||||||
|
"address", "addr", "area", "area was", "street/road/lane", "village/town/city", "colony"
|
||||||
|
)
|
||||||
|
val parts = raw.replace("\n", ",").split(",")
|
||||||
|
val cleanedParts = mutableListOf<String>()
|
||||||
|
for (part in parts) {
|
||||||
|
var value = part.trim()
|
||||||
|
if (value.isBlank()) continue
|
||||||
|
if (relationRegex.containsMatchIn(value)) continue
|
||||||
|
for (regex in prefixRegexes) {
|
||||||
|
value = regex.replace(value, "").trim()
|
||||||
|
}
|
||||||
|
value = value.replace(Regex("\\s+"), " ").trim()
|
||||||
|
if (value.isBlank()) continue
|
||||||
|
if (value.length < 3) continue
|
||||||
|
if (dropPhrases.contains(value.lowercase())) continue
|
||||||
|
cleanedParts.add(value)
|
||||||
|
}
|
||||||
|
return if (cleanedParts.isEmpty()) null else cleanedParts.joinToString(", ")
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.android.trisolarisserver.component
|
package com.android.trisolarisserver.component.document
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.android.trisolarisserver.component.document
|
||||||
|
import com.android.trisolarisserver.component.sse.SseHub
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.controller.guest.GuestDocumentResponse
|
||||||
|
import com.android.trisolarisserver.controller.guest.toResponse
|
||||||
|
import com.android.trisolarisserver.repo.guest.GuestDocumentRepo
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class GuestDocumentEvents(
|
||||||
|
private val guestDocumentRepo: GuestDocumentRepo,
|
||||||
|
private val objectMapper: ObjectMapper
|
||||||
|
) {
|
||||||
|
private val hub = SseHub<GuestDocKey>("guest-documents") { key ->
|
||||||
|
buildSnapshot(key.propertyId, key.guestId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun subscribe(propertyId: UUID, guestId: UUID): SseEmitter {
|
||||||
|
val key = GuestDocKey(propertyId, guestId)
|
||||||
|
return hub.subscribe(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emit(propertyId: UUID, guestId: UUID) {
|
||||||
|
val key = GuestDocKey(propertyId, guestId)
|
||||||
|
hub.emit(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "25000")
|
||||||
|
fun heartbeat() {
|
||||||
|
hub.heartbeat()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildSnapshot(propertyId: UUID, guestId: UUID): List<GuestDocumentResponse> {
|
||||||
|
return guestDocumentRepo
|
||||||
|
.findByPropertyIdAndGuestIdOrderByUploadedAtDesc(propertyId, guestId)
|
||||||
|
.map { it.toResponse(objectMapper) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class GuestDocKey(
|
||||||
|
val propertyId: UUID,
|
||||||
|
val guestId: UUID
|
||||||
|
)
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.android.trisolarisserver.component.geo
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.web.client.RestTemplate
|
||||||
|
import org.springframework.web.util.UriComponentsBuilder
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class DataGovPincodeClient(
|
||||||
|
private val restTemplate: RestTemplate,
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
|
@Value("\${pincode.datagov.apiKey:}")
|
||||||
|
private val apiKey: String,
|
||||||
|
@Value("\${pincode.datagov.baseUrl:https://api.data.gov.in/resource/5c2f62fe-5afa-4119-a499-fec9d604d5bd}")
|
||||||
|
private val baseUrl: String
|
||||||
|
) {
|
||||||
|
private val logger = LoggerFactory.getLogger(DataGovPincodeClient::class.java)
|
||||||
|
|
||||||
|
fun resolve(pinCode: String): PincodeLookupResult {
|
||||||
|
if (apiKey.isBlank()) return PincodeLookupResult(null, null, "NO_API_KEY", "data.gov.in", "Missing API key")
|
||||||
|
return try {
|
||||||
|
fetch(pinCode)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
logger.warn("Data.gov.in lookup failed: {}", ex.message)
|
||||||
|
PincodeLookupResult(null, null, "ERROR", "data.gov.in", ex.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetch(pinCodeValue: String): PincodeLookupResult {
|
||||||
|
val url = UriComponentsBuilder.fromUriString(baseUrl)
|
||||||
|
.queryParam("api-key", apiKey)
|
||||||
|
.queryParam("format", "json")
|
||||||
|
.queryParam("filters[pincode]", pinCodeValue)
|
||||||
|
.queryParam("offset", "0")
|
||||||
|
.queryParam("limit", "100")
|
||||||
|
.toUriString()
|
||||||
|
return try {
|
||||||
|
val response = restTemplate.getForEntity(url, String::class.java)
|
||||||
|
val body = response.body ?: return PincodeLookupResult(null, null, "EMPTY_BODY", "data.gov.in", requestUrl = url)
|
||||||
|
val parsed = parseCityState(body, pinCodeValue)
|
||||||
|
val status = when {
|
||||||
|
parsed != null -> "OK"
|
||||||
|
isFilterMismatch(body, pinCodeValue) -> "FILTER_MISMATCH"
|
||||||
|
else -> "ZERO_RESULTS"
|
||||||
|
}
|
||||||
|
val error = if (status == "FILTER_MISMATCH") "Records did not match pin filter" else null
|
||||||
|
PincodeLookupResult(parsed, body, status, "data.gov.in", error, url)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
PincodeLookupResult(null, null, "ERROR", "data.gov.in", ex.message, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseCityState(body: String, pinCodeValue: String): String? {
|
||||||
|
val root = objectMapper.readTree(body)
|
||||||
|
val records = root.path("records")
|
||||||
|
if (!records.isArray || records.isEmpty) return null
|
||||||
|
val filtered = records.filter { record ->
|
||||||
|
val recordPin = record.path("pincode").asText(null)
|
||||||
|
recordPin?.trim() == pinCodeValue
|
||||||
|
}
|
||||||
|
if (filtered.isEmpty()) return null
|
||||||
|
val chosen = chooseRecord(filtered) ?: return null
|
||||||
|
val district = chosen.path("district").asText(null)
|
||||||
|
val state = chosen.path("statename").asText(null)
|
||||||
|
val districtName = district?.let { toTitleCase(it) }
|
||||||
|
val stateName = state?.let { toTitleCase(it) }
|
||||||
|
if (districtName.isNullOrBlank() && stateName.isNullOrBlank()) return null
|
||||||
|
return listOfNotNull(districtName?.ifBlank { null }, stateName?.ifBlank { null }).joinToString(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chooseRecord(records: List<JsonNode>): JsonNode? {
|
||||||
|
val delivery = records.firstOrNull { it.path("delivery").asText("").equals("Delivery", true) }
|
||||||
|
return delivery ?: records.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isFilterMismatch(body: String, pinCodeValue: String): Boolean {
|
||||||
|
val root = objectMapper.readTree(body)
|
||||||
|
val records = root.path("records")
|
||||||
|
if (!records.isArray || records.isEmpty) return false
|
||||||
|
val anyMatch = records.any { record ->
|
||||||
|
val recordPin = record.path("pincode").asText(null)
|
||||||
|
recordPin?.trim() == pinCodeValue
|
||||||
|
}
|
||||||
|
return !anyMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toTitleCase(value: String): String {
|
||||||
|
return value.lowercase().split(Regex("\\s+")).joinToString(" ") { word ->
|
||||||
|
word.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package com.android.trisolarisserver.component.geo
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.web.client.RestTemplate
|
||||||
|
import org.springframework.web.util.UriComponentsBuilder
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class GoogleGeocodingClient(
|
||||||
|
private val restTemplate: RestTemplate,
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
|
@Value("\${google.maps.apiKey:}")
|
||||||
|
private val apiKey: String,
|
||||||
|
@Value("\${google.maps.geocode.baseUrl:https://maps.googleapis.com/maps/api/geocode/json}")
|
||||||
|
private val baseUrl: String
|
||||||
|
) {
|
||||||
|
private val logger = LoggerFactory.getLogger(GoogleGeocodingClient::class.java)
|
||||||
|
|
||||||
|
fun resolveCityState(pinCode: String): GeocodeResult {
|
||||||
|
if (apiKey.isBlank()) {
|
||||||
|
return GeocodeResult(null, null, "NO_API_KEY")
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
val url = UriComponentsBuilder.fromUriString(baseUrl)
|
||||||
|
.queryParam("components", "postal_code:$pinCode|country:IN")
|
||||||
|
.queryParam("region", "IN")
|
||||||
|
.queryParam("key", apiKey)
|
||||||
|
.toUriString()
|
||||||
|
val primary = fetch(url)
|
||||||
|
if (primary.status == "OK") {
|
||||||
|
return primary
|
||||||
|
}
|
||||||
|
if (primary.status == "ZERO_RESULTS") {
|
||||||
|
val fallbackUrl = UriComponentsBuilder.fromUriString(baseUrl)
|
||||||
|
.queryParam("address", "$pinCode India")
|
||||||
|
.queryParam("region", "IN")
|
||||||
|
.queryParam("key", apiKey)
|
||||||
|
.toUriString()
|
||||||
|
val fallback = fetch(fallbackUrl)
|
||||||
|
if (fallback.resolvedCityState != null) return fallback
|
||||||
|
return primary
|
||||||
|
}
|
||||||
|
primary
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
logger.warn("Geocoding failed: {}", ex.message)
|
||||||
|
GeocodeResult(null, null, "ERROR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetch(url: String): GeocodeResult {
|
||||||
|
val response = restTemplate.getForEntity(url, String::class.java)
|
||||||
|
val body = response.body ?: return GeocodeResult(null, null, "EMPTY_BODY")
|
||||||
|
val status = parseStatus(body)
|
||||||
|
val parsed = if (status == "OK") parseCityState(body) else null
|
||||||
|
return GeocodeResult(parsed, body, status ?: "UNKNOWN_STATUS")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseStatus(body: String): String? {
|
||||||
|
return objectMapper.readTree(body).path("status").asText(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseCityState(body: String): String? {
|
||||||
|
val root = objectMapper.readTree(body)
|
||||||
|
val results = root.path("results")
|
||||||
|
if (!results.isArray || results.isEmpty) return null
|
||||||
|
val resultNode = results.firstOrNull { node ->
|
||||||
|
node.path("types").any { it.asText(null) == "postal_code" }
|
||||||
|
} ?: results.first()
|
||||||
|
val components = resultNode.path("address_components")
|
||||||
|
if (!components.isArray) return null
|
||||||
|
|
||||||
|
var city: String? = null
|
||||||
|
var admin2: String? = null
|
||||||
|
var state: String? = null
|
||||||
|
for (comp in components) {
|
||||||
|
val types = comp.path("types").mapNotNull { it.asText(null) }.toSet()
|
||||||
|
when {
|
||||||
|
"locality" in types -> city = comp.path("long_name").asText(null) ?: city
|
||||||
|
"postal_town" in types -> if (city == null) {
|
||||||
|
city = comp.path("long_name").asText(null)
|
||||||
|
}
|
||||||
|
"sublocality" in types -> if (city == null) {
|
||||||
|
city = comp.path("long_name").asText(null)
|
||||||
|
}
|
||||||
|
"administrative_area_level_2" in types -> admin2 = comp.path("long_name").asText(null) ?: admin2
|
||||||
|
"administrative_area_level_1" in types -> state = comp.path("long_name").asText(null) ?: state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val preferredCity = admin2?.trim()?.ifBlank { null } ?: city?.trim()?.ifBlank { null }
|
||||||
|
if (preferredCity == null && state.isNullOrBlank()) return null
|
||||||
|
return listOfNotNull(preferredCity, state?.trim()?.ifBlank { null }).joinToString(", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class GeocodeResult(
|
||||||
|
val resolvedCityState: String?,
|
||||||
|
val rawResponse: String?,
|
||||||
|
val status: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PincodeLookupResult(
|
||||||
|
val resolvedCityState: String?,
|
||||||
|
val rawResponse: String?,
|
||||||
|
val status: String?,
|
||||||
|
val source: String,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val requestUrl: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package com.android.trisolarisserver.component.geo
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.repo.property.IndiaPincodeCityStateRepo
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.data.domain.PageRequest
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class PincodeResolver(
|
||||||
|
private val indiaPincodeCityStateRepo: IndiaPincodeCityStateRepo,
|
||||||
|
private val dataGovPincodeClient: DataGovPincodeClient,
|
||||||
|
private val postalPincodeClient: PostalPincodeClient,
|
||||||
|
private val googleGeocodingClient: GoogleGeocodingClient
|
||||||
|
) {
|
||||||
|
private val logger = LoggerFactory.getLogger(PincodeResolver::class.java)
|
||||||
|
|
||||||
|
fun resolve(pinCode: String): PincodeResolveResult {
|
||||||
|
val primary = resolveFromLocalDb(pinCode)
|
||||||
|
if (primary.status == "OK" && primary.resolvedCityState != null) {
|
||||||
|
return PincodeResolveResult(primary, null, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val secondary = dataGovPincodeClient.resolve(pinCode)
|
||||||
|
if (secondary.status == "OK" && secondary.resolvedCityState != null) {
|
||||||
|
return PincodeResolveResult(primary, secondary, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val tertiary = postalPincodeClient.resolve(pinCode)
|
||||||
|
if (tertiary.status == "OK" && tertiary.resolvedCityState != null) {
|
||||||
|
return PincodeResolveResult(primary, secondary, tertiary, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val google = googleGeocodingClient.resolveCityState(pinCode)
|
||||||
|
val quaternary = PincodeLookupResult(
|
||||||
|
google.resolvedCityState,
|
||||||
|
google.rawResponse,
|
||||||
|
google.status,
|
||||||
|
"google"
|
||||||
|
)
|
||||||
|
return PincodeResolveResult(primary, secondary, tertiary, quaternary)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveFromLocalDb(pinCode: String): PincodeLookupResult {
|
||||||
|
val normalizedPin = pinCode.trim()
|
||||||
|
if (!PIN_REGEX.matches(normalizedPin)) {
|
||||||
|
return PincodeLookupResult(
|
||||||
|
resolvedCityState = null,
|
||||||
|
rawResponse = null,
|
||||||
|
status = "INVALID_PIN",
|
||||||
|
source = "local-db",
|
||||||
|
errorMessage = "PIN must be 6 digits"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val candidate = indiaPincodeCityStateRepo
|
||||||
|
.findCityStateCandidates(normalizedPin.toInt(), PageRequest.of(0, 1))
|
||||||
|
.firstOrNull()
|
||||||
|
if (candidate != null) {
|
||||||
|
PincodeLookupResult(
|
||||||
|
resolvedCityState = "${candidate.city}, ${candidate.state}",
|
||||||
|
rawResponse = null,
|
||||||
|
status = "OK",
|
||||||
|
source = "local-db"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
PincodeLookupResult(
|
||||||
|
resolvedCityState = null,
|
||||||
|
rawResponse = null,
|
||||||
|
status = "ZERO_RESULTS",
|
||||||
|
source = "local-db"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
logger.warn("Local pincode lookup failed: {}", ex.message)
|
||||||
|
PincodeLookupResult(
|
||||||
|
resolvedCityState = null,
|
||||||
|
rawResponse = null,
|
||||||
|
status = "ERROR",
|
||||||
|
source = "local-db",
|
||||||
|
errorMessage = ex.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val PIN_REGEX = Regex("\\d{6}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PincodeResolveResult(
|
||||||
|
val primary: PincodeLookupResult,
|
||||||
|
val secondary: PincodeLookupResult?,
|
||||||
|
val tertiary: PincodeLookupResult?,
|
||||||
|
val quaternary: PincodeLookupResult?
|
||||||
|
) {
|
||||||
|
fun resolved(): PincodeLookupResult? {
|
||||||
|
return sequenceOf(primary, secondary, tertiary, quaternary).firstOrNull { it?.resolvedCityState != null }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.android.trisolarisserver.component.geo
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.http.HttpEntity
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.HttpMethod
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.web.client.RestTemplate
|
||||||
|
import org.springframework.web.util.UriComponentsBuilder
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class PostalPincodeClient(
|
||||||
|
private val restTemplate: RestTemplate,
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
|
@Value("\${pincode.postal.baseUrl:https://api.postalpincode.in}")
|
||||||
|
private val baseUrl: String
|
||||||
|
) {
|
||||||
|
private val logger = LoggerFactory.getLogger(PostalPincodeClient::class.java)
|
||||||
|
|
||||||
|
fun resolve(pinCode: String): PincodeLookupResult {
|
||||||
|
val first = fetch(baseUrl, pinCode)
|
||||||
|
if (first.resolvedCityState != null) return first
|
||||||
|
if (first.status == "ERROR" && baseUrl.startsWith("https://")) {
|
||||||
|
val httpUrl = baseUrl.replaceFirst("https://", "http://")
|
||||||
|
val second = fetch(httpUrl, pinCode)
|
||||||
|
if (second.resolvedCityState != null) return second
|
||||||
|
return second
|
||||||
|
}
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetch(base: String, pinCode: String): PincodeLookupResult {
|
||||||
|
val url = UriComponentsBuilder.fromUriString(base)
|
||||||
|
.path("/pincode/{pin}")
|
||||||
|
.buildAndExpand(pinCode)
|
||||||
|
.toUriString()
|
||||||
|
val headers = HttpHeaders().apply {
|
||||||
|
set("User-Agent", "Mozilla/5.0 (TrisolarisServer)")
|
||||||
|
set("Accept", "application/json")
|
||||||
|
set("Connection", "close")
|
||||||
|
}
|
||||||
|
val entity = HttpEntity<Unit>(headers)
|
||||||
|
var lastError: Exception? = null
|
||||||
|
repeat(2) {
|
||||||
|
try {
|
||||||
|
val response = restTemplate.exchange(url, HttpMethod.GET, entity, String::class.java)
|
||||||
|
val body = response.body ?: return PincodeLookupResult(null, null, "EMPTY_BODY", "postalpincode.in", requestUrl = url)
|
||||||
|
val resolved = parseCityState(body)
|
||||||
|
val status = if (resolved == null) "ZERO_RESULTS" else "OK"
|
||||||
|
return PincodeLookupResult(resolved, body, status, "postalpincode.in", requestUrl = url)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
lastError = ex
|
||||||
|
try {
|
||||||
|
Thread.sleep(200)
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val errorMessage = lastError?.message
|
||||||
|
if (errorMessage != null) {
|
||||||
|
logger.warn("Postalpincode lookup failed: {}", errorMessage)
|
||||||
|
}
|
||||||
|
return PincodeLookupResult(null, null, "ERROR", "postalpincode.in", errorMessage, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseCityState(body: String): String? {
|
||||||
|
val root = objectMapper.readTree(body)
|
||||||
|
if (!root.isArray || root.isEmpty) return null
|
||||||
|
val first = root.first()
|
||||||
|
val status = first.path("Status").asText(null)
|
||||||
|
if (!status.equals("Success", true)) return null
|
||||||
|
val offices = first.path("PostOffice")
|
||||||
|
if (!offices.isArray || offices.isEmpty) return null
|
||||||
|
val office = chooseOffice(offices) ?: return null
|
||||||
|
val district = office.path("District").asText(null)
|
||||||
|
val state = office.path("State").asText(null)
|
||||||
|
if (district.isNullOrBlank() && state.isNullOrBlank()) return null
|
||||||
|
return listOfNotNull(district?.ifBlank { null }, state?.ifBlank { null }).joinToString(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chooseOffice(offices: JsonNode): JsonNode? {
|
||||||
|
val delivery = offices.firstOrNull { it.path("DeliveryStatus").asText("").equals("Delivery", true) }
|
||||||
|
return delivery ?: offices.firstOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.android.trisolarisserver.component.razorpay
|
||||||
|
import com.android.trisolarisserver.component.sse.SseHub
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayQrEventResponse
|
||||||
|
import com.android.trisolarisserver.repo.razorpay.RazorpayQrRequestRepo
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class RazorpayQrEvents(
|
||||||
|
private val qrRequestRepo: RazorpayQrRequestRepo
|
||||||
|
) {
|
||||||
|
private val latestEvents: MutableMap<QrKey, RazorpayQrEventResponse> = ConcurrentHashMap()
|
||||||
|
private val hub = SseHub<QrKey>("qr") { key ->
|
||||||
|
latestEvents[key] ?: run {
|
||||||
|
val latest = qrRequestRepo.findTopByQrIdOrderByCreatedAtDesc(key.qrId)
|
||||||
|
RazorpayQrEventResponse(
|
||||||
|
event = "snapshot",
|
||||||
|
qrId = key.qrId,
|
||||||
|
status = latest?.status,
|
||||||
|
receivedAt = (latest?.createdAt ?: OffsetDateTime.now()).toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun subscribe(propertyId: UUID, qrId: String): SseEmitter {
|
||||||
|
return hub.subscribe(QrKey(propertyId, qrId))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emit(propertyId: UUID, qrId: String, event: RazorpayQrEventResponse) {
|
||||||
|
val key = QrKey(propertyId, qrId)
|
||||||
|
latestEvents[key] = event
|
||||||
|
hub.emit(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "25000")
|
||||||
|
fun heartbeat() {
|
||||||
|
hub.heartbeat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class QrKey(
|
||||||
|
val propertyId: UUID,
|
||||||
|
val qrId: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.android.trisolarisserver.component.room
|
||||||
|
import com.android.trisolarisserver.component.sse.SseHub
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.controller.dto.room.RoomBoardResponse
|
||||||
|
import com.android.trisolarisserver.controller.dto.room.RoomBoardStatus
|
||||||
|
import com.android.trisolarisserver.repo.room.RoomRepo
|
||||||
|
import com.android.trisolarisserver.repo.room.RoomStayRepo
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class RoomBoardEvents(
|
||||||
|
private val roomRepo: RoomRepo,
|
||||||
|
private val roomStayRepo: RoomStayRepo
|
||||||
|
) {
|
||||||
|
private val hub = SseHub<UUID>("room-board") { propertyId ->
|
||||||
|
buildSnapshot(propertyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun subscribe(propertyId: UUID): SseEmitter {
|
||||||
|
return hub.subscribe(propertyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emit(propertyId: UUID) {
|
||||||
|
hub.emit(propertyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "25000")
|
||||||
|
fun heartbeat() {
|
||||||
|
hub.heartbeat()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildSnapshot(propertyId: UUID): List<RoomBoardResponse> {
|
||||||
|
val rooms = roomRepo.findByPropertyIdOrderByRoomNumber(propertyId)
|
||||||
|
val occupiedRoomIds = roomStayRepo.findOccupiedRoomIds(propertyId).toHashSet()
|
||||||
|
return rooms.map { room ->
|
||||||
|
val status = when {
|
||||||
|
room.maintenance -> RoomBoardStatus.MAINTENANCE
|
||||||
|
!room.active -> RoomBoardStatus.INACTIVE
|
||||||
|
occupiedRoomIds.contains(room.id) -> RoomBoardStatus.OCCUPIED
|
||||||
|
else -> RoomBoardStatus.FREE
|
||||||
|
}
|
||||||
|
RoomBoardResponse(
|
||||||
|
roomNumber = room.roomNumber,
|
||||||
|
roomTypeName = room.roomType.name,
|
||||||
|
status = status
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.android.trisolarisserver.component.sse
|
||||||
|
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
|
class SseHub<K>(
|
||||||
|
private val eventName: String,
|
||||||
|
private val snapshot: (K) -> Any
|
||||||
|
) {
|
||||||
|
private val emitters: MutableMap<K, CopyOnWriteArrayList<SseEmitter>> = ConcurrentHashMap()
|
||||||
|
|
||||||
|
fun subscribe(key: K): SseEmitter {
|
||||||
|
val emitter = SseEmitter(0L)
|
||||||
|
emitters.computeIfAbsent(key) { CopyOnWriteArrayList() }.add(emitter)
|
||||||
|
emitter.onCompletion { emitters[key]?.remove(emitter) }
|
||||||
|
emitter.onTimeout { emitters[key]?.remove(emitter) }
|
||||||
|
emitter.onError { emitters[key]?.remove(emitter) }
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event().name(eventName).data(snapshot(key)))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emitters[key]?.remove(emitter)
|
||||||
|
}
|
||||||
|
return emitter
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emit(key: K) {
|
||||||
|
val list = emitters[key] ?: return
|
||||||
|
val data = snapshot(key)
|
||||||
|
val dead = mutableListOf<SseEmitter>()
|
||||||
|
for (emitter in list) {
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event().name(eventName).data(data))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
dead.add(emitter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dead.isNotEmpty()) {
|
||||||
|
list.removeAll(dead.toSet())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun heartbeat() {
|
||||||
|
emitters.forEach { (_, list) ->
|
||||||
|
val dead = mutableListOf<SseEmitter>()
|
||||||
|
for (emitter in list) {
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event().name("ping").data("ok"))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
dead.add(emitter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dead.isNotEmpty()) {
|
||||||
|
list.removeAll(dead.toSet())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.android.trisolarisserver.component
|
package com.android.trisolarisserver.component.storage
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.android.trisolarisserver.component
|
package com.android.trisolarisserver.component.storage
|
||||||
|
|
||||||
import org.apache.pdfbox.pdmodel.PDDocument
|
import org.apache.pdfbox.pdmodel.PDDocument
|
||||||
import org.apache.pdfbox.pdmodel.PDPage
|
import org.apache.pdfbox.pdmodel.PDPage
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.android.trisolarisserver.component
|
package com.android.trisolarisserver.component.storage
|
||||||
|
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.android.trisolarisserver.component
|
package com.android.trisolarisserver.component.storage
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.android.trisolarisserver.component
|
package com.android.trisolarisserver.component.storage
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package com.android.trisolarisserver.config
|
|
||||||
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class BookingSchemaFix(
|
|
||||||
private val jdbcTemplate: JdbcTemplate
|
|
||||||
) : PostgresSchemaFix(jdbcTemplate) {
|
|
||||||
|
|
||||||
override fun runPostgres(jdbcTemplate: JdbcTemplate) {
|
|
||||||
val hasExpectedGuestCount = jdbcTemplate.queryForObject(
|
|
||||||
"""
|
|
||||||
select count(*)
|
|
||||||
from information_schema.columns
|
|
||||||
where table_name = 'booking'
|
|
||||||
and column_name = 'expected_guest_count'
|
|
||||||
""".trimIndent(),
|
|
||||||
Int::class.java
|
|
||||||
) ?: 0
|
|
||||||
|
|
||||||
if (hasExpectedGuestCount == 0) {
|
|
||||||
logger.info("Adding booking.expected_guest_count column")
|
|
||||||
jdbcTemplate.execute("alter table booking add column expected_guest_count integer")
|
|
||||||
}
|
|
||||||
|
|
||||||
val hasFromCity = jdbcTemplate.queryForObject(
|
|
||||||
"""
|
|
||||||
select count(*)
|
|
||||||
from information_schema.columns
|
|
||||||
where table_name = 'booking'
|
|
||||||
and column_name = 'from_city'
|
|
||||||
""".trimIndent(),
|
|
||||||
Int::class.java
|
|
||||||
) ?: 0
|
|
||||||
if (hasFromCity == 0) {
|
|
||||||
logger.info("Adding booking.from_city column")
|
|
||||||
jdbcTemplate.execute("alter table booking add column from_city varchar")
|
|
||||||
}
|
|
||||||
|
|
||||||
val hasToCity = jdbcTemplate.queryForObject(
|
|
||||||
"""
|
|
||||||
select count(*)
|
|
||||||
from information_schema.columns
|
|
||||||
where table_name = 'booking'
|
|
||||||
and column_name = 'to_city'
|
|
||||||
""".trimIndent(),
|
|
||||||
Int::class.java
|
|
||||||
) ?: 0
|
|
||||||
if (hasToCity == 0) {
|
|
||||||
logger.info("Adding booking.to_city column")
|
|
||||||
jdbcTemplate.execute("alter table booking add column to_city varchar")
|
|
||||||
}
|
|
||||||
|
|
||||||
val hasMemberRelation = jdbcTemplate.queryForObject(
|
|
||||||
"""
|
|
||||||
select count(*)
|
|
||||||
from information_schema.columns
|
|
||||||
where table_name = 'booking'
|
|
||||||
and column_name = 'member_relation'
|
|
||||||
""".trimIndent(),
|
|
||||||
Int::class.java
|
|
||||||
) ?: 0
|
|
||||||
if (hasMemberRelation == 0) {
|
|
||||||
logger.info("Adding booking.member_relation column")
|
|
||||||
jdbcTemplate.execute("alter table booking add column member_relation varchar")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package com.android.trisolarisserver.config
|
|
||||||
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class GuestDocumentSchemaFix(
|
|
||||||
private val jdbcTemplate: JdbcTemplate
|
|
||||||
) : PostgresSchemaFix(jdbcTemplate) {
|
|
||||||
|
|
||||||
override fun runPostgres(jdbcTemplate: JdbcTemplate) {
|
|
||||||
val hasTable = jdbcTemplate.queryForObject(
|
|
||||||
"""
|
|
||||||
select count(*)
|
|
||||||
from information_schema.tables
|
|
||||||
where table_name = 'guest_document'
|
|
||||||
""".trimIndent(),
|
|
||||||
Int::class.java
|
|
||||||
) ?: 0
|
|
||||||
if (hasTable == 0) return
|
|
||||||
|
|
||||||
val hasHash = jdbcTemplate.queryForObject(
|
|
||||||
"""
|
|
||||||
select count(*)
|
|
||||||
from information_schema.columns
|
|
||||||
where table_name = 'guest_document'
|
|
||||||
and column_name = 'file_hash'
|
|
||||||
""".trimIndent(),
|
|
||||||
Int::class.java
|
|
||||||
) ?: 0
|
|
||||||
if (hasHash == 0) {
|
|
||||||
logger.info("Adding file_hash to guest_document table")
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"""
|
|
||||||
alter table guest_document
|
|
||||||
add column file_hash varchar
|
|
||||||
""".trimIndent()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package com.android.trisolarisserver.config
|
|
||||||
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class IssuedCardSchemaFix(
|
|
||||||
private val jdbcTemplate: JdbcTemplate
|
|
||||||
) : PostgresSchemaFix(jdbcTemplate) {
|
|
||||||
|
|
||||||
override fun runPostgres(jdbcTemplate: JdbcTemplate) {
|
|
||||||
val isNullable = jdbcTemplate.queryForObject(
|
|
||||||
"""
|
|
||||||
select count(*)
|
|
||||||
from information_schema.columns
|
|
||||||
where table_name = 'issued_card'
|
|
||||||
and column_name = 'room_stay_id'
|
|
||||||
and is_nullable = 'YES'
|
|
||||||
""".trimIndent(),
|
|
||||||
Int::class.java
|
|
||||||
) ?: 0
|
|
||||||
|
|
||||||
if (isNullable == 0) {
|
|
||||||
logger.info("Dropping NOT NULL on issued_card.room_stay_id")
|
|
||||||
jdbcTemplate.execute("alter table issued_card alter column room_stay_id drop not null")
|
|
||||||
}
|
|
||||||
|
|
||||||
val uniqueIndexExists = jdbcTemplate.queryForObject(
|
|
||||||
"""
|
|
||||||
select count(*)
|
|
||||||
from pg_indexes
|
|
||||||
where schemaname = 'public'
|
|
||||||
and tablename = 'issued_card'
|
|
||||||
and indexname = 'idx_issued_card_property_card_id_unique'
|
|
||||||
""".trimIndent(),
|
|
||||||
Int::class.java
|
|
||||||
) ?: 0
|
|
||||||
|
|
||||||
if (uniqueIndexExists > 0) {
|
|
||||||
logger.info("Dropping unique index on issued_card(property_id, lower(card_id))")
|
|
||||||
jdbcTemplate.execute("drop index if exists idx_issued_card_property_card_id_unique")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package com.android.trisolarisserver.config
|
|
||||||
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class PaymentSchemaFix(
|
|
||||||
private val jdbcTemplate: JdbcTemplate
|
|
||||||
) : PostgresSchemaFix(jdbcTemplate) {
|
|
||||||
|
|
||||||
override fun runPostgres(jdbcTemplate: JdbcTemplate) {
|
|
||||||
ensureColumn("payment", "gateway_payment_id", "varchar")
|
|
||||||
ensureColumn("payment", "gateway_txn_id", "varchar")
|
|
||||||
ensureColumn("payment", "bank_ref_num", "varchar")
|
|
||||||
ensureColumn("payment", "mode", "varchar")
|
|
||||||
ensureColumn("payment", "pg_type", "varchar")
|
|
||||||
ensureColumn("payment", "payer_vpa", "varchar")
|
|
||||||
ensureColumn("payment", "payer_name", "varchar")
|
|
||||||
ensureColumn("payment", "payment_source", "varchar")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureColumn(table: String, column: String, type: String) {
|
|
||||||
val exists = jdbcTemplate.queryForObject(
|
|
||||||
"""
|
|
||||||
select count(*)
|
|
||||||
from information_schema.columns
|
|
||||||
where table_name = '$table'
|
|
||||||
and column_name = '$column'
|
|
||||||
""".trimIndent(),
|
|
||||||
Int::class.java
|
|
||||||
) ?: 0
|
|
||||||
if (exists == 0) {
|
|
||||||
logger.info("Adding $table.$column column")
|
|
||||||
jdbcTemplate.execute("alter table $table add column $column $type")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package com.android.trisolarisserver.config
|
|
||||||
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class PayuPaymentAttemptSchemaFix(
|
|
||||||
private val jdbcTemplate: JdbcTemplate
|
|
||||||
) : PostgresSchemaFix(jdbcTemplate) {
|
|
||||||
|
|
||||||
override fun runPostgres(jdbcTemplate: JdbcTemplate) {
|
|
||||||
val hasTable = jdbcTemplate.queryForObject(
|
|
||||||
"""
|
|
||||||
select count(*)
|
|
||||||
from information_schema.tables
|
|
||||||
where table_name = 'payu_payment_attempt'
|
|
||||||
""".trimIndent(),
|
|
||||||
Int::class.java
|
|
||||||
) ?: 0
|
|
||||||
if (hasTable == 0) {
|
|
||||||
logger.info("Creating payu_payment_attempt table")
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"""
|
|
||||||
create table payu_payment_attempt (
|
|
||||||
id uuid primary key,
|
|
||||||
property_id uuid not null references property(id) on delete cascade,
|
|
||||||
booking_id uuid references booking(id) on delete set null,
|
|
||||||
status varchar,
|
|
||||||
unmapped_status varchar,
|
|
||||||
amount bigint,
|
|
||||||
currency varchar,
|
|
||||||
gateway_payment_id varchar,
|
|
||||||
gateway_txn_id varchar,
|
|
||||||
bank_ref_num varchar,
|
|
||||||
mode varchar,
|
|
||||||
pg_type varchar,
|
|
||||||
payer_vpa varchar,
|
|
||||||
payer_name varchar,
|
|
||||||
payment_source varchar,
|
|
||||||
error_code varchar,
|
|
||||||
error_message varchar,
|
|
||||||
payload text,
|
|
||||||
received_at timestamptz not null
|
|
||||||
)
|
|
||||||
""".trimIndent()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
package com.android.trisolarisserver.config
|
|
||||||
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class PayuPaymentLinkSettingsSchemaFix(
|
|
||||||
private val jdbcTemplate: JdbcTemplate
|
|
||||||
) : PostgresSchemaFix(jdbcTemplate) {
|
|
||||||
|
|
||||||
override fun runPostgres(jdbcTemplate: JdbcTemplate) {
|
|
||||||
val hasTable = jdbcTemplate.queryForObject(
|
|
||||||
"""
|
|
||||||
select count(*)
|
|
||||||
from information_schema.tables
|
|
||||||
where table_name = 'payu_payment_link_settings'
|
|
||||||
""".trimIndent(),
|
|
||||||
Int::class.java
|
|
||||||
) ?: 0
|
|
||||||
if (hasTable == 0) {
|
|
||||||
logger.info("Creating payu_payment_link_settings table")
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"""
|
|
||||||
create table payu_payment_link_settings (
|
|
||||||
id uuid primary key,
|
|
||||||
property_id uuid not null unique references property(id) on delete cascade,
|
|
||||||
merchant_id text not null,
|
|
||||||
client_id text,
|
|
||||||
client_secret text,
|
|
||||||
access_token text,
|
|
||||||
token_expires_at timestamptz,
|
|
||||||
is_test boolean not null default false,
|
|
||||||
updated_at timestamptz not null
|
|
||||||
)
|
|
||||||
""".trimIndent()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ensureColumn("payu_payment_link_settings", "client_id", "text")
|
|
||||||
ensureColumn("payu_payment_link_settings", "client_secret", "text")
|
|
||||||
ensureColumn("payu_payment_link_settings", "access_token", "text")
|
|
||||||
ensureColumn("payu_payment_link_settings", "token_expires_at", "timestamptz")
|
|
||||||
|
|
||||||
jdbcTemplate.execute("alter table payu_payment_link_settings alter column access_token drop not null")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureColumn(table: String, column: String, type: String) {
|
|
||||||
val exists = jdbcTemplate.queryForObject(
|
|
||||||
"""
|
|
||||||
select count(*)
|
|
||||||
from information_schema.columns
|
|
||||||
where table_name = '$table'
|
|
||||||
and column_name = '$column'
|
|
||||||
""".trimIndent(),
|
|
||||||
Int::class.java
|
|
||||||
) ?: 0
|
|
||||||
if (exists == 0) {
|
|
||||||
logger.info("Adding $table.$column column")
|
|
||||||
jdbcTemplate.execute("alter table $table add column $column $type")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package com.android.trisolarisserver.config
|
|
||||||
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class PayuQrRequestSchemaFix(
|
|
||||||
private val jdbcTemplate: JdbcTemplate
|
|
||||||
) : PostgresSchemaFix(jdbcTemplate) {
|
|
||||||
|
|
||||||
override fun runPostgres(jdbcTemplate: JdbcTemplate) {
|
|
||||||
val hasTable = jdbcTemplate.queryForObject(
|
|
||||||
"""
|
|
||||||
select count(*)
|
|
||||||
from information_schema.tables
|
|
||||||
where table_name = 'payu_qr_request'
|
|
||||||
""".trimIndent(),
|
|
||||||
Int::class.java
|
|
||||||
) ?: 0
|
|
||||||
if (hasTable == 0) {
|
|
||||||
logger.info("Creating payu_qr_request table")
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"""
|
|
||||||
create table payu_qr_request (
|
|
||||||
id uuid primary key,
|
|
||||||
property_id uuid not null references property(id) on delete cascade,
|
|
||||||
booking_id uuid not null references booking(id) on delete cascade,
|
|
||||||
txnid varchar not null,
|
|
||||||
amount bigint not null,
|
|
||||||
currency varchar not null,
|
|
||||||
status varchar not null,
|
|
||||||
request_payload text,
|
|
||||||
response_payload text,
|
|
||||||
expiry_at timestamptz,
|
|
||||||
created_at timestamptz not null
|
|
||||||
)
|
|
||||||
""".trimIndent()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
val hasExpiryAt = jdbcTemplate.queryForObject(
|
|
||||||
"""
|
|
||||||
select count(*)
|
|
||||||
from information_schema.columns
|
|
||||||
where table_name = 'payu_qr_request'
|
|
||||||
and column_name = 'expiry_at'
|
|
||||||
""".trimIndent(),
|
|
||||||
Int::class.java
|
|
||||||
) ?: 0
|
|
||||||
if (hasExpiryAt == 0) {
|
|
||||||
logger.info("Adding expiry_at to payu_qr_request table")
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"""
|
|
||||||
alter table payu_qr_request
|
|
||||||
add column expiry_at timestamptz
|
|
||||||
""".trimIndent()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
package com.android.trisolarisserver.config
|
|
||||||
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class PayuSettingsSchemaFix(
|
|
||||||
private val jdbcTemplate: JdbcTemplate
|
|
||||||
) : PostgresSchemaFix(jdbcTemplate) {
|
|
||||||
|
|
||||||
override fun runPostgres(jdbcTemplate: JdbcTemplate) {
|
|
||||||
val hasTable = jdbcTemplate.queryForObject(
|
|
||||||
"""
|
|
||||||
select count(*)
|
|
||||||
from information_schema.tables
|
|
||||||
where table_name = 'payu_settings'
|
|
||||||
""".trimIndent(),
|
|
||||||
Int::class.java
|
|
||||||
) ?: 0
|
|
||||||
if (hasTable == 0) {
|
|
||||||
logger.info("Creating payu_settings table")
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"""
|
|
||||||
create table payu_settings (
|
|
||||||
id uuid primary key,
|
|
||||||
property_id uuid not null unique references property(id) on delete cascade,
|
|
||||||
merchant_key varchar not null,
|
|
||||||
salt_32 varchar,
|
|
||||||
salt_256 varchar,
|
|
||||||
base_url varchar not null,
|
|
||||||
is_test boolean not null default false,
|
|
||||||
use_salt_256 boolean not null default true,
|
|
||||||
updated_at timestamptz not null
|
|
||||||
)
|
|
||||||
""".trimIndent()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val hasIsTest = jdbcTemplate.queryForObject(
|
|
||||||
"""
|
|
||||||
select count(*)
|
|
||||||
from information_schema.columns
|
|
||||||
where table_name = 'payu_settings'
|
|
||||||
and column_name = 'is_test'
|
|
||||||
""".trimIndent(),
|
|
||||||
Int::class.java
|
|
||||||
) ?: 0
|
|
||||||
if (hasIsTest == 0) {
|
|
||||||
logger.info("Adding payu_settings.is_test column")
|
|
||||||
jdbcTemplate.execute("alter table payu_settings add column is_test boolean not null default false")
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Ensuring payu_settings text column sizes")
|
|
||||||
jdbcTemplate.execute("alter table payu_settings alter column merchant_key type text")
|
|
||||||
jdbcTemplate.execute("alter table payu_settings alter column salt_32 type text")
|
|
||||||
jdbcTemplate.execute("alter table payu_settings alter column salt_256 type text")
|
|
||||||
jdbcTemplate.execute("alter table payu_settings alter column base_url type text")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package com.android.trisolarisserver.config
|
|
||||||
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class PayuWebhookLogSchemaFix(
|
|
||||||
private val jdbcTemplate: JdbcTemplate
|
|
||||||
) : PostgresSchemaFix(jdbcTemplate) {
|
|
||||||
|
|
||||||
override fun runPostgres(jdbcTemplate: JdbcTemplate) {
|
|
||||||
val hasTable = jdbcTemplate.queryForObject(
|
|
||||||
"""
|
|
||||||
select count(*)
|
|
||||||
from information_schema.tables
|
|
||||||
where table_name = 'payu_webhook_log'
|
|
||||||
""".trimIndent(),
|
|
||||||
Int::class.java
|
|
||||||
) ?: 0
|
|
||||||
if (hasTable == 0) {
|
|
||||||
logger.info("Creating payu_webhook_log table")
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"""
|
|
||||||
create table payu_webhook_log (
|
|
||||||
id uuid primary key,
|
|
||||||
property_id uuid not null references property(id) on delete cascade,
|
|
||||||
headers text,
|
|
||||||
payload text,
|
|
||||||
content_type varchar,
|
|
||||||
received_at timestamptz not null
|
|
||||||
)
|
|
||||||
""".trimIndent()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package com.android.trisolarisserver.config
|
|
||||||
|
|
||||||
import org.slf4j.LoggerFactory
|
|
||||||
import org.springframework.boot.ApplicationArguments
|
|
||||||
import org.springframework.boot.ApplicationRunner
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate
|
|
||||||
|
|
||||||
abstract class PostgresSchemaFix(
|
|
||||||
private val jdbcTemplate: JdbcTemplate
|
|
||||||
) : ApplicationRunner {
|
|
||||||
|
|
||||||
protected val logger = LoggerFactory.getLogger(this::class.java)
|
|
||||||
|
|
||||||
override fun run(args: ApplicationArguments) {
|
|
||||||
val version = jdbcTemplate.queryForObject("select version()", String::class.java) ?: return
|
|
||||||
if (!version.contains("PostgreSQL", ignoreCase = true)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
runPostgres(jdbcTemplate)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected abstract fun runPostgres(jdbcTemplate: JdbcTemplate)
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package com.android.trisolarisserver.config
|
|
||||||
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class RatePlanSchemaFix(
|
|
||||||
private val jdbcTemplate: JdbcTemplate
|
|
||||||
) : PostgresSchemaFix(jdbcTemplate) {
|
|
||||||
|
|
||||||
override fun runPostgres(jdbcTemplate: JdbcTemplate) {
|
|
||||||
val constraints = jdbcTemplate.query(
|
|
||||||
"""
|
|
||||||
select tc.constraint_name,
|
|
||||||
array_agg(kcu.column_name order by kcu.ordinal_position) as cols
|
|
||||||
from information_schema.table_constraints tc
|
|
||||||
join information_schema.key_column_usage kcu
|
|
||||||
on tc.constraint_name = kcu.constraint_name
|
|
||||||
and tc.table_schema = kcu.table_schema
|
|
||||||
where tc.table_name = 'rate_plan'
|
|
||||||
and tc.constraint_type = 'UNIQUE'
|
|
||||||
group by tc.constraint_name
|
|
||||||
""".trimIndent()
|
|
||||||
) { rs, _ ->
|
|
||||||
rs.getString("constraint_name") to (rs.getArray("cols").array as Array<*>).map { it.toString() }
|
|
||||||
}
|
|
||||||
|
|
||||||
val oldConstraint = constraints.firstOrNull { it.second == listOf("property_id", "code") }
|
|
||||||
if (oldConstraint != null) {
|
|
||||||
logger.info("Dropping old unique constraint on rate_plan(property_id, code)")
|
|
||||||
jdbcTemplate.execute("alter table rate_plan drop constraint if exists ${oldConstraint.first}")
|
|
||||||
}
|
|
||||||
|
|
||||||
val hasNew = constraints.any { it.second == listOf("property_id", "room_type_id", "code") }
|
|
||||||
if (!hasNew) {
|
|
||||||
logger.info("Adding unique constraint on rate_plan(property_id, room_type_id, code)")
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"alter table rate_plan add constraint rate_plan_property_roomtype_code_key unique (property_id, room_type_id, code)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package com.android.trisolarisserver.config
|
|
||||||
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class RoomImageSchemaFix(
|
|
||||||
private val jdbcTemplate: JdbcTemplate
|
|
||||||
) : PostgresSchemaFix(jdbcTemplate) {
|
|
||||||
|
|
||||||
override fun runPostgres(jdbcTemplate: JdbcTemplate) {
|
|
||||||
val hasContentHash = jdbcTemplate.queryForObject(
|
|
||||||
"""
|
|
||||||
select count(*)
|
|
||||||
from information_schema.columns
|
|
||||||
where table_name = 'room_image'
|
|
||||||
and column_name = 'content_hash'
|
|
||||||
""".trimIndent(),
|
|
||||||
Int::class.java
|
|
||||||
) ?: 0
|
|
||||||
|
|
||||||
if (hasContentHash == 0) {
|
|
||||||
logger.info("Adding room_image.content_hash column")
|
|
||||||
jdbcTemplate.execute("alter table room_image add column content_hash text")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
package com.android.trisolarisserver.config
|
|
||||||
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class RoomImageTagSchemaFix(
|
|
||||||
private val jdbcTemplate: JdbcTemplate
|
|
||||||
) : PostgresSchemaFix(jdbcTemplate) {
|
|
||||||
|
|
||||||
override fun runPostgres(jdbcTemplate: JdbcTemplate) {
|
|
||||||
val hasOldRoomImageId = jdbcTemplate.queryForObject(
|
|
||||||
"""
|
|
||||||
select count(*)
|
|
||||||
from information_schema.columns
|
|
||||||
where table_name = 'room_image_tag'
|
|
||||||
and column_name = 'room_image_id'
|
|
||||||
""".trimIndent(),
|
|
||||||
Int::class.java
|
|
||||||
) ?: 0
|
|
||||||
|
|
||||||
if (hasOldRoomImageId > 0) {
|
|
||||||
logger.info("Dropping legacy room_image_tag table")
|
|
||||||
jdbcTemplate.execute("drop table if exists room_image_tag cascade")
|
|
||||||
}
|
|
||||||
|
|
||||||
val hasRoomImageTag = jdbcTemplate.queryForObject(
|
|
||||||
"""
|
|
||||||
select count(*)
|
|
||||||
from information_schema.tables
|
|
||||||
where table_name = 'room_image_tag'
|
|
||||||
""".trimIndent(),
|
|
||||||
Int::class.java
|
|
||||||
) ?: 0
|
|
||||||
|
|
||||||
if (hasRoomImageTag == 0) {
|
|
||||||
logger.info("Creating room_image_tag table")
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"""
|
|
||||||
create table room_image_tag (
|
|
||||||
id uuid primary key,
|
|
||||||
name text not null unique,
|
|
||||||
created_at timestamptz not null
|
|
||||||
)
|
|
||||||
""".trimIndent()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val hasLink = jdbcTemplate.queryForObject(
|
|
||||||
"""
|
|
||||||
select count(*)
|
|
||||||
from information_schema.tables
|
|
||||||
where table_name = 'room_image_tag_link'
|
|
||||||
""".trimIndent(),
|
|
||||||
Int::class.java
|
|
||||||
) ?: 0
|
|
||||||
|
|
||||||
if (hasLink == 0) {
|
|
||||||
logger.info("Creating room_image_tag_link table")
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"""
|
|
||||||
create table room_image_tag_link (
|
|
||||||
room_image_id uuid not null,
|
|
||||||
tag_id uuid not null
|
|
||||||
)
|
|
||||||
""".trimIndent()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package com.android.trisolarisserver.config
|
|
||||||
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class RoomStaySchemaFix(
|
|
||||||
private val jdbcTemplate: JdbcTemplate
|
|
||||||
) : PostgresSchemaFix(jdbcTemplate) {
|
|
||||||
|
|
||||||
override fun runPostgres(jdbcTemplate: JdbcTemplate) {
|
|
||||||
val exists = jdbcTemplate.queryForObject(
|
|
||||||
"""
|
|
||||||
select count(*)
|
|
||||||
from information_schema.table_constraints
|
|
||||||
where table_name = 'room_stay'
|
|
||||||
and constraint_name = 'room_stay_rate_source_check'
|
|
||||||
""".trimIndent(),
|
|
||||||
Int::class.java
|
|
||||||
) ?: 0
|
|
||||||
|
|
||||||
if (exists > 0) {
|
|
||||||
logger.info("Updating room_stay_rate_source_check constraint")
|
|
||||||
jdbcTemplate.execute("alter table room_stay drop constraint room_stay_rate_source_check")
|
|
||||||
}
|
|
||||||
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"""
|
|
||||||
alter table room_stay
|
|
||||||
add constraint room_stay_rate_source_check
|
|
||||||
check (rate_source in ('MANUAL','PRESET','RATE_PLAN','NEGOTIATED','OTA'))
|
|
||||||
""".trimIndent()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package com.android.trisolarisserver.config
|
|
||||||
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class RoomTypeSchemaFix(
|
|
||||||
private val jdbcTemplate: JdbcTemplate
|
|
||||||
) : PostgresSchemaFix(jdbcTemplate) {
|
|
||||||
|
|
||||||
override fun runPostgres(jdbcTemplate: JdbcTemplate) {
|
|
||||||
val hasActive = jdbcTemplate.queryForObject(
|
|
||||||
"""
|
|
||||||
select count(*)
|
|
||||||
from information_schema.columns
|
|
||||||
where table_name = 'room_type'
|
|
||||||
and column_name = 'is_active'
|
|
||||||
""".trimIndent(),
|
|
||||||
Int::class.java
|
|
||||||
) ?: 0
|
|
||||||
|
|
||||||
if (hasActive == 0) {
|
|
||||||
logger.info("Adding room_type.is_active column")
|
|
||||||
jdbcTemplate.execute("alter table room_type add column is_active boolean not null default true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
package com.android.trisolarisserver.config
|
package com.android.trisolarisserver.config.core
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.MediaType
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.security.access.AccessDeniedException
|
import org.springframework.security.access.AccessDeniedException
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||||
@@ -18,7 +19,9 @@ class ApiExceptionHandler {
|
|||||||
request: HttpServletRequest
|
request: HttpServletRequest
|
||||||
): ResponseEntity<ApiError> {
|
): ResponseEntity<ApiError> {
|
||||||
val status = ex.statusCode as HttpStatus
|
val status = ex.statusCode as HttpStatus
|
||||||
return ResponseEntity.status(status).body(
|
return ResponseEntity.status(status)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(
|
||||||
ApiError(
|
ApiError(
|
||||||
timestamp = OffsetDateTime.now().toString(),
|
timestamp = OffsetDateTime.now().toString(),
|
||||||
status = status.value(),
|
status = status.value(),
|
||||||
@@ -34,7 +37,9 @@ class ApiExceptionHandler {
|
|||||||
ex: AccessDeniedException,
|
ex: AccessDeniedException,
|
||||||
request: HttpServletRequest
|
request: HttpServletRequest
|
||||||
): ResponseEntity<ApiError> {
|
): ResponseEntity<ApiError> {
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(
|
||||||
ApiError(
|
ApiError(
|
||||||
timestamp = OffsetDateTime.now().toString(),
|
timestamp = OffsetDateTime.now().toString(),
|
||||||
status = HttpStatus.FORBIDDEN.value(),
|
status = HttpStatus.FORBIDDEN.value(),
|
||||||
@@ -50,7 +55,9 @@ class ApiExceptionHandler {
|
|||||||
ex: Exception,
|
ex: Exception,
|
||||||
request: HttpServletRequest
|
request: HttpServletRequest
|
||||||
): ResponseEntity<ApiError> {
|
): ResponseEntity<ApiError> {
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(
|
||||||
ApiError(
|
ApiError(
|
||||||
timestamp = OffsetDateTime.now().toString(),
|
timestamp = OffsetDateTime.now().toString(),
|
||||||
status = HttpStatus.INTERNAL_SERVER_ERROR.value(),
|
status = HttpStatus.INTERNAL_SERVER_ERROR.value(),
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.android.trisolarisserver.config
|
package com.android.trisolarisserver.config.core
|
||||||
|
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.android.trisolarisserver.config
|
package com.android.trisolarisserver.config.core
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
@@ -1,787 +0,0 @@
|
|||||||
package com.android.trisolarisserver.controller
|
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.PropertyAccess
|
|
||||||
import com.android.trisolarisserver.component.RoomBoardEvents
|
|
||||||
import com.android.trisolarisserver.controller.dto.BookingCancelRequest
|
|
||||||
import com.android.trisolarisserver.controller.dto.BookingCheckInRequest
|
|
||||||
import com.android.trisolarisserver.controller.dto.BookingBulkCheckInRequest
|
|
||||||
import com.android.trisolarisserver.controller.dto.BookingCheckOutRequest
|
|
||||||
import com.android.trisolarisserver.controller.dto.BookingCreateRequest
|
|
||||||
import com.android.trisolarisserver.controller.dto.BookingCreateResponse
|
|
||||||
import com.android.trisolarisserver.controller.dto.BookingDetailResponse
|
|
||||||
import com.android.trisolarisserver.controller.dto.BookingExpectedDatesUpdateRequest
|
|
||||||
import com.android.trisolarisserver.controller.dto.BookingLinkGuestRequest
|
|
||||||
import com.android.trisolarisserver.controller.dto.BookingNoShowRequest
|
|
||||||
import com.android.trisolarisserver.controller.dto.BookingListItem
|
|
||||||
import com.android.trisolarisserver.controller.dto.RoomStayPreAssignRequest
|
|
||||||
import com.android.trisolarisserver.db.repo.BookingRepo
|
|
||||||
import com.android.trisolarisserver.db.repo.GuestDocumentRepo
|
|
||||||
import com.android.trisolarisserver.db.repo.GuestRepo
|
|
||||||
import com.android.trisolarisserver.db.repo.GuestRatingRepo
|
|
||||||
import com.android.trisolarisserver.models.booking.BookingStatus
|
|
||||||
import com.android.trisolarisserver.models.booking.MemberRelation
|
|
||||||
import com.android.trisolarisserver.models.booking.TransportMode
|
|
||||||
import com.android.trisolarisserver.models.room.RoomStay
|
|
||||||
import com.android.trisolarisserver.models.room.RateSource
|
|
||||||
import com.android.trisolarisserver.models.property.Role
|
|
||||||
import com.android.trisolarisserver.repo.AppUserRepo
|
|
||||||
import com.android.trisolarisserver.repo.GuestVehicleRepo
|
|
||||||
import com.android.trisolarisserver.repo.PaymentRepo
|
|
||||||
import com.android.trisolarisserver.repo.PropertyRepo
|
|
||||||
import com.android.trisolarisserver.repo.RoomRepo
|
|
||||||
import com.android.trisolarisserver.repo.RoomStayRepo
|
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
|
||||||
import org.springframework.http.HttpStatus
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
import org.springframework.web.server.ResponseStatusException
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/properties/{propertyId}/bookings")
|
|
||||||
class BookingFlow(
|
|
||||||
private val propertyAccess: PropertyAccess,
|
|
||||||
private val bookingRepo: BookingRepo,
|
|
||||||
private val guestRepo: GuestRepo,
|
|
||||||
private val roomRepo: RoomRepo,
|
|
||||||
private val roomStayRepo: RoomStayRepo,
|
|
||||||
private val appUserRepo: AppUserRepo,
|
|
||||||
private val propertyRepo: PropertyRepo,
|
|
||||||
private val roomBoardEvents: RoomBoardEvents,
|
|
||||||
private val guestVehicleRepo: GuestVehicleRepo,
|
|
||||||
private val guestRatingRepo: GuestRatingRepo,
|
|
||||||
private val guestDocumentRepo: GuestDocumentRepo,
|
|
||||||
private val paymentRepo: PaymentRepo
|
|
||||||
) {
|
|
||||||
|
|
||||||
@PostMapping
|
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
|
||||||
@Transactional
|
|
||||||
fun createBooking(
|
|
||||||
@PathVariable propertyId: UUID,
|
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
|
||||||
@RequestBody request: BookingCreateRequest
|
|
||||||
): BookingCreateResponse {
|
|
||||||
val actor = requireActor(propertyId, principal)
|
|
||||||
val property = propertyRepo.findById(propertyId).orElseThrow {
|
|
||||||
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
val expectedCheckInAt = parseOffset(request.expectedCheckInAt)
|
|
||||||
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expectedCheckInAt required")
|
|
||||||
val expectedCheckOutAt = parseOffset(request.expectedCheckOutAt)
|
|
||||||
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expectedCheckOutAt required")
|
|
||||||
if (!expectedCheckOutAt.isAfter(expectedCheckInAt)) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date range")
|
|
||||||
}
|
|
||||||
|
|
||||||
val now = nowForProperty(property.timezone)
|
|
||||||
val phone = request.guestPhoneE164?.trim()?.takeIf { it.isNotBlank() }
|
|
||||||
val guest = resolveGuestForBooking(propertyId, property, actor, now, phone)
|
|
||||||
val fromCity = request.fromCity?.trim()?.ifBlank { null }
|
|
||||||
val toCity = request.toCity?.trim()?.ifBlank { null }
|
|
||||||
val memberRelation = parseMemberRelation(request.memberRelation)
|
|
||||||
val hasGuestCounts = request.maleCount != null || request.femaleCount != null || request.childCount != null
|
|
||||||
val adultCount = if (hasGuestCounts) {
|
|
||||||
(request.maleCount ?: 0) + (request.femaleCount ?: 0)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
val totalGuestCount = if (hasGuestCounts && adultCount!=null) {
|
|
||||||
adultCount + (request.childCount ?: 0)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
val booking = com.android.trisolarisserver.models.booking.Booking(
|
|
||||||
property = property,
|
|
||||||
primaryGuest = guest,
|
|
||||||
status = BookingStatus.OPEN,
|
|
||||||
source = request.source?.trim().takeIf { !it.isNullOrBlank() } ?: "WALKIN",
|
|
||||||
checkinAt = null,
|
|
||||||
expectedCheckinAt = expectedCheckInAt,
|
|
||||||
expectedCheckoutAt = expectedCheckOutAt,
|
|
||||||
transportMode = request.transportMode?.let {
|
|
||||||
val mode = parseTransportMode(it)
|
|
||||||
if (!isTransportModeAllowed(property, mode)) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Transport mode disabled")
|
|
||||||
}
|
|
||||||
mode
|
|
||||||
},
|
|
||||||
adultCount = adultCount,
|
|
||||||
childCount = request.childCount,
|
|
||||||
maleCount = request.maleCount,
|
|
||||||
femaleCount = request.femaleCount,
|
|
||||||
totalGuestCount = totalGuestCount,
|
|
||||||
expectedGuestCount = request.expectedGuestCount,
|
|
||||||
fromCity = fromCity,
|
|
||||||
toCity = toCity,
|
|
||||||
memberRelation = memberRelation,
|
|
||||||
notes = request.notes,
|
|
||||||
createdBy = actor,
|
|
||||||
updatedAt = now
|
|
||||||
)
|
|
||||||
|
|
||||||
val saved = bookingRepo.save(booking)
|
|
||||||
return BookingCreateResponse(
|
|
||||||
id = saved.id!!,
|
|
||||||
status = saved.status.name,
|
|
||||||
guestId = guest.id,
|
|
||||||
checkInAt = saved.checkinAt?.toString(),
|
|
||||||
expectedCheckInAt = saved.expectedCheckinAt?.toString(),
|
|
||||||
expectedCheckOutAt = saved.expectedCheckoutAt?.toString()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
fun listBookings(
|
|
||||||
@PathVariable propertyId: UUID,
|
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
|
||||||
@RequestParam(required = false) status: String?
|
|
||||||
): List<BookingListItem> {
|
|
||||||
requireRole(
|
|
||||||
propertyAccess,
|
|
||||||
propertyId,
|
|
||||||
principal,
|
|
||||||
Role.ADMIN,
|
|
||||||
Role.MANAGER,
|
|
||||||
Role.STAFF,
|
|
||||||
Role.HOUSEKEEPING,
|
|
||||||
Role.FINANCE
|
|
||||||
)
|
|
||||||
val property = propertyRepo.findById(propertyId).orElseThrow {
|
|
||||||
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
|
||||||
}
|
|
||||||
val statuses = parseStatuses(status)
|
|
||||||
val bookings = if (statuses.isEmpty()) {
|
|
||||||
bookingRepo.findByPropertyIdOrderByCreatedAtDesc(propertyId)
|
|
||||||
} else {
|
|
||||||
bookingRepo.findByPropertyIdAndStatusInOrderByCreatedAtDesc(propertyId, statuses)
|
|
||||||
}
|
|
||||||
val bookingIds = bookings.mapNotNull { it.id }
|
|
||||||
val roomNumbersByBooking = if (bookingIds.isEmpty()) {
|
|
||||||
emptyMap()
|
|
||||||
} else {
|
|
||||||
roomStayRepo.findActiveRoomNumbersByBookingIds(bookingIds)
|
|
||||||
.groupBy { it.bookingId }
|
|
||||||
.mapValues { (_, rows) -> rows.map { it.roomNumber }.distinct().sorted() }
|
|
||||||
}
|
|
||||||
val staysByBooking = if (bookingIds.isEmpty()) {
|
|
||||||
emptyMap()
|
|
||||||
} else {
|
|
||||||
roomStayRepo.findByBookingIdIn(bookingIds).groupBy { it.booking.id!! }
|
|
||||||
}
|
|
||||||
val paymentsByBooking = if (bookingIds.isEmpty()) {
|
|
||||||
emptyMap()
|
|
||||||
} else {
|
|
||||||
paymentRepo.sumAmountByBookingIds(bookingIds)
|
|
||||||
.associate { it.bookingId to it.total }
|
|
||||||
}
|
|
||||||
return bookings.map { booking ->
|
|
||||||
val guest = booking.primaryGuest
|
|
||||||
val stays = staysByBooking[booking.id].orEmpty()
|
|
||||||
val expectedPay = if (stays.isEmpty()) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
computeExpectedPay(stays, property.timezone)
|
|
||||||
}
|
|
||||||
val collected = paymentsByBooking[booking.id] ?: 0L
|
|
||||||
val pending = expectedPay?.let { it - collected }
|
|
||||||
BookingListItem(
|
|
||||||
id = booking.id!!,
|
|
||||||
status = booking.status.name,
|
|
||||||
guestId = guest?.id,
|
|
||||||
guestName = guest?.name,
|
|
||||||
guestPhone = guest?.phoneE164,
|
|
||||||
roomNumbers = roomNumbersByBooking[booking.id] ?: emptyList(),
|
|
||||||
source = booking.source,
|
|
||||||
expectedCheckInAt = booking.expectedCheckinAt?.toString(),
|
|
||||||
expectedCheckOutAt = booking.expectedCheckoutAt?.toString(),
|
|
||||||
checkInAt = booking.checkinAt?.toString(),
|
|
||||||
checkOutAt = booking.checkoutAt?.toString(),
|
|
||||||
adultCount = booking.adultCount,
|
|
||||||
childCount = booking.childCount,
|
|
||||||
maleCount = booking.maleCount,
|
|
||||||
femaleCount = booking.femaleCount,
|
|
||||||
totalGuestCount = booking.totalGuestCount,
|
|
||||||
expectedGuestCount = booking.expectedGuestCount,
|
|
||||||
notes = booking.notes,
|
|
||||||
pending = pending
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/{bookingId}")
|
|
||||||
@Transactional
|
|
||||||
fun getBooking(
|
|
||||||
@PathVariable propertyId: UUID,
|
|
||||||
@PathVariable bookingId: UUID,
|
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?
|
|
||||||
): BookingDetailResponse {
|
|
||||||
requireRole(
|
|
||||||
propertyAccess,
|
|
||||||
propertyId,
|
|
||||||
principal,
|
|
||||||
Role.ADMIN,
|
|
||||||
Role.MANAGER,
|
|
||||||
Role.STAFF,
|
|
||||||
Role.HOUSEKEEPING,
|
|
||||||
Role.FINANCE
|
|
||||||
)
|
|
||||||
val booking = requireBooking(propertyId, bookingId)
|
|
||||||
val stays = roomStayRepo.findByBookingId(bookingId)
|
|
||||||
val activeRooms = stays.filter { it.toAt == null }
|
|
||||||
val roomsToShow = if (activeRooms.isNotEmpty()) activeRooms else stays
|
|
||||||
val roomNumbers = roomsToShow.map { it.room.roomNumber }
|
|
||||||
.distinct()
|
|
||||||
.sorted()
|
|
||||||
|
|
||||||
val guest = booking.primaryGuest
|
|
||||||
val signatureUrl = guest?.signaturePath?.let {
|
|
||||||
"/properties/$propertyId/guests/${guest.id}/signature/file"
|
|
||||||
}
|
|
||||||
|
|
||||||
val totalNightlyRate = roomsToShow.sumOf { it.nightlyRate ?: 0L }
|
|
||||||
val expectedPay = computeExpectedPayTotal(stays, booking.expectedCheckoutAt, booking.property.timezone)
|
|
||||||
val accruedPay = computeExpectedPay(stays, booking.property.timezone)
|
|
||||||
val amountCollected = paymentRepo.sumAmountByBookingId(bookingId)
|
|
||||||
val pending = accruedPay - amountCollected
|
|
||||||
|
|
||||||
return BookingDetailResponse(
|
|
||||||
id = booking.id!!,
|
|
||||||
status = booking.status.name,
|
|
||||||
guestId = guest?.id,
|
|
||||||
guestName = guest?.name,
|
|
||||||
guestPhone = guest?.phoneE164,
|
|
||||||
guestNationality = guest?.nationality,
|
|
||||||
guestAddressText = guest?.addressText,
|
|
||||||
guestSignatureUrl = signatureUrl,
|
|
||||||
roomNumbers = roomNumbers,
|
|
||||||
source = booking.source,
|
|
||||||
fromCity = booking.fromCity,
|
|
||||||
toCity = booking.toCity,
|
|
||||||
memberRelation = booking.memberRelation?.name,
|
|
||||||
transportMode = booking.transportMode?.name,
|
|
||||||
checkInAt = booking.checkinAt?.toString(),
|
|
||||||
checkOutAt = booking.checkoutAt?.toString(),
|
|
||||||
expectedCheckInAt = booking.expectedCheckinAt?.toString(),
|
|
||||||
expectedCheckOutAt = booking.expectedCheckoutAt?.toString(),
|
|
||||||
adultCount = booking.adultCount,
|
|
||||||
childCount = booking.childCount,
|
|
||||||
maleCount = booking.maleCount,
|
|
||||||
femaleCount = booking.femaleCount,
|
|
||||||
totalGuestCount = booking.totalGuestCount,
|
|
||||||
expectedGuestCount = booking.expectedGuestCount,
|
|
||||||
notes = booking.notes,
|
|
||||||
registeredByName = booking.createdBy?.name,
|
|
||||||
registeredByPhone = booking.createdBy?.phoneE164,
|
|
||||||
totalNightlyRate = totalNightlyRate,
|
|
||||||
expectedPay = expectedPay,
|
|
||||||
amountCollected = amountCollected,
|
|
||||||
pending = pending
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/{bookingId}/link-guest")
|
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
|
||||||
@Transactional
|
|
||||||
fun linkGuest(
|
|
||||||
@PathVariable propertyId: UUID,
|
|
||||||
@PathVariable bookingId: UUID,
|
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
|
||||||
@RequestBody request: BookingLinkGuestRequest
|
|
||||||
) {
|
|
||||||
requireMember(propertyAccess, propertyId, principal)
|
|
||||||
val booking = requireBooking(propertyId, bookingId)
|
|
||||||
val guest = guestRepo.findById(request.guestId).orElseThrow {
|
|
||||||
ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found")
|
|
||||||
}
|
|
||||||
if (guest.property.id != propertyId) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property")
|
|
||||||
}
|
|
||||||
val previous = booking.primaryGuest
|
|
||||||
booking.primaryGuest = guest
|
|
||||||
booking.updatedAt = OffsetDateTime.now()
|
|
||||||
bookingRepo.save(booking)
|
|
||||||
if (previous != null && previous.id != guest.id && isPlaceholderGuest(previous) && isSafeToDelete(previous)) {
|
|
||||||
guestRepo.delete(previous)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/{bookingId}/check-in")
|
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
|
||||||
@Transactional
|
|
||||||
fun checkIn(
|
|
||||||
@PathVariable propertyId: UUID,
|
|
||||||
@PathVariable bookingId: UUID,
|
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
|
||||||
@RequestBody request: BookingCheckInRequest
|
|
||||||
) {
|
|
||||||
val actor = requireActor(propertyId, principal)
|
|
||||||
|
|
||||||
val roomIds = request.roomIds.distinct()
|
|
||||||
if (roomIds.isEmpty()) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "roomIds required")
|
|
||||||
}
|
|
||||||
val booking = requireBooking(propertyId, bookingId)
|
|
||||||
if (booking.status != BookingStatus.OPEN) {
|
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not open")
|
|
||||||
}
|
|
||||||
|
|
||||||
val now = OffsetDateTime.now()
|
|
||||||
val checkInAt = parseOffset(request.checkInAt) ?: now
|
|
||||||
|
|
||||||
val rooms = roomIds.map { roomId ->
|
|
||||||
val room = roomRepo.findByIdAndPropertyId(roomId, propertyId)
|
|
||||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found")
|
|
||||||
if (!room.active || room.maintenance) {
|
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Room not available")
|
|
||||||
}
|
|
||||||
room
|
|
||||||
}
|
|
||||||
|
|
||||||
val occupied = roomStayRepo.findActiveRoomIds(propertyId, rooms.mapNotNull { it.id })
|
|
||||||
if (occupied.isNotEmpty()) {
|
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Room already occupied")
|
|
||||||
}
|
|
||||||
|
|
||||||
rooms.forEach { room ->
|
|
||||||
val stay = RoomStay(
|
|
||||||
property = booking.property,
|
|
||||||
booking = booking,
|
|
||||||
room = room,
|
|
||||||
fromAt = checkInAt,
|
|
||||||
toAt = null,
|
|
||||||
rateSource = parseRateSource(request.rateSource),
|
|
||||||
nightlyRate = request.nightlyRate,
|
|
||||||
ratePlanCode = request.ratePlanCode,
|
|
||||||
currency = request.currency ?: booking.property.currency,
|
|
||||||
createdBy = actor
|
|
||||||
)
|
|
||||||
roomStayRepo.save(stay)
|
|
||||||
}
|
|
||||||
|
|
||||||
booking.status = BookingStatus.CHECKED_IN
|
|
||||||
booking.checkinAt = checkInAt
|
|
||||||
booking.transportMode = request.transportMode?.let {
|
|
||||||
val mode = parseTransportMode(it)
|
|
||||||
if (!isTransportModeAllowed(booking.property, mode)) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Transport mode disabled")
|
|
||||||
}
|
|
||||||
mode
|
|
||||||
}
|
|
||||||
if (request.notes != null) booking.notes = request.notes
|
|
||||||
booking.updatedAt = now
|
|
||||||
bookingRepo.save(booking)
|
|
||||||
roomBoardEvents.emit(propertyId)
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/{bookingId}/check-in/bulk")
|
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
|
||||||
@Transactional
|
|
||||||
fun bulkCheckIn(
|
|
||||||
@PathVariable propertyId: UUID,
|
|
||||||
@PathVariable bookingId: UUID,
|
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
|
||||||
@RequestBody request: BookingBulkCheckInRequest
|
|
||||||
) {
|
|
||||||
val actor = requireActor(propertyId, principal)
|
|
||||||
if (request.stays.isEmpty()) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "stays required")
|
|
||||||
}
|
|
||||||
|
|
||||||
val booking = requireBooking(propertyId, bookingId)
|
|
||||||
if (booking.status != BookingStatus.OPEN) {
|
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not open")
|
|
||||||
}
|
|
||||||
|
|
||||||
val roomIds = request.stays.map { it.roomId }
|
|
||||||
if (roomIds.distinct().size != roomIds.size) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Duplicate roomId in stays")
|
|
||||||
}
|
|
||||||
|
|
||||||
val rooms = request.stays.associate { stay ->
|
|
||||||
val room = roomRepo.findByIdAndPropertyId(stay.roomId, propertyId)
|
|
||||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found")
|
|
||||||
if (!room.active || room.maintenance) {
|
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Room not available")
|
|
||||||
}
|
|
||||||
stay.roomId to room
|
|
||||||
}
|
|
||||||
|
|
||||||
val occupied = roomStayRepo.findActiveRoomIds(propertyId, roomIds)
|
|
||||||
if (occupied.isNotEmpty()) {
|
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Room already occupied")
|
|
||||||
}
|
|
||||||
|
|
||||||
val now = OffsetDateTime.now()
|
|
||||||
val checkInTimes = mutableListOf<OffsetDateTime>()
|
|
||||||
val checkOutTimes = mutableListOf<OffsetDateTime>()
|
|
||||||
request.stays.forEach { stay ->
|
|
||||||
val checkInAt = parseOffset(stay.checkInAt) ?: now
|
|
||||||
checkInTimes.add(checkInAt)
|
|
||||||
val checkOutAt = parseOffset(stay.checkOutAt)
|
|
||||||
if (checkOutAt != null) {
|
|
||||||
if (!checkOutAt.isAfter(checkInAt)) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date range for stay")
|
|
||||||
}
|
|
||||||
checkOutTimes.add(checkOutAt)
|
|
||||||
}
|
|
||||||
val room = rooms.getValue(stay.roomId)
|
|
||||||
val newStay = RoomStay(
|
|
||||||
property = booking.property,
|
|
||||||
booking = booking,
|
|
||||||
room = room,
|
|
||||||
fromAt = checkInAt,
|
|
||||||
toAt = null,
|
|
||||||
rateSource = parseRateSource(stay.rateSource),
|
|
||||||
nightlyRate = stay.nightlyRate,
|
|
||||||
ratePlanCode = stay.ratePlanCode,
|
|
||||||
currency = stay.currency ?: booking.property.currency,
|
|
||||||
createdBy = actor
|
|
||||||
)
|
|
||||||
roomStayRepo.save(newStay)
|
|
||||||
}
|
|
||||||
|
|
||||||
val bookingCheckInAt = checkInTimes.minOrNull() ?: now
|
|
||||||
val bookingExpectedCheckout = checkOutTimes.maxOrNull()
|
|
||||||
booking.status = BookingStatus.CHECKED_IN
|
|
||||||
booking.checkinAt = bookingCheckInAt
|
|
||||||
if (bookingExpectedCheckout != null) {
|
|
||||||
booking.expectedCheckoutAt = bookingExpectedCheckout
|
|
||||||
}
|
|
||||||
booking.transportMode = request.transportMode?.let {
|
|
||||||
val mode = parseTransportMode(it)
|
|
||||||
if (!isTransportModeAllowed(booking.property, mode)) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Transport mode disabled")
|
|
||||||
}
|
|
||||||
mode
|
|
||||||
}
|
|
||||||
if (request.notes != null) booking.notes = request.notes
|
|
||||||
booking.updatedAt = now
|
|
||||||
bookingRepo.save(booking)
|
|
||||||
roomBoardEvents.emit(propertyId)
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/{bookingId}/expected-dates")
|
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
|
||||||
@Transactional
|
|
||||||
fun updateExpectedDates(
|
|
||||||
@PathVariable propertyId: UUID,
|
|
||||||
@PathVariable bookingId: UUID,
|
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
|
||||||
@RequestBody request: BookingExpectedDatesUpdateRequest
|
|
||||||
) {
|
|
||||||
requireActor(propertyId, principal)
|
|
||||||
val booking = requireBooking(propertyId, bookingId)
|
|
||||||
when (booking.status) {
|
|
||||||
BookingStatus.OPEN -> {
|
|
||||||
if (request.expectedCheckInAt != null) {
|
|
||||||
booking.expectedCheckinAt = parseOffset(request.expectedCheckInAt)
|
|
||||||
}
|
|
||||||
if (request.expectedCheckOutAt != null) {
|
|
||||||
booking.expectedCheckoutAt = parseOffset(request.expectedCheckOutAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BookingStatus.CHECKED_IN -> {
|
|
||||||
if (request.expectedCheckInAt != null) {
|
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Cannot change expected check-in after check-in")
|
|
||||||
}
|
|
||||||
if (request.expectedCheckOutAt != null) {
|
|
||||||
booking.expectedCheckoutAt = parseOffset(request.expectedCheckOutAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BookingStatus.CHECKED_OUT,
|
|
||||||
BookingStatus.CANCELLED,
|
|
||||||
BookingStatus.NO_SHOW -> {
|
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking closed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val expectedIn = booking.expectedCheckinAt
|
|
||||||
val expectedOut = booking.expectedCheckoutAt
|
|
||||||
if (expectedIn != null && expectedOut != null && !expectedOut.isAfter(expectedIn)) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date range")
|
|
||||||
}
|
|
||||||
|
|
||||||
booking.updatedAt = OffsetDateTime.now()
|
|
||||||
bookingRepo.save(booking)
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/{bookingId}/check-out")
|
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
|
||||||
@Transactional
|
|
||||||
fun checkOut(
|
|
||||||
@PathVariable propertyId: UUID,
|
|
||||||
@PathVariable bookingId: UUID,
|
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
|
||||||
@RequestBody request: BookingCheckOutRequest
|
|
||||||
) {
|
|
||||||
requireActor(propertyId, principal)
|
|
||||||
val booking = requireBooking(propertyId, bookingId)
|
|
||||||
if (booking.status != BookingStatus.CHECKED_IN) {
|
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not checked in")
|
|
||||||
}
|
|
||||||
val now = OffsetDateTime.now()
|
|
||||||
val checkOutAt = parseOffset(request.checkOutAt) ?: now
|
|
||||||
|
|
||||||
val stays = roomStayRepo.findActiveByBookingId(bookingId)
|
|
||||||
stays.forEach { it.toAt = checkOutAt }
|
|
||||||
roomStayRepo.saveAll(stays)
|
|
||||||
|
|
||||||
booking.status = BookingStatus.CHECKED_OUT
|
|
||||||
booking.checkoutAt = checkOutAt
|
|
||||||
if (request.notes != null) booking.notes = request.notes
|
|
||||||
booking.updatedAt = now
|
|
||||||
bookingRepo.save(booking)
|
|
||||||
roomBoardEvents.emit(propertyId)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolveGuestForBooking(
|
|
||||||
propertyId: UUID,
|
|
||||||
property: com.android.trisolarisserver.models.property.Property,
|
|
||||||
actor: com.android.trisolarisserver.models.property.AppUser?,
|
|
||||||
now: OffsetDateTime,
|
|
||||||
phone: String?
|
|
||||||
): com.android.trisolarisserver.models.booking.Guest {
|
|
||||||
if (phone != null) {
|
|
||||||
val existing = guestRepo.findByPropertyIdAndPhoneE164(propertyId, phone)
|
|
||||||
if (existing != null) {
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val guest = com.android.trisolarisserver.models.booking.Guest(
|
|
||||||
property = property,
|
|
||||||
phoneE164 = phone,
|
|
||||||
createdBy = actor,
|
|
||||||
updatedAt = now
|
|
||||||
)
|
|
||||||
return guestRepo.save(guest)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseStatuses(raw: String?): Set<BookingStatus> {
|
|
||||||
if (raw.isNullOrBlank()) return emptySet()
|
|
||||||
return raw.split(",")
|
|
||||||
.map { it.trim() }
|
|
||||||
.filter { it.isNotEmpty() }
|
|
||||||
.map { value ->
|
|
||||||
try {
|
|
||||||
BookingStatus.valueOf(value.uppercase())
|
|
||||||
} catch (_: IllegalArgumentException) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid status: $value")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.toSet()
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/{bookingId}/cancel")
|
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
|
||||||
@Transactional
|
|
||||||
fun cancel(
|
|
||||||
@PathVariable propertyId: UUID,
|
|
||||||
@PathVariable bookingId: UUID,
|
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
|
||||||
@RequestBody request: BookingCancelRequest
|
|
||||||
) {
|
|
||||||
requireActor(propertyId, principal)
|
|
||||||
val booking = requireBooking(propertyId, bookingId)
|
|
||||||
if (booking.status == BookingStatus.CHECKED_IN) {
|
|
||||||
val active = roomStayRepo.findActiveByBookingId(bookingId)
|
|
||||||
if (active.isNotEmpty()) {
|
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Cannot cancel checked-in booking")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
booking.status = BookingStatus.CANCELLED
|
|
||||||
if (request.reason != null) booking.notes = request.reason
|
|
||||||
booking.updatedAt = OffsetDateTime.now()
|
|
||||||
bookingRepo.save(booking)
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/{bookingId}/no-show")
|
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
|
||||||
@Transactional
|
|
||||||
fun noShow(
|
|
||||||
@PathVariable propertyId: UUID,
|
|
||||||
@PathVariable bookingId: UUID,
|
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
|
||||||
@RequestBody request: BookingNoShowRequest
|
|
||||||
) {
|
|
||||||
requireActor(propertyId, principal)
|
|
||||||
val booking = requireBooking(propertyId, bookingId)
|
|
||||||
if (booking.status != BookingStatus.OPEN) {
|
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not open")
|
|
||||||
}
|
|
||||||
booking.status = BookingStatus.NO_SHOW
|
|
||||||
if (request.reason != null) booking.notes = request.reason
|
|
||||||
booking.updatedAt = OffsetDateTime.now()
|
|
||||||
bookingRepo.save(booking)
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/{bookingId}/room-stays")
|
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
|
||||||
@Transactional
|
|
||||||
fun preAssignRoom(
|
|
||||||
@PathVariable propertyId: UUID,
|
|
||||||
@PathVariable bookingId: UUID,
|
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
|
||||||
@RequestBody request: RoomStayPreAssignRequest
|
|
||||||
) {
|
|
||||||
val actor = requireActor(propertyId, principal)
|
|
||||||
val booking = requireBooking(propertyId, bookingId)
|
|
||||||
if (booking.status == BookingStatus.CANCELLED || booking.status == BookingStatus.NO_SHOW || booking.status == BookingStatus.CHECKED_OUT) {
|
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking closed")
|
|
||||||
}
|
|
||||||
|
|
||||||
val room = roomRepo.findByIdAndPropertyId(request.roomId, propertyId)
|
|
||||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found")
|
|
||||||
if (!room.active || room.maintenance) {
|
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Room not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
val fromAt = parseOffset(request.fromAt)
|
|
||||||
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "fromAt required")
|
|
||||||
val toAt = parseOffset(request.toAt)
|
|
||||||
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "toAt required")
|
|
||||||
if (!toAt.isAfter(fromAt)) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date range")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (roomStayRepo.existsOverlap(propertyId, request.roomId, fromAt, toAt)) {
|
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Room already reserved/occupied for range")
|
|
||||||
}
|
|
||||||
|
|
||||||
val stay = RoomStay(
|
|
||||||
property = booking.property,
|
|
||||||
booking = booking,
|
|
||||||
room = room,
|
|
||||||
fromAt = fromAt,
|
|
||||||
toAt = toAt,
|
|
||||||
rateSource = parseRateSource(request.rateSource),
|
|
||||||
nightlyRate = request.nightlyRate,
|
|
||||||
ratePlanCode = request.ratePlanCode,
|
|
||||||
currency = request.currency ?: booking.property.currency,
|
|
||||||
createdBy = actor
|
|
||||||
)
|
|
||||||
roomStayRepo.save(stay)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requireBooking(propertyId: UUID, bookingId: UUID): com.android.trisolarisserver.models.booking.Booking {
|
|
||||||
val booking = bookingRepo.findById(bookingId).orElseThrow {
|
|
||||||
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
|
|
||||||
}
|
|
||||||
if (booking.property.id != propertyId) {
|
|
||||||
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
|
|
||||||
}
|
|
||||||
return booking
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requireActor(propertyId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser {
|
|
||||||
val resolved = requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
|
|
||||||
return appUserRepo.findById(resolved.userId).orElseThrow {
|
|
||||||
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseTransportMode(value: String): TransportMode {
|
|
||||||
return try {
|
|
||||||
TransportMode.valueOf(value)
|
|
||||||
} catch (_: IllegalArgumentException) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown transport mode")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseMemberRelation(value: String?): MemberRelation? {
|
|
||||||
if (value.isNullOrBlank()) return null
|
|
||||||
return try {
|
|
||||||
MemberRelation.valueOf(value.trim().uppercase())
|
|
||||||
} catch (_: IllegalArgumentException) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown member relation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseRateSource(value: String?): RateSource? {
|
|
||||||
if (value.isNullOrBlank()) return null
|
|
||||||
return try {
|
|
||||||
RateSource.valueOf(value.trim().uppercase())
|
|
||||||
} catch (_: IllegalArgumentException) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown rate source")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun computeExpectedPay(stays: List<RoomStay>, timezone: String?): Long {
|
|
||||||
if (stays.isEmpty()) return 0
|
|
||||||
val now = nowForProperty(timezone)
|
|
||||||
var total = 0L
|
|
||||||
stays.forEach { stay ->
|
|
||||||
val rate = stay.nightlyRate ?: 0L
|
|
||||||
if (rate == 0L) return@forEach
|
|
||||||
val start = stay.fromAt.toLocalDate()
|
|
||||||
val endAt = stay.toAt ?: now
|
|
||||||
val end = endAt.toLocalDate()
|
|
||||||
val nights = daysBetweenInclusive(start, end)
|
|
||||||
total += rate * nights
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun computeExpectedPayTotal(
|
|
||||||
stays: List<RoomStay>,
|
|
||||||
expectedCheckoutAt: OffsetDateTime?,
|
|
||||||
timezone: String?
|
|
||||||
): Long {
|
|
||||||
if (stays.isEmpty()) return 0
|
|
||||||
val now = nowForProperty(timezone)
|
|
||||||
var total = 0L
|
|
||||||
stays.forEach { stay ->
|
|
||||||
val rate = stay.nightlyRate ?: 0L
|
|
||||||
if (rate == 0L) return@forEach
|
|
||||||
val start = stay.fromAt.toLocalDate()
|
|
||||||
val endAt = stay.toAt ?: expectedCheckoutAt ?: now
|
|
||||||
val end = endAt.toLocalDate()
|
|
||||||
val nights = daysBetweenInclusive(start, end)
|
|
||||||
total += rate * nights
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun daysBetweenInclusive(start: java.time.LocalDate, end: java.time.LocalDate): Long {
|
|
||||||
val diff = end.toEpochDay() - start.toEpochDay()
|
|
||||||
return if (diff <= 0) 1L else diff
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isTransportModeAllowed(
|
|
||||||
property: com.android.trisolarisserver.models.property.Property,
|
|
||||||
mode: TransportMode
|
|
||||||
): Boolean {
|
|
||||||
val allowed = property.allowedTransportModes.ifEmpty {
|
|
||||||
TransportMode.entries.toSet()
|
|
||||||
}
|
|
||||||
return allowed.contains(mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isPlaceholderGuest(guest: com.android.trisolarisserver.models.booking.Guest): Boolean {
|
|
||||||
return guest.phoneE164.isNullOrBlank() &&
|
|
||||||
guest.name.isNullOrBlank() &&
|
|
||||||
guest.nationality.isNullOrBlank() &&
|
|
||||||
guest.addressText.isNullOrBlank() &&
|
|
||||||
guest.signaturePath.isNullOrBlank()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isSafeToDelete(guest: com.android.trisolarisserver.models.booking.Guest): Boolean {
|
|
||||||
val id = guest.id ?: return false
|
|
||||||
if (bookingRepo.countByPrimaryGuestId(id) > 0) return false
|
|
||||||
if (guestVehicleRepo.existsByGuestId(id)) return false
|
|
||||||
if (guestDocumentRepo.existsByGuestId(id)) return false
|
|
||||||
if (guestRatingRepo.existsByGuestId(id)) return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
package com.android.trisolarisserver.controller
|
|
||||||
|
|
||||||
import com.android.trisolarisserver.db.repo.GuestRepo
|
|
||||||
import com.android.trisolarisserver.models.booking.Guest
|
|
||||||
import com.android.trisolarisserver.models.property.Property
|
|
||||||
import com.android.trisolarisserver.models.room.RoomStay
|
|
||||||
import com.android.trisolarisserver.repo.PropertyRepo
|
|
||||||
import com.android.trisolarisserver.repo.RoomStayRepo
|
|
||||||
import org.springframework.http.HttpStatus
|
|
||||||
import org.springframework.web.server.ResponseStatusException
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
internal data class PropertyGuest(
|
|
||||||
val property: Property,
|
|
||||||
val guest: Guest
|
|
||||||
)
|
|
||||||
|
|
||||||
internal fun requireProperty(propertyRepo: PropertyRepo, propertyId: UUID): Property {
|
|
||||||
return propertyRepo.findById(propertyId).orElseThrow {
|
|
||||||
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun requirePropertyGuest(
|
|
||||||
propertyRepo: PropertyRepo,
|
|
||||||
guestRepo: GuestRepo,
|
|
||||||
propertyId: UUID,
|
|
||||||
guestId: UUID
|
|
||||||
): PropertyGuest {
|
|
||||||
val property = requireProperty(propertyRepo, propertyId)
|
|
||||||
val guest = guestRepo.findById(guestId).orElseThrow {
|
|
||||||
ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found")
|
|
||||||
}
|
|
||||||
if (guest.property.id != property.id) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property")
|
|
||||||
}
|
|
||||||
return PropertyGuest(property, guest)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun requireRoomStayForProperty(
|
|
||||||
roomStayRepo: RoomStayRepo,
|
|
||||||
propertyId: UUID,
|
|
||||||
roomStayId: UUID
|
|
||||||
): RoomStay {
|
|
||||||
val stay = roomStayRepo.findById(roomStayId).orElseThrow {
|
|
||||||
ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found")
|
|
||||||
}
|
|
||||||
if (stay.property.id != propertyId) {
|
|
||||||
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property")
|
|
||||||
}
|
|
||||||
return stay
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun requireOpenRoomStayForProperty(
|
|
||||||
roomStayRepo: RoomStayRepo,
|
|
||||||
propertyId: UUID,
|
|
||||||
roomStayId: UUID,
|
|
||||||
closedMessage: String
|
|
||||||
): RoomStay {
|
|
||||||
val stay = requireRoomStayForProperty(roomStayRepo, propertyId, roomStayId)
|
|
||||||
if (stay.toAt != null) {
|
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, closedMessage)
|
|
||||||
}
|
|
||||||
return stay
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun parseOffset(value: String?): OffsetDateTime? {
|
|
||||||
if (value.isNullOrBlank()) return null
|
|
||||||
return try {
|
|
||||||
OffsetDateTime.parse(value.trim())
|
|
||||||
} catch (_: Exception) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun nowForProperty(timezone: String?): OffsetDateTime {
|
|
||||||
val zone = try {
|
|
||||||
if (timezone.isNullOrBlank()) ZoneId.of("Asia/Kolkata") else ZoneId.of(timezone)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
ZoneId.of("Asia/Kolkata")
|
|
||||||
}
|
|
||||||
return OffsetDateTime.now(zone)
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
package com.android.trisolarisserver.controller
|
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.PropertyAccess
|
|
||||||
import com.android.trisolarisserver.controller.dto.PayuPaymentLinkSettingsResponse
|
|
||||||
import com.android.trisolarisserver.controller.dto.PayuPaymentLinkSettingsUpsertRequest
|
|
||||||
import com.android.trisolarisserver.models.payment.PayuPaymentLinkSettings
|
|
||||||
import com.android.trisolarisserver.models.property.Role
|
|
||||||
import com.android.trisolarisserver.repo.PayuPaymentLinkSettingsRepo
|
|
||||||
import com.android.trisolarisserver.repo.PropertyRepo
|
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
|
||||||
import org.springframework.http.HttpStatus
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
|
||||||
import org.springframework.web.bind.annotation.PutMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
import org.springframework.web.server.ResponseStatusException
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/properties/{propertyId}/payu-payment-link-settings")
|
|
||||||
class PayuPaymentLinkSettingsController(
|
|
||||||
private val propertyAccess: PropertyAccess,
|
|
||||||
private val propertyRepo: PropertyRepo,
|
|
||||||
private val settingsRepo: PayuPaymentLinkSettingsRepo
|
|
||||||
) {
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
fun getSettings(
|
|
||||||
@PathVariable propertyId: UUID,
|
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?
|
|
||||||
): PayuPaymentLinkSettingsResponse {
|
|
||||||
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
|
|
||||||
val settings = settingsRepo.findByPropertyId(propertyId)
|
|
||||||
if (settings == null) {
|
|
||||||
return PayuPaymentLinkSettingsResponse(
|
|
||||||
propertyId = propertyId,
|
|
||||||
configured = false,
|
|
||||||
merchantId = null,
|
|
||||||
isTest = false,
|
|
||||||
hasClientId = false,
|
|
||||||
hasClientSecret = false,
|
|
||||||
hasAccessToken = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return settings.toResponse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@PutMapping
|
|
||||||
fun upsertSettings(
|
|
||||||
@PathVariable propertyId: UUID,
|
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
|
||||||
@RequestBody request: PayuPaymentLinkSettingsUpsertRequest
|
|
||||||
): PayuPaymentLinkSettingsResponse {
|
|
||||||
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
|
|
||||||
val property = propertyRepo.findById(propertyId).orElseThrow {
|
|
||||||
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
|
||||||
}
|
|
||||||
val merchantId = request.merchantId.trim().ifBlank {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "merchantId required")
|
|
||||||
}
|
|
||||||
val isTest = request.isTest ?: false
|
|
||||||
val existing = settingsRepo.findByPropertyId(propertyId)
|
|
||||||
val updated = if (existing == null) {
|
|
||||||
PayuPaymentLinkSettings(
|
|
||||||
property = property,
|
|
||||||
merchantId = merchantId,
|
|
||||||
clientId = request.clientId?.trim()?.ifBlank { null },
|
|
||||||
clientSecret = request.clientSecret?.trim()?.ifBlank { null },
|
|
||||||
accessToken = request.accessToken?.trim()?.ifBlank { null },
|
|
||||||
isTest = isTest,
|
|
||||||
updatedAt = OffsetDateTime.now()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
existing.merchantId = merchantId
|
|
||||||
val oldClientId = existing.clientId
|
|
||||||
val oldClientSecret = existing.clientSecret
|
|
||||||
val oldIsTest = existing.isTest
|
|
||||||
if (request.clientId != null) existing.clientId = request.clientId.trim().ifBlank { null }
|
|
||||||
if (request.clientSecret != null) existing.clientSecret = request.clientSecret.trim().ifBlank { null }
|
|
||||||
if (request.accessToken != null) existing.accessToken = request.accessToken.trim().ifBlank { null }
|
|
||||||
existing.isTest = isTest
|
|
||||||
val credsChanged = (request.clientId != null && existing.clientId != oldClientId) ||
|
|
||||||
(request.clientSecret != null && existing.clientSecret != oldClientSecret) ||
|
|
||||||
oldIsTest != isTest
|
|
||||||
if (credsChanged) {
|
|
||||||
existing.accessToken = null
|
|
||||||
existing.tokenExpiresAt = null
|
|
||||||
}
|
|
||||||
existing.updatedAt = OffsetDateTime.now()
|
|
||||||
existing
|
|
||||||
}
|
|
||||||
return settingsRepo.save(updated).toResponse()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun PayuPaymentLinkSettings.toResponse(): PayuPaymentLinkSettingsResponse {
|
|
||||||
val propertyId = property.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Property id missing")
|
|
||||||
return PayuPaymentLinkSettingsResponse(
|
|
||||||
propertyId = propertyId,
|
|
||||||
configured = true,
|
|
||||||
merchantId = merchantId,
|
|
||||||
isTest = isTest,
|
|
||||||
hasClientId = !clientId.isNullOrBlank(),
|
|
||||||
hasClientSecret = !clientSecret.isNullOrBlank(),
|
|
||||||
hasAccessToken = !accessToken.isNullOrBlank()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
package com.android.trisolarisserver.controller
|
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.PropertyAccess
|
|
||||||
import com.android.trisolarisserver.controller.dto.PayuPaymentLinkCreateRequest
|
|
||||||
import com.android.trisolarisserver.controller.dto.PayuPaymentLinkCreateResponse
|
|
||||||
import com.android.trisolarisserver.models.booking.BookingStatus
|
|
||||||
import com.android.trisolarisserver.models.property.Role
|
|
||||||
import com.android.trisolarisserver.db.repo.BookingRepo
|
|
||||||
import com.android.trisolarisserver.repo.PaymentRepo
|
|
||||||
import com.android.trisolarisserver.repo.PayuPaymentLinkSettingsRepo
|
|
||||||
import com.android.trisolarisserver.repo.RoomStayRepo
|
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
|
||||||
import org.springframework.http.HttpEntity
|
|
||||||
import org.springframework.http.HttpHeaders
|
|
||||||
import org.springframework.http.HttpStatus
|
|
||||||
import org.springframework.http.MediaType
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
import org.springframework.web.client.RestTemplate
|
|
||||||
import org.springframework.web.server.ResponseStatusException
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/properties/{propertyId}/bookings/{bookingId}/payments/payu")
|
|
||||||
class PayuPaymentLinksController(
|
|
||||||
private val propertyAccess: PropertyAccess,
|
|
||||||
private val bookingRepo: BookingRepo,
|
|
||||||
private val roomStayRepo: RoomStayRepo,
|
|
||||||
private val paymentRepo: PaymentRepo,
|
|
||||||
private val settingsRepo: PayuPaymentLinkSettingsRepo,
|
|
||||||
private val restTemplate: RestTemplate,
|
|
||||||
private val objectMapper: ObjectMapper
|
|
||||||
) {
|
|
||||||
|
|
||||||
@PostMapping("/link")
|
|
||||||
@Transactional
|
|
||||||
fun createPaymentLink(
|
|
||||||
@PathVariable propertyId: UUID,
|
|
||||||
@PathVariable bookingId: UUID,
|
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
|
||||||
@RequestBody request: PayuPaymentLinkCreateRequest
|
|
||||||
): PayuPaymentLinkCreateResponse {
|
|
||||||
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.FINANCE)
|
|
||||||
|
|
||||||
val booking = bookingRepo.findById(bookingId).orElseThrow {
|
|
||||||
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
|
|
||||||
}
|
|
||||||
if (booking.property.id != propertyId) {
|
|
||||||
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
|
|
||||||
}
|
|
||||||
if (booking.status == BookingStatus.CANCELLED || booking.status == BookingStatus.NO_SHOW || booking.status == BookingStatus.CHECKED_OUT) {
|
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking closed")
|
|
||||||
}
|
|
||||||
|
|
||||||
val settings = settingsRepo.findByPropertyId(propertyId)
|
|
||||||
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "PayU payment link settings not configured")
|
|
||||||
|
|
||||||
val stays = roomStayRepo.findByBookingId(bookingId)
|
|
||||||
val expectedPay = computeExpectedPay(stays, booking.property.timezone)
|
|
||||||
val collected = paymentRepo.sumAmountByBookingId(bookingId)
|
|
||||||
val pending = expectedPay - collected
|
|
||||||
if (pending <= 0) {
|
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "No pending amount")
|
|
||||||
}
|
|
||||||
|
|
||||||
val isAmountFilledByCustomer = request.isAmountFilledByCustomer ?: false
|
|
||||||
val requestedAmount = request.amount?.takeIf { it > 0 }
|
|
||||||
if (!isAmountFilledByCustomer && requestedAmount == null) {
|
|
||||||
// default to pending if not open amount
|
|
||||||
}
|
|
||||||
if (requestedAmount != null && requestedAmount > pending) {
|
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Amount exceeds pending")
|
|
||||||
}
|
|
||||||
val amountLong = if (isAmountFilledByCustomer) null else (requestedAmount ?: pending)
|
|
||||||
|
|
||||||
val guest = booking.primaryGuest
|
|
||||||
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Booking missing guest")
|
|
||||||
val customerName = guest.name?.trim()?.ifBlank { null } ?: "Guest"
|
|
||||||
val customerPhone = guest.phoneE164?.trim()?.ifBlank { null }
|
|
||||||
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest phone missing")
|
|
||||||
val customerEmail = "guest-${bookingId.toString().substring(0, 8)}@hoteltrisolaris.in"
|
|
||||||
|
|
||||||
val body = mutableMapOf<String, Any>(
|
|
||||||
"description" to (request.description?.trim()?.ifBlank { null } ?: "Booking $bookingId"),
|
|
||||||
"source" to "API",
|
|
||||||
"isPartialPaymentAllowed" to (request.isPartialPaymentAllowed ?: false),
|
|
||||||
"isAmountFilledByCustomer" to isAmountFilledByCustomer,
|
|
||||||
"customer" to mapOf(
|
|
||||||
"name" to customerName,
|
|
||||||
"email" to customerEmail,
|
|
||||||
"phone" to customerPhone
|
|
||||||
),
|
|
||||||
"udf" to mapOf(
|
|
||||||
"udf1" to bookingId.toString(),
|
|
||||||
"udf2" to propertyId.toString(),
|
|
||||||
"udf3" to (request.udf3?.trim()?.ifBlank { null }),
|
|
||||||
"udf4" to (request.udf4?.trim()?.ifBlank { null }),
|
|
||||||
"udf5" to (request.udf5?.trim()?.ifBlank { null })
|
|
||||||
),
|
|
||||||
"viaEmail" to (request.viaEmail ?: false),
|
|
||||||
"viaSms" to (request.viaSms ?: false)
|
|
||||||
)
|
|
||||||
request.successUrl?.trim()?.ifBlank { null }?.let { body["successURL"] = it }
|
|
||||||
request.failureUrl?.trim()?.ifBlank { null }?.let { body["failureURL"] = it }
|
|
||||||
if (amountLong != null) {
|
|
||||||
body["subAmount"] = amountLong
|
|
||||||
}
|
|
||||||
if (request.minAmountForCustomer != null) {
|
|
||||||
body["minAmountForCustomer"] = request.minAmountForCustomer
|
|
||||||
}
|
|
||||||
request.expiryDate?.trim()?.ifBlank { null }?.let { body["expiryDate"] = it }
|
|
||||||
|
|
||||||
val accessToken = resolveAccessToken(settings)
|
|
||||||
settingsRepo.save(settings)
|
|
||||||
val headers = HttpHeaders().apply {
|
|
||||||
contentType = MediaType.APPLICATION_JSON
|
|
||||||
set("Authorization", "Bearer $accessToken")
|
|
||||||
set("merchantId", settings.merchantId)
|
|
||||||
set("mid", settings.merchantId)
|
|
||||||
}
|
|
||||||
val entity = HttpEntity(body, headers)
|
|
||||||
val response = restTemplate.postForEntity(resolveBaseUrl(settings.isTest), entity, String::class.java)
|
|
||||||
val responseBody = response.body ?: ""
|
|
||||||
if (!response.statusCode.is2xxSuccessful) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "PayU request failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
val paymentLink = extractPaymentLink(responseBody)
|
|
||||||
return PayuPaymentLinkCreateResponse(
|
|
||||||
amount = amountLong ?: pending,
|
|
||||||
currency = booking.property.currency,
|
|
||||||
paymentLink = paymentLink,
|
|
||||||
payuResponse = responseBody
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolveBaseUrl(isTest: Boolean): String {
|
|
||||||
return if (isTest) {
|
|
||||||
"https://uatoneapi.payu.in/payment-links"
|
|
||||||
} else {
|
|
||||||
"https://oneapi.payu.in/payment-links"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolveAccessToken(settings: com.android.trisolarisserver.models.payment.PayuPaymentLinkSettings): String {
|
|
||||||
val now = OffsetDateTime.now()
|
|
||||||
val existing = settings.accessToken?.trim()?.ifBlank { null }
|
|
||||||
val expiresAt = settings.tokenExpiresAt
|
|
||||||
if (existing != null && expiresAt != null && expiresAt.isAfter(now.plusSeconds(60))) {
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
val clientId = settings.clientId?.trim()?.ifBlank { null }
|
|
||||||
val clientSecret = settings.clientSecret?.trim()?.ifBlank { null }
|
|
||||||
if (clientId == null || clientSecret == null) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Payment link client credentials missing")
|
|
||||||
}
|
|
||||||
val tokenResponse = fetchAccessToken(settings.isTest, clientId, clientSecret)
|
|
||||||
settings.accessToken = tokenResponse.accessToken
|
|
||||||
settings.tokenExpiresAt = now.plusSeconds(tokenResponse.expiresIn.toLong())
|
|
||||||
return tokenResponse.accessToken
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class TokenResponse(val accessToken: String, val expiresIn: Int)
|
|
||||||
|
|
||||||
private fun fetchAccessToken(isTest: Boolean, clientId: String, clientSecret: String): TokenResponse {
|
|
||||||
val url = if (isTest) {
|
|
||||||
"https://uat-accounts.payu.in/oauth/token"
|
|
||||||
} else {
|
|
||||||
"https://accounts.payu.in/oauth/token"
|
|
||||||
}
|
|
||||||
val form = org.springframework.util.LinkedMultiValueMap<String, String>().apply {
|
|
||||||
add("client_id", clientId)
|
|
||||||
add("client_secret", clientSecret)
|
|
||||||
add("grant_type", "client_credentials")
|
|
||||||
add("scope", "create_payment_links")
|
|
||||||
}
|
|
||||||
val headers = HttpHeaders().apply {
|
|
||||||
contentType = MediaType.APPLICATION_FORM_URLENCODED
|
|
||||||
}
|
|
||||||
val entity = HttpEntity(form, headers)
|
|
||||||
val response = restTemplate.postForEntity(url, entity, String::class.java)
|
|
||||||
val body = response.body ?: ""
|
|
||||||
if (!response.statusCode.is2xxSuccessful) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "PayU token request failed")
|
|
||||||
}
|
|
||||||
return try {
|
|
||||||
val node = objectMapper.readTree(body)
|
|
||||||
val token = node.path("access_token").asText(null)
|
|
||||||
val expiresIn = node.path("expires_in").asInt(0)
|
|
||||||
if (token.isNullOrBlank() || expiresIn <= 0) {
|
|
||||||
throw IllegalStateException("Token missing")
|
|
||||||
}
|
|
||||||
TokenResponse(token, expiresIn)
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "PayU token parse failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun extractPaymentLink(body: String): String? {
|
|
||||||
if (body.isBlank()) return null
|
|
||||||
return try {
|
|
||||||
val node = objectMapper.readTree(body)
|
|
||||||
val link = node.path("result").path("paymentLink").asText(null)
|
|
||||||
link?.takeIf { it.isNotBlank() }
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private fun computeExpectedPay(stays: List<com.android.trisolarisserver.models.room.RoomStay>, timezone: String?): Long {
|
|
||||||
if (stays.isEmpty()) return 0
|
|
||||||
val now = nowForProperty(timezone)
|
|
||||||
var total = 0L
|
|
||||||
stays.forEach { stay ->
|
|
||||||
val rate = stay.nightlyRate ?: 0L
|
|
||||||
if (rate == 0L) return@forEach
|
|
||||||
val start = stay.fromAt.toLocalDate()
|
|
||||||
val endAt = stay.toAt ?: now
|
|
||||||
val end = endAt.toLocalDate()
|
|
||||||
val nights = daysBetweenInclusive(start, end)
|
|
||||||
total += rate * nights
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun daysBetweenInclusive(start: java.time.LocalDate, end: java.time.LocalDate): Long {
|
|
||||||
val diff = end.toEpochDay() - start.toEpochDay()
|
|
||||||
return if (diff <= 0) 1L else diff
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
package com.android.trisolarisserver.controller
|
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.PropertyAccess
|
|
||||||
import com.android.trisolarisserver.controller.dto.PayuQrGenerateRequest
|
|
||||||
import com.android.trisolarisserver.controller.dto.PayuQrGenerateResponse
|
|
||||||
import com.android.trisolarisserver.models.booking.BookingStatus
|
|
||||||
import com.android.trisolarisserver.models.payment.PayuQrRequest
|
|
||||||
import com.android.trisolarisserver.models.payment.PayuQrStatus
|
|
||||||
import com.android.trisolarisserver.models.property.Role
|
|
||||||
import com.android.trisolarisserver.db.repo.BookingRepo
|
|
||||||
import com.android.trisolarisserver.repo.PaymentRepo
|
|
||||||
import com.android.trisolarisserver.repo.PayuQrRequestRepo
|
|
||||||
import com.android.trisolarisserver.repo.PayuSettingsRepo
|
|
||||||
import com.android.trisolarisserver.repo.RoomStayRepo
|
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
|
||||||
import org.springframework.http.HttpStatus
|
|
||||||
import org.springframework.http.MediaType
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
import org.springframework.util.LinkedMultiValueMap
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
import org.springframework.web.client.RestTemplate
|
|
||||||
import org.springframework.web.server.ResponseStatusException
|
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/properties/{propertyId}/bookings/{bookingId}/payments/payu")
|
|
||||||
class PayuQrPayments(
|
|
||||||
private val propertyAccess: PropertyAccess,
|
|
||||||
private val bookingRepo: BookingRepo,
|
|
||||||
private val roomStayRepo: RoomStayRepo,
|
|
||||||
private val paymentRepo: PaymentRepo,
|
|
||||||
private val payuSettingsRepo: PayuSettingsRepo,
|
|
||||||
private val payuQrRequestRepo: PayuQrRequestRepo,
|
|
||||||
private val restTemplate: RestTemplate
|
|
||||||
) {
|
|
||||||
private val defaultExpirySeconds = 30 * 60
|
|
||||||
|
|
||||||
@PostMapping("/qr")
|
|
||||||
@Transactional
|
|
||||||
fun generateQr(
|
|
||||||
@PathVariable propertyId: UUID,
|
|
||||||
@PathVariable bookingId: UUID,
|
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
|
||||||
@RequestBody request: PayuQrGenerateRequest,
|
|
||||||
httpRequest: HttpServletRequest
|
|
||||||
): PayuQrGenerateResponse {
|
|
||||||
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.FINANCE)
|
|
||||||
|
|
||||||
val booking = bookingRepo.findById(bookingId).orElseThrow {
|
|
||||||
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
|
|
||||||
}
|
|
||||||
if (booking.property.id != propertyId) {
|
|
||||||
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
|
|
||||||
}
|
|
||||||
if (booking.status == BookingStatus.CANCELLED || booking.status == BookingStatus.NO_SHOW || booking.status == BookingStatus.CHECKED_OUT) {
|
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking closed")
|
|
||||||
}
|
|
||||||
|
|
||||||
val settings = payuSettingsRepo.findByPropertyId(propertyId)
|
|
||||||
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "PayU settings not configured")
|
|
||||||
val salt = pickSalt(settings)
|
|
||||||
|
|
||||||
val stays = roomStayRepo.findByBookingId(bookingId)
|
|
||||||
val expectedPay = computeExpectedPay(stays, booking.property.timezone)
|
|
||||||
val collected = paymentRepo.sumAmountByBookingId(bookingId)
|
|
||||||
val pending = expectedPay - collected
|
|
||||||
if (pending <= 0) {
|
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "No pending amount")
|
|
||||||
}
|
|
||||||
val requestedAmount = request.amount?.takeIf { it > 0 }
|
|
||||||
if (requestedAmount != null && requestedAmount > pending) {
|
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Amount exceeds pending")
|
|
||||||
}
|
|
||||||
val amountLong = requestedAmount ?: pending
|
|
||||||
val expirySeconds = request.expirySeconds
|
|
||||||
?: request.expiryMinutes?.let { it * 60 }
|
|
||||||
?: defaultExpirySeconds
|
|
||||||
|
|
||||||
val existing = payuQrRequestRepo.findTopByBookingIdAndAmountAndCurrencyAndStatusOrderByCreatedAtDesc(
|
|
||||||
bookingId,
|
|
||||||
amountLong,
|
|
||||||
booking.property.currency,
|
|
||||||
PayuQrStatus.SENT
|
|
||||||
)
|
|
||||||
if (existing != null) {
|
|
||||||
val expiryAt = existing.expiryAt
|
|
||||||
val responsePayload = existing.responsePayload
|
|
||||||
if (expiryAt != null && !responsePayload.isNullOrBlank()) {
|
|
||||||
val now = OffsetDateTime.now()
|
|
||||||
if (now.isBefore(expiryAt)) {
|
|
||||||
return PayuQrGenerateResponse(
|
|
||||||
txnid = existing.txnid,
|
|
||||||
amount = amountLong,
|
|
||||||
currency = booking.property.currency,
|
|
||||||
payuResponse = responsePayload
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val txnid = "QR${bookingId.toString().substring(0, 8)}${System.currentTimeMillis()}"
|
|
||||||
val productInfo = "Booking $bookingId"
|
|
||||||
val guest = booking.primaryGuest
|
|
||||||
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Booking missing guest")
|
|
||||||
val firstname = guest.name?.trim()?.ifBlank { null } ?: "Guest"
|
|
||||||
val phone = guest.phoneE164?.trim()?.ifBlank { null }
|
|
||||||
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest phone missing")
|
|
||||||
val email = "guest-${bookingId.toString().substring(0, 8)}@hoteltrisolaris.in"
|
|
||||||
val amount = String.format("%.2f", amountLong.toDouble())
|
|
||||||
|
|
||||||
val udf1 = bookingId.toString()
|
|
||||||
val udf2 = propertyId.toString()
|
|
||||||
val udf3 = request.udf3?.trim()?.ifBlank { "" } ?: ""
|
|
||||||
val udf4 = request.udf4?.trim()?.ifBlank { "" } ?: ""
|
|
||||||
val udf5 = request.udf5?.trim()?.ifBlank { "" } ?: ""
|
|
||||||
val hash = sha512(
|
|
||||||
listOf(
|
|
||||||
settings.merchantKey,
|
|
||||||
txnid,
|
|
||||||
amount,
|
|
||||||
productInfo,
|
|
||||||
firstname,
|
|
||||||
email,
|
|
||||||
udf1,
|
|
||||||
udf2,
|
|
||||||
udf3,
|
|
||||||
udf4,
|
|
||||||
udf5,
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
salt
|
|
||||||
).joinToString("|")
|
|
||||||
)
|
|
||||||
|
|
||||||
val form = LinkedMultiValueMap<String, String>().apply {
|
|
||||||
set("key", settings.merchantKey)
|
|
||||||
set("txnid", txnid)
|
|
||||||
set("amount", amount)
|
|
||||||
set("productinfo", productInfo)
|
|
||||||
set("firstname", firstname)
|
|
||||||
set("email", email)
|
|
||||||
set("phone", phone)
|
|
||||||
set("surl", buildReturnUrl(propertyId, true))
|
|
||||||
set("furl", buildReturnUrl(propertyId, false))
|
|
||||||
set("pg", "DBQR")
|
|
||||||
set("bankcode", "UPIDBQR")
|
|
||||||
set("hash", hash)
|
|
||||||
set("udf1", udf1)
|
|
||||||
set("udf2", udf2)
|
|
||||||
set("udf3", udf3) // always
|
|
||||||
set("udf4", udf4) // always
|
|
||||||
set("udf5", udf5) // always
|
|
||||||
set("txn_s2s_flow", "4")
|
|
||||||
val clientIp = request.clientIp?.trim()?.ifBlank { null }
|
|
||||||
?: extractClientIp(httpRequest)
|
|
||||||
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "clientIp required")
|
|
||||||
val deviceInfo = request.deviceInfo?.trim()?.ifBlank { null }
|
|
||||||
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "deviceInfo required")
|
|
||||||
set("s2s_client_ip", clientIp)
|
|
||||||
set("s2s_device_info", deviceInfo)
|
|
||||||
set("expiry_time", expirySeconds.toString())
|
|
||||||
request.address1?.trim()?.ifBlank { null }?.let { add("address1", it) }
|
|
||||||
request.address2?.trim()?.ifBlank { null }?.let { add("address2", it) }
|
|
||||||
request.city?.trim()?.ifBlank { null }?.let { add("city", it) }
|
|
||||||
request.state?.trim()?.ifBlank { null }?.let { add("state", it) }
|
|
||||||
request.country?.trim()?.ifBlank { null }?.let { add("country", it) }
|
|
||||||
request.zipcode?.trim()?.ifBlank { null }?.let { add("zipcode", it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
val requestPayload = form.entries.joinToString("&") { entry ->
|
|
||||||
entry.value.joinToString("&") { value -> "${entry.key}=$value" }
|
|
||||||
}
|
|
||||||
|
|
||||||
val createdAt = OffsetDateTime.now()
|
|
||||||
val expiryAt = createdAt.plusSeconds(expirySeconds.toLong())
|
|
||||||
val record = payuQrRequestRepo.save(
|
|
||||||
PayuQrRequest(
|
|
||||||
property = booking.property,
|
|
||||||
booking = booking,
|
|
||||||
txnid = txnid,
|
|
||||||
amount = amountLong,
|
|
||||||
currency = booking.property.currency,
|
|
||||||
status = PayuQrStatus.CREATED,
|
|
||||||
requestPayload = requestPayload,
|
|
||||||
expiryAt = expiryAt,
|
|
||||||
createdAt = createdAt
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val headers = org.springframework.http.HttpHeaders().apply {
|
|
||||||
contentType = MediaType.APPLICATION_FORM_URLENCODED
|
|
||||||
}
|
|
||||||
val entity = org.springframework.http.HttpEntity(form, headers)
|
|
||||||
val response = restTemplate.postForEntity(resolveBaseUrl(settings), entity, String::class.java)
|
|
||||||
val responseBody = response.body ?: ""
|
|
||||||
|
|
||||||
record.responsePayload = responseBody
|
|
||||||
record.status = if (response.statusCode.is2xxSuccessful) PayuQrStatus.SENT else PayuQrStatus.FAILED
|
|
||||||
payuQrRequestRepo.save(record)
|
|
||||||
|
|
||||||
if (!response.statusCode.is2xxSuccessful) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "PayU request failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
return PayuQrGenerateResponse(
|
|
||||||
txnid = txnid,
|
|
||||||
amount = amountLong,
|
|
||||||
currency = booking.property.currency,
|
|
||||||
payuResponse = responseBody
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun pickSalt(settings: com.android.trisolarisserver.models.payment.PayuSettings): String {
|
|
||||||
val salt = if (settings.useSalt256) settings.salt256 else settings.salt32
|
|
||||||
return salt?.trim()?.ifBlank { null }
|
|
||||||
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "PayU salt missing")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildReturnUrl(propertyId: UUID, success: Boolean): String {
|
|
||||||
val path = if (success) "success" else "failure"
|
|
||||||
return "https://api.hoteltrisolaris.in/properties/$propertyId/payu/return/$path"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolveBaseUrl(settings: com.android.trisolarisserver.models.payment.PayuSettings): String {
|
|
||||||
return if (settings.isTest) {
|
|
||||||
"https://test.payu.in/_payment"
|
|
||||||
} else {
|
|
||||||
"https://secure.payu.in/_payment"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sha512(input: String): String {
|
|
||||||
val bytes = MessageDigest.getInstance("SHA-512").digest(input.toByteArray(Charsets.UTF_8))
|
|
||||||
return bytes.joinToString("") { "%02x".format(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun extractClientIp(request: HttpServletRequest): String? {
|
|
||||||
val forwarded = request.getHeader("X-Forwarded-For")
|
|
||||||
?.split(",")
|
|
||||||
?.firstOrNull()
|
|
||||||
?.trim()
|
|
||||||
?.ifBlank { null }
|
|
||||||
if (forwarded != null) return forwarded
|
|
||||||
val realIp = request.getHeader("X-Real-IP")?.trim()?.ifBlank { null }
|
|
||||||
if (realIp != null) return realIp
|
|
||||||
return request.remoteAddr?.trim()?.ifBlank { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun computeExpectedPay(stays: List<com.android.trisolarisserver.models.room.RoomStay>, timezone: String?): Long {
|
|
||||||
if (stays.isEmpty()) return 0
|
|
||||||
val now = nowForProperty(timezone)
|
|
||||||
var total = 0L
|
|
||||||
stays.forEach { stay ->
|
|
||||||
val rate = stay.nightlyRate ?: 0L
|
|
||||||
if (rate == 0L) return@forEach
|
|
||||||
val start = stay.fromAt.toLocalDate()
|
|
||||||
val endAt = stay.toAt ?: now
|
|
||||||
val end = endAt.toLocalDate()
|
|
||||||
val nights = daysBetweenInclusive(start, end)
|
|
||||||
total += rate * nights
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun daysBetweenInclusive(start: java.time.LocalDate, end: java.time.LocalDate): Long {
|
|
||||||
val diff = end.toEpochDay() - start.toEpochDay()
|
|
||||||
return if (diff <= 0) 1L else diff
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
package com.android.trisolarisserver.controller
|
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.PropertyAccess
|
|
||||||
import com.android.trisolarisserver.controller.dto.PayuSettingsUpsertRequest
|
|
||||||
import com.android.trisolarisserver.controller.dto.PayuSettingsResponse
|
|
||||||
import com.android.trisolarisserver.models.payment.PayuSettings
|
|
||||||
import com.android.trisolarisserver.models.property.Role
|
|
||||||
import com.android.trisolarisserver.repo.PayuSettingsRepo
|
|
||||||
import com.android.trisolarisserver.repo.PropertyRepo
|
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
|
||||||
import org.springframework.http.HttpStatus
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
|
||||||
import org.springframework.web.bind.annotation.PutMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
import org.springframework.web.server.ResponseStatusException
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/properties/{propertyId}/payu-settings")
|
|
||||||
class PayuSettingsController(
|
|
||||||
private val propertyAccess: PropertyAccess,
|
|
||||||
private val propertyRepo: PropertyRepo,
|
|
||||||
private val payuSettingsRepo: PayuSettingsRepo
|
|
||||||
) {
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
fun getSettings(
|
|
||||||
@PathVariable propertyId: UUID,
|
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?
|
|
||||||
): PayuSettingsResponse {
|
|
||||||
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
|
|
||||||
val settings = payuSettingsRepo.findByPropertyId(propertyId)
|
|
||||||
if (settings == null) {
|
|
||||||
return PayuSettingsResponse(
|
|
||||||
propertyId = propertyId,
|
|
||||||
configured = false,
|
|
||||||
merchantKey = null,
|
|
||||||
isTest = false,
|
|
||||||
useSalt256 = true,
|
|
||||||
hasSalt32 = false,
|
|
||||||
hasSalt256 = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return settings.toResponse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@PutMapping
|
|
||||||
fun upsertSettings(
|
|
||||||
@PathVariable propertyId: UUID,
|
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
|
||||||
@RequestBody request: PayuSettingsUpsertRequest
|
|
||||||
): PayuSettingsResponse {
|
|
||||||
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
|
|
||||||
val property = propertyRepo.findById(propertyId).orElseThrow {
|
|
||||||
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
|
||||||
}
|
|
||||||
val key = request.merchantKey.trim().ifBlank {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "merchantKey required")
|
|
||||||
}
|
|
||||||
val isTest = request.isTest ?: false
|
|
||||||
val baseUrl = if (isTest) {
|
|
||||||
"https://test.payu.in/_payment"
|
|
||||||
} else {
|
|
||||||
"https://secure.payu.in/_payment"
|
|
||||||
}
|
|
||||||
val existing = payuSettingsRepo.findByPropertyId(propertyId)
|
|
||||||
val updated = if (existing == null) {
|
|
||||||
PayuSettings(
|
|
||||||
property = property,
|
|
||||||
merchantKey = key,
|
|
||||||
salt32 = request.salt32?.trim()?.ifBlank { null },
|
|
||||||
salt256 = request.salt256?.trim()?.ifBlank { null },
|
|
||||||
baseUrl = baseUrl,
|
|
||||||
isTest = isTest,
|
|
||||||
useSalt256 = request.useSalt256 ?: true,
|
|
||||||
updatedAt = OffsetDateTime.now()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
existing.merchantKey = key
|
|
||||||
if (request.salt32 != null) existing.salt32 = request.salt32.trim().ifBlank { null }
|
|
||||||
if (request.salt256 != null) existing.salt256 = request.salt256.trim().ifBlank { null }
|
|
||||||
existing.baseUrl = baseUrl
|
|
||||||
existing.isTest = isTest
|
|
||||||
if (request.useSalt256 != null) existing.useSalt256 = request.useSalt256
|
|
||||||
existing.updatedAt = OffsetDateTime.now()
|
|
||||||
existing
|
|
||||||
}
|
|
||||||
return payuSettingsRepo.save(updated).toResponse()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun PayuSettings.toResponse(): PayuSettingsResponse {
|
|
||||||
val propertyId = property.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Property id missing")
|
|
||||||
return PayuSettingsResponse(
|
|
||||||
propertyId = propertyId,
|
|
||||||
configured = true,
|
|
||||||
merchantKey = merchantKey,
|
|
||||||
isTest = isTest,
|
|
||||||
useSalt256 = useSalt256,
|
|
||||||
hasSalt32 = !salt32.isNullOrBlank(),
|
|
||||||
hasSalt256 = !salt256.isNullOrBlank()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
package com.android.trisolarisserver.controller
|
|
||||||
|
|
||||||
import com.android.trisolarisserver.models.booking.Payment
|
|
||||||
import com.android.trisolarisserver.models.booking.PaymentMethod
|
|
||||||
import com.android.trisolarisserver.models.payment.PayuPaymentAttempt
|
|
||||||
import com.android.trisolarisserver.models.payment.PayuWebhookLog
|
|
||||||
import com.android.trisolarisserver.db.repo.BookingRepo
|
|
||||||
import com.android.trisolarisserver.repo.PayuPaymentAttemptRepo
|
|
||||||
import com.android.trisolarisserver.repo.PayuWebhookLogRepo
|
|
||||||
import com.android.trisolarisserver.repo.PaymentRepo
|
|
||||||
import com.android.trisolarisserver.repo.PropertyRepo
|
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
|
||||||
import org.springframework.http.HttpStatus
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
import org.springframework.web.server.ResponseStatusException
|
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
import java.math.BigDecimal
|
|
||||||
import java.net.URLDecoder
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.ZoneOffset
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/properties/{propertyId}/payu/webhook")
|
|
||||||
class PayuWebhookCapture(
|
|
||||||
private val propertyRepo: PropertyRepo,
|
|
||||||
private val bookingRepo: BookingRepo,
|
|
||||||
private val paymentRepo: PaymentRepo,
|
|
||||||
private val payuPaymentAttemptRepo: PayuPaymentAttemptRepo,
|
|
||||||
private val payuWebhookLogRepo: PayuWebhookLogRepo
|
|
||||||
) {
|
|
||||||
|
|
||||||
@PostMapping
|
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
|
||||||
@Transactional
|
|
||||||
fun capture(
|
|
||||||
@PathVariable propertyId: UUID,
|
|
||||||
@RequestBody(required = false) body: String?,
|
|
||||||
request: HttpServletRequest
|
|
||||||
) {
|
|
||||||
val property = propertyRepo.findById(propertyId).orElseThrow {
|
|
||||||
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
|
||||||
}
|
|
||||||
val headers = request.headerNames.toList().associateWith { request.getHeader(it) }
|
|
||||||
val headersText = headers.entries.joinToString("\n") { (k, v) -> "$k: $v" }
|
|
||||||
payuWebhookLogRepo.save(
|
|
||||||
PayuWebhookLog(
|
|
||||||
property = property,
|
|
||||||
headers = headersText,
|
|
||||||
payload = body,
|
|
||||||
contentType = request.contentType,
|
|
||||||
receivedAt = OffsetDateTime.now()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (body.isNullOrBlank()) return
|
|
||||||
val data = parseFormBody(body)
|
|
||||||
val status = data["status"]?.lowercase() ?: data["unmappedstatus"]?.lowercase()
|
|
||||||
val isSuccess = status == "success" || status == "captured"
|
|
||||||
val isRefund = status == "refund" || status == "refunded"
|
|
||||||
val bookingId = data["udf1"]?.let { runCatching { UUID.fromString(it) }.getOrNull() }
|
|
||||||
val booking = bookingId?.let { bookingRepo.findById(it).orElse(null) }
|
|
||||||
if (booking != null && booking.property.id != propertyId) return
|
|
||||||
|
|
||||||
val amountRaw = data["amount"]?.ifBlank { null } ?: data["net_amount_debit"]?.ifBlank { null }
|
|
||||||
val amount = parseAmount(amountRaw)
|
|
||||||
val gatewayPaymentId = data["mihpayid"]?.ifBlank { null }
|
|
||||||
val gatewayTxnId = data["txnid"]?.ifBlank { null }
|
|
||||||
val bankRef = data["bank_ref_num"]?.ifBlank { null } ?: data["bank_ref_no"]?.ifBlank { null }
|
|
||||||
val mode = data["mode"]?.ifBlank { null }
|
|
||||||
val pgType = data["PG_TYPE"]?.ifBlank { null }
|
|
||||||
val payerVpa = data["field3"]?.ifBlank { null }
|
|
||||||
val payerName = data["field6"]?.ifBlank { null }
|
|
||||||
val paymentSource = data["payment_source"]?.ifBlank { null }
|
|
||||||
val errorCode = data["error"]?.ifBlank { null }
|
|
||||||
val errorMessage = data["error_Message"]?.ifBlank { null }
|
|
||||||
val receivedAt = parseAddedOn(data["addedon"], booking?.property?.timezone)
|
|
||||||
|
|
||||||
payuPaymentAttemptRepo.save(
|
|
||||||
PayuPaymentAttempt(
|
|
||||||
property = property,
|
|
||||||
booking = booking,
|
|
||||||
status = status,
|
|
||||||
unmappedStatus = data["unmappedstatus"]?.ifBlank { null },
|
|
||||||
amount = amount,
|
|
||||||
currency = booking?.property?.currency ?: property.currency,
|
|
||||||
gatewayPaymentId = gatewayPaymentId,
|
|
||||||
gatewayTxnId = gatewayTxnId,
|
|
||||||
bankRefNum = bankRef,
|
|
||||||
mode = mode,
|
|
||||||
pgType = pgType,
|
|
||||||
payerVpa = payerVpa,
|
|
||||||
payerName = payerName,
|
|
||||||
paymentSource = paymentSource,
|
|
||||||
errorCode = errorCode,
|
|
||||||
errorMessage = errorMessage,
|
|
||||||
payload = body,
|
|
||||||
receivedAt = receivedAt
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!isSuccess && !isRefund) return
|
|
||||||
if (booking == null) return
|
|
||||||
|
|
||||||
if (gatewayPaymentId != null && paymentRepo.findByGatewayPaymentId(gatewayPaymentId) != null) return
|
|
||||||
if (gatewayPaymentId == null && gatewayTxnId != null && paymentRepo.findByGatewayTxnId(gatewayTxnId) != null) return
|
|
||||||
|
|
||||||
val signedAmount = amount?.let { if (isRefund) -it else it } ?: return
|
|
||||||
val notes = buildString {
|
|
||||||
append("payu status=").append(status)
|
|
||||||
gatewayTxnId?.let { append(" txnid=").append(it) }
|
|
||||||
bankRef?.let { append(" bank_ref=").append(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
paymentRepo.save(
|
|
||||||
Payment(
|
|
||||||
property = booking.property,
|
|
||||||
booking = booking,
|
|
||||||
amount = signedAmount,
|
|
||||||
currency = booking.property.currency,
|
|
||||||
method = PaymentMethod.ONLINE,
|
|
||||||
gatewayPaymentId = gatewayPaymentId,
|
|
||||||
gatewayTxnId = gatewayTxnId,
|
|
||||||
bankRefNum = bankRef,
|
|
||||||
mode = mode,
|
|
||||||
pgType = pgType,
|
|
||||||
payerVpa = payerVpa,
|
|
||||||
payerName = payerName,
|
|
||||||
paymentSource = paymentSource,
|
|
||||||
reference = gatewayPaymentId?.let { "payu:$it" } ?: gatewayTxnId?.let { "payu:$it" },
|
|
||||||
notes = notes,
|
|
||||||
receivedAt = receivedAt
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseFormBody(body: String): Map<String, String> {
|
|
||||||
return body.split("&")
|
|
||||||
.mapNotNull { pair ->
|
|
||||||
val idx = pair.indexOf("=")
|
|
||||||
if (idx <= 0) return@mapNotNull null
|
|
||||||
val key = URLDecoder.decode(pair.substring(0, idx), "UTF-8")
|
|
||||||
val value = URLDecoder.decode(pair.substring(idx + 1), "UTF-8")
|
|
||||||
key to value
|
|
||||||
}
|
|
||||||
.toMap()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseAmount(value: String?): Long? {
|
|
||||||
if (value.isNullOrBlank()) return null
|
|
||||||
return try {
|
|
||||||
val bd = BigDecimal(value.trim()).setScale(0, java.math.RoundingMode.HALF_UP)
|
|
||||||
bd.longValueExact()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseAddedOn(value: String?, timezone: String?): OffsetDateTime {
|
|
||||||
if (value.isNullOrBlank()) return OffsetDateTime.now()
|
|
||||||
return try {
|
|
||||||
val local = LocalDateTime.parse(value.trim(), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
|
|
||||||
val zone = try {
|
|
||||||
if (timezone.isNullOrBlank()) ZoneId.of("Asia/Kolkata") else ZoneId.of(timezone)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
ZoneId.of("Asia/Kolkata")
|
|
||||||
}
|
|
||||||
local.atZone(zone).toOffsetDateTime()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
OffsetDateTime.now(ZoneOffset.UTC)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
package com.android.trisolarisserver.controller
|
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.PropertyAccess
|
|
||||||
import com.android.trisolarisserver.component.RoomBoardEvents
|
|
||||||
import com.android.trisolarisserver.controller.dto.RoomChangeRequest
|
|
||||||
import com.android.trisolarisserver.controller.dto.RoomChangeResponse
|
|
||||||
import com.android.trisolarisserver.models.room.RoomStay
|
|
||||||
import com.android.trisolarisserver.models.room.RoomStayChange
|
|
||||||
import com.android.trisolarisserver.models.property.Role
|
|
||||||
import com.android.trisolarisserver.repo.AppUserRepo
|
|
||||||
import com.android.trisolarisserver.repo.RoomRepo
|
|
||||||
import com.android.trisolarisserver.repo.RoomStayChangeRepo
|
|
||||||
import com.android.trisolarisserver.repo.RoomStayRepo
|
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
|
||||||
import org.springframework.http.HttpStatus
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
import org.springframework.web.server.ResponseStatusException
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/properties/{propertyId}/room-stays")
|
|
||||||
class RoomStayFlow(
|
|
||||||
private val propertyAccess: PropertyAccess,
|
|
||||||
private val roomStayRepo: RoomStayRepo,
|
|
||||||
private val roomStayChangeRepo: RoomStayChangeRepo,
|
|
||||||
private val roomRepo: RoomRepo,
|
|
||||||
private val appUserRepo: AppUserRepo,
|
|
||||||
private val roomBoardEvents: RoomBoardEvents
|
|
||||||
) {
|
|
||||||
|
|
||||||
@PostMapping("/{roomStayId}/change-room")
|
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
|
||||||
@Transactional
|
|
||||||
fun changeRoom(
|
|
||||||
@PathVariable propertyId: UUID,
|
|
||||||
@PathVariable roomStayId: UUID,
|
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
|
||||||
@RequestBody request: RoomChangeRequest
|
|
||||||
): RoomChangeResponse {
|
|
||||||
val actor = requireActor(propertyId, principal)
|
|
||||||
|
|
||||||
val stay = requireOpenRoomStayForProperty(
|
|
||||||
roomStayRepo,
|
|
||||||
propertyId,
|
|
||||||
roomStayId,
|
|
||||||
"Room stay already closed"
|
|
||||||
)
|
|
||||||
if (request.idempotencyKey.isBlank()) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "idempotencyKey required")
|
|
||||||
}
|
|
||||||
if (request.newRoomId == stay.room.id) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "New room is same as current")
|
|
||||||
}
|
|
||||||
|
|
||||||
val existing = roomStayChangeRepo.findByRoomStayIdAndIdempotencyKey(roomStayId, request.idempotencyKey)
|
|
||||||
if (existing != null) {
|
|
||||||
return toResponse(existing)
|
|
||||||
}
|
|
||||||
|
|
||||||
val newRoom = roomRepo.findByIdAndPropertyId(request.newRoomId, propertyId)
|
|
||||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found")
|
|
||||||
if (!newRoom.active || newRoom.maintenance) {
|
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Room not available")
|
|
||||||
}
|
|
||||||
val occupied = roomStayRepo.findActiveRoomIds(propertyId, listOf(request.newRoomId))
|
|
||||||
if (occupied.isNotEmpty()) {
|
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Room already occupied")
|
|
||||||
}
|
|
||||||
|
|
||||||
val movedAt = parseOffset(request.movedAt) ?: OffsetDateTime.now()
|
|
||||||
|
|
||||||
stay.toAt = movedAt
|
|
||||||
roomStayRepo.save(stay)
|
|
||||||
|
|
||||||
val newStay = RoomStay(
|
|
||||||
property = stay.property,
|
|
||||||
booking = stay.booking,
|
|
||||||
room = newRoom,
|
|
||||||
fromAt = movedAt,
|
|
||||||
toAt = null,
|
|
||||||
rateSource = stay.rateSource,
|
|
||||||
nightlyRate = stay.nightlyRate,
|
|
||||||
ratePlanCode = stay.ratePlanCode,
|
|
||||||
currency = stay.currency,
|
|
||||||
createdBy = actor
|
|
||||||
)
|
|
||||||
val savedNewStay = roomStayRepo.save(newStay)
|
|
||||||
|
|
||||||
val change = RoomStayChange(
|
|
||||||
property = stay.property,
|
|
||||||
roomStay = stay,
|
|
||||||
newRoomStay = savedNewStay,
|
|
||||||
idempotencyKey = request.idempotencyKey
|
|
||||||
)
|
|
||||||
val savedChange = roomStayChangeRepo.save(change)
|
|
||||||
roomBoardEvents.emit(propertyId)
|
|
||||||
return toResponse(savedChange)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toResponse(change: RoomStayChange): RoomChangeResponse {
|
|
||||||
return RoomChangeResponse(
|
|
||||||
oldRoomStayId = change.roomStay.id!!,
|
|
||||||
newRoomStayId = change.newRoomStay.id!!,
|
|
||||||
oldRoomId = change.roomStay.room.id!!,
|
|
||||||
newRoomId = change.newRoomStay.room.id!!,
|
|
||||||
movedAt = change.newRoomStay.fromAt.toString()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requireActor(propertyId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser {
|
|
||||||
val resolved = requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
|
|
||||||
return appUserRepo.findById(resolved.userId).orElseThrow {
|
|
||||||
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.android.trisolarisserver.controller
|
package com.android.trisolarisserver.controller.assets
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.core.io.FileSystemResource
|
import org.springframework.core.io.FileSystemResource
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package com.android.trisolarisserver.controller
|
package com.android.trisolarisserver.controller.auth
|
||||||
|
|
||||||
import com.android.trisolarisserver.controller.dto.PropertyUserResponse
|
import com.android.trisolarisserver.controller.dto.property.PropertyUserResponse
|
||||||
import com.android.trisolarisserver.controller.dto.UserResponse
|
import com.android.trisolarisserver.controller.dto.property.UserResponse
|
||||||
import com.android.trisolarisserver.repo.AppUserRepo
|
import com.android.trisolarisserver.repo.property.AppUserRepo
|
||||||
import com.android.trisolarisserver.repo.PropertyUserRepo
|
import com.android.trisolarisserver.repo.property.PropertyUserRepo
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
@@ -1,19 +1,22 @@
|
|||||||
package com.android.trisolarisserver.controller
|
package com.android.trisolarisserver.controller.booking
|
||||||
|
import com.android.trisolarisserver.controller.common.computeExpectedPay
|
||||||
|
import com.android.trisolarisserver.controller.common.requireMember
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.PropertyAccess
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
import com.android.trisolarisserver.controller.dto.BookingBalanceResponse
|
import com.android.trisolarisserver.controller.dto.payment.BookingBalanceResponse
|
||||||
import com.android.trisolarisserver.db.repo.BookingRepo
|
import com.android.trisolarisserver.repo.booking.BookingRepo
|
||||||
import com.android.trisolarisserver.repo.PaymentRepo
|
import com.android.trisolarisserver.repo.booking.ChargeRepo
|
||||||
import com.android.trisolarisserver.repo.RoomStayRepo
|
import com.android.trisolarisserver.repo.booking.PaymentRepo
|
||||||
|
import com.android.trisolarisserver.repo.room.RoomStayRepo
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import org.springframework.web.server.ResponseStatusException
|
import org.springframework.web.server.ResponseStatusException
|
||||||
import java.time.LocalDate
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -22,10 +25,12 @@ class BookingBalances(
|
|||||||
private val propertyAccess: PropertyAccess,
|
private val propertyAccess: PropertyAccess,
|
||||||
private val bookingRepo: BookingRepo,
|
private val bookingRepo: BookingRepo,
|
||||||
private val roomStayRepo: RoomStayRepo,
|
private val roomStayRepo: RoomStayRepo,
|
||||||
|
private val chargeRepo: ChargeRepo,
|
||||||
private val paymentRepo: PaymentRepo
|
private val paymentRepo: PaymentRepo
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
|
@Transactional(readOnly = true)
|
||||||
fun getBalance(
|
fun getBalance(
|
||||||
@PathVariable propertyId: UUID,
|
@PathVariable propertyId: UUID,
|
||||||
@PathVariable bookingId: UUID,
|
@PathVariable bookingId: UUID,
|
||||||
@@ -38,35 +43,20 @@ class BookingBalances(
|
|||||||
if (booking.property.id != propertyId) {
|
if (booking.property.id != propertyId) {
|
||||||
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
|
||||||
}
|
}
|
||||||
val expected = computeExpectedPay(bookingId, booking.property.timezone)
|
val expected = computeExpectedPay(
|
||||||
|
roomStayRepo.findByBookingId(bookingId),
|
||||||
|
booking.property.timezone,
|
||||||
|
booking.billingMode,
|
||||||
|
booking.billingCheckinTime,
|
||||||
|
booking.billingCheckoutTime
|
||||||
|
)
|
||||||
|
val charges = chargeRepo.sumAmountByBookingId(bookingId)
|
||||||
val collected = paymentRepo.sumAmountByBookingId(bookingId)
|
val collected = paymentRepo.sumAmountByBookingId(bookingId)
|
||||||
val pending = expected - collected
|
val pending = expected + charges - collected
|
||||||
return BookingBalanceResponse(
|
return BookingBalanceResponse(
|
||||||
expectedPay = expected,
|
expectedPay = expected + charges,
|
||||||
amountCollected = collected,
|
amountCollected = collected,
|
||||||
pending = pending
|
pending = pending
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun computeExpectedPay(bookingId: UUID, timezone: String?): Long {
|
|
||||||
val stays = roomStayRepo.findByBookingId(bookingId)
|
|
||||||
if (stays.isEmpty()) return 0
|
|
||||||
val now = nowForProperty(timezone)
|
|
||||||
var total = 0L
|
|
||||||
stays.forEach { stay ->
|
|
||||||
val rate = stay.nightlyRate ?: 0L
|
|
||||||
if (rate == 0L) return@forEach
|
|
||||||
val start = stay.fromAt.toLocalDate()
|
|
||||||
val endAt = stay.toAt ?: now
|
|
||||||
val end = endAt.toLocalDate()
|
|
||||||
val nights = daysBetweenInclusive(start, end)
|
|
||||||
total += rate * nights
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun daysBetweenInclusive(start: LocalDate, end: LocalDate): Long {
|
|
||||||
val diff = end.toEpochDay() - start.toEpochDay()
|
|
||||||
return if (diff <= 0) 1L else diff
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,161 @@
|
|||||||
|
package com.android.trisolarisserver.controller.booking
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
|
import com.android.trisolarisserver.controller.common.parseOffset
|
||||||
|
import com.android.trisolarisserver.controller.common.requireRole
|
||||||
|
import com.android.trisolarisserver.controller.dto.booking.BookingRoomRequestCreateRequest
|
||||||
|
import com.android.trisolarisserver.controller.dto.booking.BookingRoomRequestResponse
|
||||||
|
import com.android.trisolarisserver.models.booking.BookingRoomRequest
|
||||||
|
import com.android.trisolarisserver.models.booking.BookingRoomRequestStatus
|
||||||
|
import com.android.trisolarisserver.models.booking.BookingStatus
|
||||||
|
import com.android.trisolarisserver.models.property.Role
|
||||||
|
import com.android.trisolarisserver.repo.booking.BookingRepo
|
||||||
|
import com.android.trisolarisserver.repo.booking.BookingRoomRequestRepo
|
||||||
|
import com.android.trisolarisserver.repo.property.AppUserRepo
|
||||||
|
import com.android.trisolarisserver.repo.room.RoomRepo
|
||||||
|
import com.android.trisolarisserver.repo.room.RoomStayRepo
|
||||||
|
import com.android.trisolarisserver.repo.room.RoomTypeRepo
|
||||||
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/properties/{propertyId}/bookings/{bookingId}/room-requests")
|
||||||
|
class BookingRoomRequests(
|
||||||
|
private val propertyAccess: PropertyAccess,
|
||||||
|
private val bookingRepo: BookingRepo,
|
||||||
|
private val roomTypeRepo: RoomTypeRepo,
|
||||||
|
private val roomRepo: RoomRepo,
|
||||||
|
private val roomStayRepo: RoomStayRepo,
|
||||||
|
private val appUserRepo: AppUserRepo,
|
||||||
|
private val bookingRoomRequestRepo: BookingRoomRequestRepo
|
||||||
|
) {
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@Transactional
|
||||||
|
fun create(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable bookingId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@RequestBody request: BookingRoomRequestCreateRequest
|
||||||
|
): BookingRoomRequestResponse {
|
||||||
|
val actor = requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
|
||||||
|
val booking = bookingRepo.findById(bookingId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
|
||||||
|
}
|
||||||
|
if (booking.property.id != propertyId) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
|
||||||
|
}
|
||||||
|
if (booking.status == BookingStatus.CANCELLED || booking.status == BookingStatus.NO_SHOW || booking.status == BookingStatus.CHECKED_OUT) {
|
||||||
|
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.quantity <= 0) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "quantity must be > 0")
|
||||||
|
}
|
||||||
|
val fromAt = parseOffset(request.fromAt)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "fromAt required")
|
||||||
|
val toAt = parseOffset(request.toAt)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "toAt required")
|
||||||
|
if (!toAt.isAfter(fromAt)) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date range")
|
||||||
|
}
|
||||||
|
|
||||||
|
val roomType = roomTypeRepo.findByPropertyIdAndCodeIgnoreCase(propertyId, request.roomTypeCode)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room type not found")
|
||||||
|
val capacity = roomRepo.countActiveSellableByType(propertyId, roomType.id!!)
|
||||||
|
val occupied = roomStayRepo.countOccupiedByTypeInRange(propertyId, roomType.id!!, fromAt, toAt)
|
||||||
|
val requested = bookingRoomRequestRepo.sumRemainingByTypeAndRange(propertyId, roomType.id!!, fromAt, toAt)
|
||||||
|
val free = capacity - occupied - requested
|
||||||
|
if (request.quantity.toLong() > free) {
|
||||||
|
throw ResponseStatusException(HttpStatus.CONFLICT, "Insufficient room type availability")
|
||||||
|
}
|
||||||
|
|
||||||
|
val appUser = appUserRepo.findById(actor.userId).orElse(null)
|
||||||
|
val saved = bookingRoomRequestRepo.save(
|
||||||
|
BookingRoomRequest(
|
||||||
|
property = booking.property,
|
||||||
|
booking = booking,
|
||||||
|
roomType = roomType,
|
||||||
|
quantity = request.quantity,
|
||||||
|
fromAt = fromAt,
|
||||||
|
toAt = toAt,
|
||||||
|
status = BookingRoomRequestStatus.ACTIVE,
|
||||||
|
createdBy = appUser
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return saved.toResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun list(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable bookingId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
): List<BookingRoomRequestResponse> {
|
||||||
|
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF, Role.HOUSEKEEPING, Role.FINANCE)
|
||||||
|
val booking = bookingRepo.findById(bookingId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
|
||||||
|
}
|
||||||
|
if (booking.property.id != propertyId) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
|
||||||
|
}
|
||||||
|
return bookingRoomRequestRepo.findByBookingIdOrderByCreatedAtAsc(bookingId).map { it.toResponse() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{requestId}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
@Transactional
|
||||||
|
fun cancel(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable bookingId: UUID,
|
||||||
|
@PathVariable requestId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
) {
|
||||||
|
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
|
||||||
|
val booking = bookingRepo.findById(bookingId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
|
||||||
|
}
|
||||||
|
if (booking.property.id != propertyId) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
|
||||||
|
}
|
||||||
|
val request = bookingRoomRequestRepo.findById(requestId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Room request not found")
|
||||||
|
}
|
||||||
|
if (request.booking.id != bookingId || request.property.id != propertyId) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room request not found for booking")
|
||||||
|
}
|
||||||
|
if (request.status == BookingRoomRequestStatus.FULFILLED) {
|
||||||
|
throw ResponseStatusException(HttpStatus.CONFLICT, "Cannot cancel fulfilled room request")
|
||||||
|
}
|
||||||
|
request.status = BookingRoomRequestStatus.CANCELLED
|
||||||
|
bookingRoomRequestRepo.save(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BookingRoomRequest.toResponse(): BookingRoomRequestResponse {
|
||||||
|
val remaining = (quantity - fulfilledQuantity).coerceAtLeast(0)
|
||||||
|
return BookingRoomRequestResponse(
|
||||||
|
id = id!!,
|
||||||
|
bookingId = booking.id!!,
|
||||||
|
roomTypeCode = roomType.code,
|
||||||
|
quantity = quantity,
|
||||||
|
fulfilledQuantity = fulfilledQuantity,
|
||||||
|
remainingQuantity = remaining,
|
||||||
|
fromAt = fromAt.toString(),
|
||||||
|
toAt = toAt.toString(),
|
||||||
|
status = status.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package com.android.trisolarisserver.controller.booking
|
||||||
|
import com.android.trisolarisserver.controller.common.billableNights
|
||||||
|
import com.android.trisolarisserver.controller.common.computeExpectedPay
|
||||||
|
import com.android.trisolarisserver.controller.common.computeExpectedPayTotal
|
||||||
|
import com.android.trisolarisserver.controller.common.nowForProperty
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.controller.dto.booking.BookingDetailResponse
|
||||||
|
import com.android.trisolarisserver.models.booking.BookingStatus
|
||||||
|
import com.android.trisolarisserver.repo.booking.BookingRepo
|
||||||
|
import com.android.trisolarisserver.repo.booking.ChargeRepo
|
||||||
|
import com.android.trisolarisserver.repo.guest.GuestVehicleRepo
|
||||||
|
import com.android.trisolarisserver.repo.booking.PaymentRepo
|
||||||
|
import com.android.trisolarisserver.repo.room.RoomStayRepo
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class BookingSnapshotBuilder(
|
||||||
|
private val bookingRepo: BookingRepo,
|
||||||
|
private val roomStayRepo: RoomStayRepo,
|
||||||
|
private val chargeRepo: ChargeRepo,
|
||||||
|
private val paymentRepo: PaymentRepo,
|
||||||
|
private val guestVehicleRepo: GuestVehicleRepo
|
||||||
|
) {
|
||||||
|
fun build(propertyId: UUID, bookingId: UUID): BookingDetailResponse {
|
||||||
|
val booking = bookingRepo.findDetailedById(bookingId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
|
||||||
|
}
|
||||||
|
if (booking.property.id != propertyId) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
|
||||||
|
}
|
||||||
|
|
||||||
|
val stays = roomStayRepo.findByBookingIdWithRoom(bookingId)
|
||||||
|
val activeRooms = stays.filter { it.toAt == null }
|
||||||
|
val roomsToShow = activeRooms.ifEmpty { stays }
|
||||||
|
val roomNumbers = roomsToShow.map { it.room.roomNumber }
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
|
||||||
|
val guest = booking.primaryGuest
|
||||||
|
val vehicleNumbers = if (guest?.id != null) {
|
||||||
|
guestVehicleRepo.findByGuestIdIn(listOf(guest.id!!))
|
||||||
|
.map { it.vehicleNumber }
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
val signatureUrl = guest?.signaturePath?.let {
|
||||||
|
"/properties/$propertyId/guests/${guest.id}/signature/file"
|
||||||
|
}
|
||||||
|
|
||||||
|
val totalNightlyRate = roomsToShow.sumOf { it.nightlyRate ?: 0L }
|
||||||
|
val billableNights = computeBookingBillableNights(booking)
|
||||||
|
val expectedPay = computeExpectedPayTotal(
|
||||||
|
stays,
|
||||||
|
booking.expectedCheckoutAt,
|
||||||
|
booking.property.timezone,
|
||||||
|
booking.billingMode,
|
||||||
|
booking.billingCheckinTime,
|
||||||
|
booking.billingCheckoutTime
|
||||||
|
)
|
||||||
|
val accruedPay = computeExpectedPay(
|
||||||
|
stays,
|
||||||
|
booking.property.timezone,
|
||||||
|
booking.billingMode,
|
||||||
|
booking.billingCheckinTime,
|
||||||
|
booking.billingCheckoutTime
|
||||||
|
)
|
||||||
|
val extraCharges = chargeRepo.sumAmountByBookingId(bookingId)
|
||||||
|
val amountCollected = paymentRepo.sumAmountByBookingId(bookingId)
|
||||||
|
val pending = accruedPay + extraCharges - amountCollected
|
||||||
|
|
||||||
|
return BookingDetailResponse(
|
||||||
|
id = booking.id!!,
|
||||||
|
status = booking.status.name,
|
||||||
|
guestId = guest?.id,
|
||||||
|
guestName = guest?.name,
|
||||||
|
guestPhone = guest?.phoneE164,
|
||||||
|
guestNationality = guest?.nationality,
|
||||||
|
guestAddressText = guest?.addressText,
|
||||||
|
guestAge = guest?.age,
|
||||||
|
guestSignatureUrl = signatureUrl,
|
||||||
|
vehicleNumbers = vehicleNumbers,
|
||||||
|
roomNumbers = roomNumbers,
|
||||||
|
source = booking.source,
|
||||||
|
billingMode = booking.billingMode.name,
|
||||||
|
billingCheckinTime = booking.billingCheckinTime,
|
||||||
|
billingCheckoutTime = booking.billingCheckoutTime,
|
||||||
|
fromCity = booking.fromCity,
|
||||||
|
toCity = booking.toCity,
|
||||||
|
memberRelation = booking.memberRelation?.name,
|
||||||
|
transportMode = booking.transportMode?.name,
|
||||||
|
checkInAt = booking.checkinAt?.toString(),
|
||||||
|
checkOutAt = booking.checkoutAt?.toString(),
|
||||||
|
expectedCheckInAt = booking.expectedCheckinAt?.toString(),
|
||||||
|
expectedCheckOutAt = booking.expectedCheckoutAt?.toString(),
|
||||||
|
adultCount = booking.adultCount,
|
||||||
|
childCount = booking.childCount,
|
||||||
|
maleCount = booking.maleCount,
|
||||||
|
femaleCount = booking.femaleCount,
|
||||||
|
totalGuestCount = booking.totalGuestCount,
|
||||||
|
expectedGuestCount = booking.expectedGuestCount,
|
||||||
|
notes = booking.notes,
|
||||||
|
registeredByName = booking.createdBy?.name,
|
||||||
|
registeredByPhone = booking.createdBy?.phoneE164,
|
||||||
|
totalNightlyRate = totalNightlyRate,
|
||||||
|
billableNights = billableNights,
|
||||||
|
expectedPay = expectedPay + extraCharges,
|
||||||
|
amountCollected = amountCollected,
|
||||||
|
pending = pending
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeBookingBillableNights(booking: com.android.trisolarisserver.models.booking.Booking): Long? {
|
||||||
|
val startAt: java.time.OffsetDateTime
|
||||||
|
val endAt: java.time.OffsetDateTime
|
||||||
|
when (booking.status) {
|
||||||
|
BookingStatus.OPEN -> {
|
||||||
|
startAt = booking.expectedCheckinAt ?: return null
|
||||||
|
endAt = booking.expectedCheckoutAt ?: return null
|
||||||
|
}
|
||||||
|
BookingStatus.CHECKED_IN -> {
|
||||||
|
startAt = booking.checkinAt ?: booking.expectedCheckinAt ?: return null
|
||||||
|
endAt = booking.expectedCheckoutAt ?: nowForProperty(booking.property.timezone)
|
||||||
|
}
|
||||||
|
BookingStatus.CHECKED_OUT,
|
||||||
|
BookingStatus.CANCELLED,
|
||||||
|
BookingStatus.NO_SHOW -> {
|
||||||
|
startAt = booking.checkinAt ?: booking.expectedCheckinAt ?: return null
|
||||||
|
endAt = booking.checkoutAt ?: booking.expectedCheckoutAt ?: return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return billableNights(
|
||||||
|
startAt = startAt,
|
||||||
|
endAt = endAt,
|
||||||
|
timezone = booking.property.timezone,
|
||||||
|
billingMode = booking.billingMode,
|
||||||
|
billingCheckinTime = booking.billingCheckinTime,
|
||||||
|
billingCheckoutTime = booking.billingCheckoutTime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.android.trisolarisserver.controller
|
package com.android.trisolarisserver.controller.card
|
||||||
|
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.android.trisolarisserver.controller
|
package com.android.trisolarisserver.controller.card
|
||||||
|
|
||||||
import com.android.trisolarisserver.controller.dto.IssuedCardResponse
|
import com.android.trisolarisserver.controller.dto.booking.IssuedCardResponse
|
||||||
import com.android.trisolarisserver.models.room.IssuedCard
|
import com.android.trisolarisserver.models.room.IssuedCard
|
||||||
|
|
||||||
internal fun IssuedCard.toResponse(): IssuedCardResponse {
|
internal fun IssuedCard.toResponse(): IssuedCardResponse {
|
||||||
@@ -1,18 +1,23 @@
|
|||||||
package com.android.trisolarisserver.controller
|
package com.android.trisolarisserver.controller.card
|
||||||
|
import com.android.trisolarisserver.controller.common.nowForProperty
|
||||||
|
import com.android.trisolarisserver.controller.common.parseOffset
|
||||||
|
import com.android.trisolarisserver.controller.common.requireMember
|
||||||
|
import com.android.trisolarisserver.controller.common.requireOpenRoomStayForProperty
|
||||||
|
import com.android.trisolarisserver.controller.common.requireRoomStayForProperty
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.PropertyAccess
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
import com.android.trisolarisserver.controller.dto.CardPrepareRequest
|
import com.android.trisolarisserver.controller.dto.booking.CardPrepareRequest
|
||||||
import com.android.trisolarisserver.controller.dto.CardPrepareResponse
|
import com.android.trisolarisserver.controller.dto.booking.CardPrepareResponse
|
||||||
import com.android.trisolarisserver.controller.dto.CardRevokeResponse
|
import com.android.trisolarisserver.controller.dto.booking.CardRevokeResponse
|
||||||
import com.android.trisolarisserver.controller.dto.IssueCardRequest
|
import com.android.trisolarisserver.controller.dto.booking.IssueCardRequest
|
||||||
import com.android.trisolarisserver.controller.dto.IssuedCardResponse
|
import com.android.trisolarisserver.controller.dto.booking.IssuedCardResponse
|
||||||
import com.android.trisolarisserver.models.property.Role
|
import com.android.trisolarisserver.models.property.Role
|
||||||
import com.android.trisolarisserver.models.room.IssuedCard
|
import com.android.trisolarisserver.models.room.IssuedCard
|
||||||
import com.android.trisolarisserver.repo.AppUserRepo
|
import com.android.trisolarisserver.repo.property.AppUserRepo
|
||||||
import com.android.trisolarisserver.repo.IssuedCardRepo
|
import com.android.trisolarisserver.repo.card.IssuedCardRepo
|
||||||
import com.android.trisolarisserver.repo.PropertyCardCounterRepo
|
import com.android.trisolarisserver.repo.card.PropertyCardCounterRepo
|
||||||
import com.android.trisolarisserver.repo.PropertyRepo
|
import com.android.trisolarisserver.repo.property.PropertyRepo
|
||||||
import com.android.trisolarisserver.repo.RoomStayRepo
|
import com.android.trisolarisserver.repo.room.RoomStayRepo
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
package com.android.trisolarisserver.controller
|
package com.android.trisolarisserver.controller.card
|
||||||
|
import com.android.trisolarisserver.controller.common.nowForProperty
|
||||||
|
import com.android.trisolarisserver.controller.common.parseOffset
|
||||||
|
import com.android.trisolarisserver.controller.common.requireMember
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.PropertyAccess
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
import com.android.trisolarisserver.controller.dto.CardPrepareResponse
|
import com.android.trisolarisserver.controller.dto.booking.CardPrepareResponse
|
||||||
import com.android.trisolarisserver.controller.dto.IssueTempCardRequest
|
import com.android.trisolarisserver.controller.dto.booking.IssueTempCardRequest
|
||||||
import com.android.trisolarisserver.controller.dto.IssuedCardResponse
|
import com.android.trisolarisserver.controller.dto.booking.IssuedCardResponse
|
||||||
import com.android.trisolarisserver.models.property.Role
|
import com.android.trisolarisserver.models.property.Role
|
||||||
import com.android.trisolarisserver.models.room.IssuedCard
|
import com.android.trisolarisserver.models.room.IssuedCard
|
||||||
import com.android.trisolarisserver.repo.AppUserRepo
|
import com.android.trisolarisserver.repo.property.AppUserRepo
|
||||||
import com.android.trisolarisserver.repo.IssuedCardRepo
|
import com.android.trisolarisserver.repo.card.IssuedCardRepo
|
||||||
import com.android.trisolarisserver.repo.PropertyCardCounterRepo
|
import com.android.trisolarisserver.repo.card.PropertyCardCounterRepo
|
||||||
import com.android.trisolarisserver.repo.PropertyRepo
|
import com.android.trisolarisserver.repo.property.PropertyRepo
|
||||||
import com.android.trisolarisserver.repo.RoomRepo
|
import com.android.trisolarisserver.repo.room.RoomRepo
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
package com.android.trisolarisserver.controller
|
package com.android.trisolarisserver.controller.common
|
||||||
|
import com.android.trisolarisserver.controller.common.requireMember
|
||||||
|
import com.android.trisolarisserver.controller.common.requirePrincipal
|
||||||
|
import com.android.trisolarisserver.controller.common.requireRole
|
||||||
|
import com.android.trisolarisserver.controller.common.requireSuperAdmin
|
||||||
|
import com.android.trisolarisserver.controller.common.requireUser
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.PropertyAccess
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
import com.android.trisolarisserver.models.property.AppUser
|
import com.android.trisolarisserver.models.property.AppUser
|
||||||
import com.android.trisolarisserver.models.property.Role
|
import com.android.trisolarisserver.models.property.Role
|
||||||
import com.android.trisolarisserver.repo.AppUserRepo
|
import com.android.trisolarisserver.repo.property.AppUserRepo
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.web.server.ResponseStatusException
|
import org.springframework.web.server.ResponseStatusException
|
||||||
@@ -40,3 +45,11 @@ internal fun requireRole(
|
|||||||
propertyAccess.requireAnyRole(propertyId, resolved.userId, *roles)
|
propertyAccess.requireAnyRole(propertyId, resolved.userId, *roles)
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun requireSuperAdmin(appUserRepo: AppUserRepo, principal: MyPrincipal?): AppUser {
|
||||||
|
val user = requireUser(appUserRepo, principal)
|
||||||
|
if (!user.superAdmin) {
|
||||||
|
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Super admin only")
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package com.android.trisolarisserver.controller.common
|
||||||
|
import com.android.trisolarisserver.controller.common.computeExpectedPay
|
||||||
|
import com.android.trisolarisserver.controller.common.computeExpectedPayTotal
|
||||||
|
import com.android.trisolarisserver.controller.common.daysBetweenInclusive
|
||||||
|
import com.android.trisolarisserver.controller.common.nowForProperty
|
||||||
|
import com.android.trisolarisserver.controller.common.parseDate
|
||||||
|
import com.android.trisolarisserver.controller.common.parseOffset
|
||||||
|
import com.android.trisolarisserver.controller.common.requireOpenRoomStayForProperty
|
||||||
|
import com.android.trisolarisserver.controller.common.requireProperty
|
||||||
|
import com.android.trisolarisserver.controller.common.requirePropertyGuest
|
||||||
|
import com.android.trisolarisserver.controller.common.requireRoomStayForProperty
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.repo.guest.GuestRepo
|
||||||
|
import com.android.trisolarisserver.models.booking.Guest
|
||||||
|
import com.android.trisolarisserver.models.booking.BookingBillingMode
|
||||||
|
import com.android.trisolarisserver.models.property.Property
|
||||||
|
import com.android.trisolarisserver.models.room.RoomStay
|
||||||
|
import com.android.trisolarisserver.repo.property.PropertyRepo
|
||||||
|
import com.android.trisolarisserver.repo.room.RoomStayRepo
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
internal data class PropertyGuest(
|
||||||
|
val property: Property,
|
||||||
|
val guest: Guest
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun requireProperty(propertyRepo: PropertyRepo, propertyId: UUID): Property {
|
||||||
|
return propertyRepo.findById(propertyId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun requirePropertyGuest(
|
||||||
|
propertyRepo: PropertyRepo,
|
||||||
|
guestRepo: GuestRepo,
|
||||||
|
propertyId: UUID,
|
||||||
|
guestId: UUID
|
||||||
|
): PropertyGuest {
|
||||||
|
val property = requireProperty(propertyRepo, propertyId)
|
||||||
|
val guest = guestRepo.findById(guestId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found")
|
||||||
|
}
|
||||||
|
if (guest.property.id != property.id) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property")
|
||||||
|
}
|
||||||
|
return PropertyGuest(property, guest)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun requireRoomStayForProperty(
|
||||||
|
roomStayRepo: RoomStayRepo,
|
||||||
|
propertyId: UUID,
|
||||||
|
roomStayId: UUID
|
||||||
|
): RoomStay {
|
||||||
|
val stay = roomStayRepo.findById(roomStayId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found")
|
||||||
|
}
|
||||||
|
if (stay.property.id != propertyId || stay.isVoided) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property")
|
||||||
|
}
|
||||||
|
return stay
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun requireOpenRoomStayForProperty(
|
||||||
|
roomStayRepo: RoomStayRepo,
|
||||||
|
propertyId: UUID,
|
||||||
|
roomStayId: UUID,
|
||||||
|
closedMessage: String
|
||||||
|
): RoomStay {
|
||||||
|
val stay = requireRoomStayForProperty(roomStayRepo, propertyId, roomStayId)
|
||||||
|
if (stay.toAt != null) {
|
||||||
|
throw ResponseStatusException(HttpStatus.CONFLICT, closedMessage)
|
||||||
|
}
|
||||||
|
return stay
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun parseOffset(value: String?): OffsetDateTime? {
|
||||||
|
if (value.isNullOrBlank()) return null
|
||||||
|
return try {
|
||||||
|
OffsetDateTime.parse(value.trim())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun parseDate(value: String, errorMessage: String): LocalDate {
|
||||||
|
return try {
|
||||||
|
LocalDate.parse(value.trim())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun nowForProperty(timezone: String?): OffsetDateTime {
|
||||||
|
val zone = try {
|
||||||
|
if (timezone.isNullOrBlank()) ZoneId.of("Asia/Kolkata") else ZoneId.of(timezone)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
ZoneId.of("Asia/Kolkata")
|
||||||
|
}
|
||||||
|
return OffsetDateTime.now(zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun computeExpectedPay(
|
||||||
|
stays: List<RoomStay>,
|
||||||
|
timezone: String?,
|
||||||
|
billingMode: BookingBillingMode,
|
||||||
|
billingCheckinTime: String?,
|
||||||
|
billingCheckoutTime: String?
|
||||||
|
): Long {
|
||||||
|
if (stays.isEmpty()) return 0
|
||||||
|
val now = nowForProperty(timezone)
|
||||||
|
var total = 0L
|
||||||
|
stays.forEach { stay ->
|
||||||
|
if (stay.isVoided) return@forEach
|
||||||
|
val rate = stay.nightlyRate ?: 0L
|
||||||
|
if (rate == 0L) return@forEach
|
||||||
|
val endAt = stay.toAt ?: now
|
||||||
|
val nights = billableNights(
|
||||||
|
stay.fromAt,
|
||||||
|
endAt,
|
||||||
|
timezone,
|
||||||
|
billingMode,
|
||||||
|
billingCheckinTime,
|
||||||
|
billingCheckoutTime
|
||||||
|
)
|
||||||
|
total += rate * nights
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun computeExpectedPayTotal(
|
||||||
|
stays: List<RoomStay>,
|
||||||
|
expectedCheckoutAt: OffsetDateTime?,
|
||||||
|
timezone: String?,
|
||||||
|
billingMode: BookingBillingMode,
|
||||||
|
billingCheckinTime: String?,
|
||||||
|
billingCheckoutTime: String?
|
||||||
|
): Long {
|
||||||
|
if (stays.isEmpty()) return 0
|
||||||
|
val now = nowForProperty(timezone)
|
||||||
|
var total = 0L
|
||||||
|
stays.forEach { stay ->
|
||||||
|
if (stay.isVoided) return@forEach
|
||||||
|
val rate = stay.nightlyRate ?: 0L
|
||||||
|
if (rate == 0L) return@forEach
|
||||||
|
val endAt = stay.toAt ?: expectedCheckoutAt ?: now
|
||||||
|
val nights = billableNights(
|
||||||
|
stay.fromAt,
|
||||||
|
endAt,
|
||||||
|
timezone,
|
||||||
|
billingMode,
|
||||||
|
billingCheckinTime,
|
||||||
|
billingCheckoutTime
|
||||||
|
)
|
||||||
|
total += rate * nights
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val DEFAULT_BILLING_CHECKIN_TIME = "12:00"
|
||||||
|
private const val DEFAULT_BILLING_CHECKOUT_TIME = "11:00"
|
||||||
|
|
||||||
|
internal fun billableNights(
|
||||||
|
startAt: OffsetDateTime,
|
||||||
|
endAt: OffsetDateTime,
|
||||||
|
timezone: String?,
|
||||||
|
billingMode: BookingBillingMode,
|
||||||
|
billingCheckinTime: String?,
|
||||||
|
billingCheckoutTime: String?
|
||||||
|
): Long {
|
||||||
|
if (!endAt.isAfter(startAt)) return 1L
|
||||||
|
if (billingMode == BookingBillingMode.FULL_24H) {
|
||||||
|
val minutes = java.time.Duration.between(startAt, endAt).toMinutes().coerceAtLeast(0L)
|
||||||
|
if (minutes == 0L) return 1L
|
||||||
|
val fullDays = minutes / (24L * 60L)
|
||||||
|
val remainder = minutes % (24L * 60L)
|
||||||
|
val extraNight = if (remainder > 120L) 1L else 0L
|
||||||
|
return (fullDays + extraNight).coerceAtLeast(1L)
|
||||||
|
}
|
||||||
|
val zone = try {
|
||||||
|
if (timezone.isNullOrBlank()) ZoneId.of("Asia/Kolkata") else ZoneId.of(timezone)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
ZoneId.of("Asia/Kolkata")
|
||||||
|
}
|
||||||
|
val checkinPolicy = parseBillingTimeOrDefault(billingCheckinTime, DEFAULT_BILLING_CHECKIN_TIME)
|
||||||
|
val checkoutPolicy = parseBillingTimeOrDefault(billingCheckoutTime, DEFAULT_BILLING_CHECKOUT_TIME)
|
||||||
|
val localStart = startAt.atZoneSameInstant(zone)
|
||||||
|
val localEnd = endAt.atZoneSameInstant(zone)
|
||||||
|
var billStartDate = localStart.toLocalDate()
|
||||||
|
if (localStart.toLocalTime().isBefore(checkinPolicy)) {
|
||||||
|
billStartDate = billStartDate.minusDays(1)
|
||||||
|
}
|
||||||
|
var billEndDate = localEnd.toLocalDate()
|
||||||
|
if (localEnd.toLocalTime().isAfter(checkoutPolicy)) {
|
||||||
|
billEndDate = billEndDate.plusDays(1)
|
||||||
|
}
|
||||||
|
val diff = billEndDate.toEpochDay() - billStartDate.toEpochDay()
|
||||||
|
return if (diff <= 0L) 1L else diff
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseBillingTimeOrDefault(raw: String?, fallback: String): LocalTime {
|
||||||
|
val value = raw?.trim()?.takeIf { it.isNotEmpty() } ?: fallback
|
||||||
|
return try {
|
||||||
|
LocalTime.parse(value)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
LocalTime.parse(fallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun daysBetweenInclusive(start: LocalDate, end: LocalDate): Long {
|
||||||
|
val diff = end.toEpochDay() - start.toEpochDay()
|
||||||
|
return if (diff <= 0) 1L else diff
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
package com.android.trisolarisserver.controller
|
package com.android.trisolarisserver.controller.document
|
||||||
|
|
||||||
object DocumentPrompts {
|
object DocumentPrompts {
|
||||||
val NAME = "name" to "NAME? Reply only the name or NONE."
|
val NAME = "name" to "NAME? Reply only the name or NONE."
|
||||||
val DOB = "dob" to "DOB? Reply only date or NONE."
|
val DOB = "dob" to "DOB? Reply only date or NONE."
|
||||||
val ID_NUMBER = "idNumber" to "ID NUMBER? Reply only number or NONE."
|
val ID_NUMBER = "idNumber" to "ID NUMBER?. Reply only number or NONE."
|
||||||
val ADDRESS = "address" to "POSTAL ADDRESS ONLY (street/area/city/state). Ignore IDs, UUIDs, and codes. Reply only address or NONE."
|
val ADDRESS = "address" to "POSTAL ADDRESS ONLY (street/area/city/state). Ignore IDs, UUIDs, and codes. Reply only address or NONE."
|
||||||
val PIN_CODE = "pinCode" to "POSTAL PIN CODE? Reply only pin or NONE."
|
val PIN_CODE = "pinCode" to "CITY POSTAL PIN CODE. Reply only pin or NONE."
|
||||||
val CITY = "city" to "CITY? Reply only city or NONE."
|
val CITY = "city" to "CITY? Reply only city or NONE."
|
||||||
val GENDER = "gender" to "GENDER? Reply only MALE/FEMALE/OTHER or NONE."
|
val GENDER = "gender" to "GENDER? Reply only MALE/FEMALE/OTHER or NONE."
|
||||||
val NATIONALITY = "nationality" to "NATIONALITY? Reply only nationality or NONE."
|
val NATIONALITY = "nationality" to "NATIONALITY? Reply only nationality or NONE."
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
package com.android.trisolarisserver.controller.dto
|
|
||||||
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
data class PayuSettingsUpsertRequest(
|
|
||||||
val merchantKey: String,
|
|
||||||
val salt32: String? = null,
|
|
||||||
val salt256: String? = null,
|
|
||||||
val isTest: Boolean? = null,
|
|
||||||
val useSalt256: Boolean? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PayuSettingsResponse(
|
|
||||||
val propertyId: UUID,
|
|
||||||
val configured: Boolean,
|
|
||||||
val merchantKey: String?,
|
|
||||||
val isTest: Boolean,
|
|
||||||
val useSalt256: Boolean,
|
|
||||||
val hasSalt32: Boolean,
|
|
||||||
val hasSalt256: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PayuQrGenerateRequest(
|
|
||||||
val amount: Long? = null,
|
|
||||||
val customerName: String? = null,
|
|
||||||
val customerEmail: String? = null,
|
|
||||||
val customerPhone: String? = null,
|
|
||||||
val expiryMinutes: Int? = null,
|
|
||||||
val expirySeconds: Int? = null,
|
|
||||||
val clientIp: String? = null,
|
|
||||||
val deviceInfo: String? = null,
|
|
||||||
val address1: String? = null,
|
|
||||||
val address2: String? = null,
|
|
||||||
val city: String? = null,
|
|
||||||
val state: String? = null,
|
|
||||||
val country: String? = null,
|
|
||||||
val zipcode: String? = null,
|
|
||||||
val udf3: String? = null,
|
|
||||||
val udf4: String? = null,
|
|
||||||
val udf5: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PayuQrGenerateResponse(
|
|
||||||
val txnid: String,
|
|
||||||
val amount: Long,
|
|
||||||
val currency: String,
|
|
||||||
val payuResponse: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PayuPaymentLinkSettingsUpsertRequest(
|
|
||||||
val merchantId: String,
|
|
||||||
val clientId: String? = null,
|
|
||||||
val clientSecret: String? = null,
|
|
||||||
val accessToken: String? = null,
|
|
||||||
val isTest: Boolean? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PayuPaymentLinkSettingsResponse(
|
|
||||||
val propertyId: UUID,
|
|
||||||
val configured: Boolean,
|
|
||||||
val merchantId: String?,
|
|
||||||
val isTest: Boolean,
|
|
||||||
val hasClientId: Boolean,
|
|
||||||
val hasClientSecret: Boolean,
|
|
||||||
val hasAccessToken: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PayuPaymentLinkCreateRequest(
|
|
||||||
val amount: Long? = null,
|
|
||||||
val isAmountFilledByCustomer: Boolean? = null,
|
|
||||||
val isPartialPaymentAllowed: Boolean? = null,
|
|
||||||
val minAmountForCustomer: Long? = null,
|
|
||||||
val description: String? = null,
|
|
||||||
val expiryDate: String? = null,
|
|
||||||
val successUrl: String? = null,
|
|
||||||
val failureUrl: String? = null,
|
|
||||||
val udf3: String? = null,
|
|
||||||
val udf4: String? = null,
|
|
||||||
val udf5: String? = null,
|
|
||||||
val viaEmail: Boolean? = null,
|
|
||||||
val viaSms: Boolean? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PayuPaymentLinkCreateResponse(
|
|
||||||
val amount: Long,
|
|
||||||
val currency: String,
|
|
||||||
val paymentLink: String?,
|
|
||||||
val payuResponse: String
|
|
||||||
)
|
|
||||||
@@ -1,28 +1,19 @@
|
|||||||
package com.android.trisolarisserver.controller.dto
|
package com.android.trisolarisserver.controller.dto.booking
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
data class BookingCheckInRequest(
|
@JsonIgnoreProperties(ignoreUnknown = false)
|
||||||
val roomIds: List<UUID>,
|
|
||||||
val checkInAt: String? = null,
|
|
||||||
val transportMode: String? = null,
|
|
||||||
val nightlyRate: Long? = null,
|
|
||||||
val rateSource: String? = null,
|
|
||||||
val ratePlanCode: String? = null,
|
|
||||||
val currency: String? = null,
|
|
||||||
val notes: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class BookingCheckInStayRequest(
|
data class BookingCheckInStayRequest(
|
||||||
val roomId: UUID,
|
val roomId: UUID,
|
||||||
val checkInAt: String? = null,
|
val checkInAt: String? = null,
|
||||||
val checkOutAt: String? = null,
|
|
||||||
val nightlyRate: Long? = null,
|
val nightlyRate: Long? = null,
|
||||||
val rateSource: String? = null,
|
val rateSource: String? = null,
|
||||||
val ratePlanCode: String? = null,
|
val ratePlanCode: String? = null,
|
||||||
val currency: String? = null
|
val currency: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = false)
|
||||||
data class BookingBulkCheckInRequest(
|
data class BookingBulkCheckInRequest(
|
||||||
val stays: List<BookingCheckInStayRequest>,
|
val stays: List<BookingCheckInStayRequest>,
|
||||||
val transportMode: String? = null,
|
val transportMode: String? = null,
|
||||||
@@ -33,6 +24,8 @@ data class BookingCreateRequest(
|
|||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
val expectedCheckInAt: String,
|
val expectedCheckInAt: String,
|
||||||
val expectedCheckOutAt: String,
|
val expectedCheckOutAt: String,
|
||||||
|
val billingMode: String? = null,
|
||||||
|
val billingCheckoutTime: String? = null,
|
||||||
val guestPhoneE164: String? = null,
|
val guestPhoneE164: String? = null,
|
||||||
val fromCity: String? = null,
|
val fromCity: String? = null,
|
||||||
val toCity: String? = null,
|
val toCity: String? = null,
|
||||||
@@ -48,6 +41,9 @@ data class BookingCreateRequest(
|
|||||||
data class BookingCreateResponse(
|
data class BookingCreateResponse(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
val status: String,
|
val status: String,
|
||||||
|
val billingMode: String,
|
||||||
|
val billingCheckinTime: String,
|
||||||
|
val billingCheckoutTime: String,
|
||||||
val guestId: UUID?,
|
val guestId: UUID?,
|
||||||
val checkInAt: String?,
|
val checkInAt: String?,
|
||||||
val expectedCheckInAt: String?,
|
val expectedCheckInAt: String?,
|
||||||
@@ -60,8 +56,12 @@ data class BookingListItem(
|
|||||||
val guestId: UUID?,
|
val guestId: UUID?,
|
||||||
val guestName: String?,
|
val guestName: String?,
|
||||||
val guestPhone: String?,
|
val guestPhone: String?,
|
||||||
|
val vehicleNumbers: List<String>,
|
||||||
val roomNumbers: List<Int>,
|
val roomNumbers: List<Int>,
|
||||||
val source: String?,
|
val source: String?,
|
||||||
|
val billingMode: String,
|
||||||
|
val billingCheckinTime: String,
|
||||||
|
val billingCheckoutTime: String,
|
||||||
val expectedCheckInAt: String?,
|
val expectedCheckInAt: String?,
|
||||||
val expectedCheckOutAt: String?,
|
val expectedCheckOutAt: String?,
|
||||||
val checkInAt: String?,
|
val checkInAt: String?,
|
||||||
@@ -84,9 +84,14 @@ data class BookingDetailResponse(
|
|||||||
val guestPhone: String?,
|
val guestPhone: String?,
|
||||||
val guestNationality: String?,
|
val guestNationality: String?,
|
||||||
val guestAddressText: String?,
|
val guestAddressText: String?,
|
||||||
|
val guestAge: String?,
|
||||||
val guestSignatureUrl: String?,
|
val guestSignatureUrl: String?,
|
||||||
|
val vehicleNumbers: List<String>,
|
||||||
val roomNumbers: List<Int>,
|
val roomNumbers: List<Int>,
|
||||||
val source: String?,
|
val source: String?,
|
||||||
|
val billingMode: String,
|
||||||
|
val billingCheckinTime: String,
|
||||||
|
val billingCheckoutTime: String,
|
||||||
val fromCity: String?,
|
val fromCity: String?,
|
||||||
val toCity: String?,
|
val toCity: String?,
|
||||||
val memberRelation: String?,
|
val memberRelation: String?,
|
||||||
@@ -105,6 +110,7 @@ data class BookingDetailResponse(
|
|||||||
val registeredByName: String?,
|
val registeredByName: String?,
|
||||||
val registeredByPhone: String?,
|
val registeredByPhone: String?,
|
||||||
val totalNightlyRate: Long,
|
val totalNightlyRate: Long,
|
||||||
|
val billableNights: Long?,
|
||||||
val expectedPay: Long,
|
val expectedPay: Long,
|
||||||
val amountCollected: Long,
|
val amountCollected: Long,
|
||||||
val pending: Long
|
val pending: Long
|
||||||
@@ -119,11 +125,43 @@ data class BookingExpectedDatesUpdateRequest(
|
|||||||
val expectedCheckOutAt: String? = null
|
val expectedCheckOutAt: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class BookingBillingPolicyUpdateRequest(
|
||||||
|
val billingMode: String,
|
||||||
|
val billingCheckoutTime: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
data class BookingCheckOutRequest(
|
data class BookingCheckOutRequest(
|
||||||
val checkOutAt: String? = null,
|
val checkOutAt: String? = null,
|
||||||
val notes: String? = null
|
val notes: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class BookingBillableNightsRequest(
|
||||||
|
val expectedCheckInAt: String? = null,
|
||||||
|
val expectedCheckOutAt: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BookingBillableNightsResponse(
|
||||||
|
val bookingId: UUID,
|
||||||
|
val status: String,
|
||||||
|
val billableNights: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BookingExpectedCheckoutPreviewRequest(
|
||||||
|
val checkInAt: String,
|
||||||
|
val billableNights: Long? = null,
|
||||||
|
val billingMode: String? = null,
|
||||||
|
val billingCheckinTime: String? = null,
|
||||||
|
val billingCheckoutTime: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BookingExpectedCheckoutPreviewResponse(
|
||||||
|
val expectedCheckOutAt: String,
|
||||||
|
val billableNights: Long,
|
||||||
|
val billingMode: String,
|
||||||
|
val billingCheckinTime: String,
|
||||||
|
val billingCheckoutTime: String
|
||||||
|
)
|
||||||
|
|
||||||
data class BookingCancelRequest(
|
data class BookingCancelRequest(
|
||||||
val cancelledAt: String? = null,
|
val cancelledAt: String? = null,
|
||||||
val reason: String? = null
|
val reason: String? = null
|
||||||
@@ -134,29 +172,8 @@ data class BookingNoShowRequest(
|
|||||||
val reason: String? = null
|
val reason: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RoomChangeRequest(
|
data class RoomStayVoidRequest(
|
||||||
val newRoomId: UUID,
|
val reason: String? = null
|
||||||
val movedAt: String? = null,
|
|
||||||
val idempotencyKey: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class RoomChangeResponse(
|
|
||||||
val oldRoomStayId: UUID,
|
|
||||||
val newRoomStayId: UUID,
|
|
||||||
val oldRoomId: UUID,
|
|
||||||
val newRoomId: UUID,
|
|
||||||
val movedAt: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class RoomStayPreAssignRequest(
|
|
||||||
val roomId: UUID,
|
|
||||||
val fromAt: String,
|
|
||||||
val toAt: String,
|
|
||||||
val nightlyRate: Long? = null,
|
|
||||||
val rateSource: String? = null,
|
|
||||||
val ratePlanCode: String? = null,
|
|
||||||
val currency: String? = null,
|
|
||||||
val notes: String? = null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class IssueCardRequest(
|
data class IssueCardRequest(
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.android.trisolarisserver.controller.dto.booking
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class BookingRoomRequestCreateRequest(
|
||||||
|
val roomTypeCode: String,
|
||||||
|
val quantity: Int,
|
||||||
|
val fromAt: String,
|
||||||
|
val toAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BookingRoomRequestResponse(
|
||||||
|
val id: UUID,
|
||||||
|
val bookingId: UUID,
|
||||||
|
val roomTypeCode: String,
|
||||||
|
val quantity: Int,
|
||||||
|
val fulfilledQuantity: Int,
|
||||||
|
val remainingQuantity: Int,
|
||||||
|
val fromAt: String,
|
||||||
|
val toAt: String,
|
||||||
|
val status: String
|
||||||
|
)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.android.trisolarisserver.controller.dto
|
package com.android.trisolarisserver.controller.dto.guest
|
||||||
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.android.trisolarisserver.controller.dto
|
package com.android.trisolarisserver.controller.dto.payment
|
||||||
|
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.android.trisolarisserver.controller.dto
|
package com.android.trisolarisserver.controller.dto.payment
|
||||||
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.android.trisolarisserver.controller.dto.property
|
||||||
|
|
||||||
|
data class CancellationPolicyUpsertRequest(
|
||||||
|
val freeDaysBeforeCheckin: Int = 0,
|
||||||
|
val penaltyMode: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CancellationPolicyResponse(
|
||||||
|
val freeDaysBeforeCheckin: Int,
|
||||||
|
val penaltyMode: String
|
||||||
|
)
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
package com.android.trisolarisserver.controller.dto
|
package com.android.trisolarisserver.controller.dto.property
|
||||||
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
data class PropertyCreateRequest(
|
data class PropertyCreateRequest(
|
||||||
val code: String,
|
|
||||||
val name: String,
|
val name: String,
|
||||||
val addressText: String? = null,
|
val addressText: String? = null,
|
||||||
val timezone: String? = null,
|
val timezone: String? = null,
|
||||||
val currency: String? = null,
|
val currency: String? = null,
|
||||||
|
val billingCheckinTime: String? = null,
|
||||||
|
val billingCheckoutTime: String? = null,
|
||||||
val active: Boolean? = null,
|
val active: Boolean? = null,
|
||||||
val otaAliases: Set<String>? = null,
|
val otaAliases: Set<String>? = null,
|
||||||
val emailAddresses: Set<String>? = null,
|
val emailAddresses: Set<String>? = null,
|
||||||
@@ -20,6 +21,8 @@ data class PropertyUpdateRequest(
|
|||||||
val addressText: String? = null,
|
val addressText: String? = null,
|
||||||
val timezone: String? = null,
|
val timezone: String? = null,
|
||||||
val currency: String? = null,
|
val currency: String? = null,
|
||||||
|
val billingCheckinTime: String? = null,
|
||||||
|
val billingCheckoutTime: String? = null,
|
||||||
val active: Boolean? = null,
|
val active: Boolean? = null,
|
||||||
val otaAliases: Set<String>? = null,
|
val otaAliases: Set<String>? = null,
|
||||||
val emailAddresses: Set<String>? = null,
|
val emailAddresses: Set<String>? = null,
|
||||||
@@ -33,16 +36,34 @@ data class PropertyResponse(
|
|||||||
val addressText: String?,
|
val addressText: String?,
|
||||||
val timezone: String,
|
val timezone: String,
|
||||||
val currency: String,
|
val currency: String,
|
||||||
|
val billingCheckinTime: String,
|
||||||
|
val billingCheckoutTime: String,
|
||||||
val active: Boolean,
|
val active: Boolean,
|
||||||
val otaAliases: Set<String>,
|
val otaAliases: Set<String>,
|
||||||
val emailAddresses: Set<String>,
|
val emailAddresses: Set<String>,
|
||||||
val allowedTransportModes: Set<String>
|
val allowedTransportModes: Set<String>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class PropertyCodeResponse(
|
||||||
|
val code: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PropertyBillingPolicyRequest(
|
||||||
|
val billingCheckinTime: String,
|
||||||
|
val billingCheckoutTime: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PropertyBillingPolicyResponse(
|
||||||
|
val propertyId: UUID,
|
||||||
|
val billingCheckinTime: String,
|
||||||
|
val billingCheckoutTime: String
|
||||||
|
)
|
||||||
|
|
||||||
data class GuestResponse(
|
data class GuestResponse(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
val name: String?,
|
val name: String?,
|
||||||
val phoneE164: String?,
|
val phoneE164: String?,
|
||||||
|
val dob: String?,
|
||||||
val nationality: String?,
|
val nationality: String?,
|
||||||
val addressText: String?,
|
val addressText: String?,
|
||||||
val signatureUrl: String?,
|
val signatureUrl: String?,
|
||||||
@@ -85,6 +106,10 @@ data class PropertyUserRoleRequest(
|
|||||||
val roles: Set<String>
|
val roles: Set<String>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class PropertyUserDisableRequest(
|
||||||
|
val disabled: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
data class PropertyUserResponse(
|
data class PropertyUserResponse(
|
||||||
val userId: UUID,
|
val userId: UUID,
|
||||||
val propertyId: UUID,
|
val propertyId: UUID,
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.android.trisolarisserver.controller.dto.property
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class AppUserSummaryResponse(
|
||||||
|
val id: UUID,
|
||||||
|
val phoneE164: String?,
|
||||||
|
val name: String?,
|
||||||
|
val disabled: Boolean,
|
||||||
|
val superAdmin: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PropertyUserDetailsResponse(
|
||||||
|
val userId: UUID,
|
||||||
|
val propertyId: UUID,
|
||||||
|
val roles: Set<String>,
|
||||||
|
val name: String?,
|
||||||
|
val phoneE164: String?,
|
||||||
|
val disabled: Boolean,
|
||||||
|
val superAdmin: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PropertyAccessCodeCreateRequest(
|
||||||
|
val roles: Set<String>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PropertyAccessCodeResponse(
|
||||||
|
val propertyId: UUID,
|
||||||
|
val code: String,
|
||||||
|
val expiresAt: OffsetDateTime,
|
||||||
|
val roles: Set<String>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PropertyAccessCodeJoinRequest(
|
||||||
|
val propertyCode: String? = null,
|
||||||
|
val propertyId: String? = null,
|
||||||
|
val code: String
|
||||||
|
)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.android.trisolarisserver.controller.dto
|
package com.android.trisolarisserver.controller.dto.rate
|
||||||
|
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package com.android.trisolarisserver.controller.dto.razorpay
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class RazorpaySettingsUpsertRequest(
|
||||||
|
val keyId: String? = null,
|
||||||
|
val keySecret: String? = null,
|
||||||
|
val webhookSecret: String? = null,
|
||||||
|
val keyIdTest: String? = null,
|
||||||
|
val keySecretTest: String? = null,
|
||||||
|
val webhookSecretTest: String? = null,
|
||||||
|
val isTest: Boolean? = null,
|
||||||
|
// Backward-compatible aliases (older clients sending PayU-shaped payloads)
|
||||||
|
val merchantKey: String? = null,
|
||||||
|
val salt32: String? = null,
|
||||||
|
val salt256: String? = null,
|
||||||
|
val useSalt256: Boolean? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RazorpaySettingsResponse(
|
||||||
|
val propertyId: UUID,
|
||||||
|
val configured: Boolean,
|
||||||
|
val isTest: Boolean,
|
||||||
|
val hasKeyId: Boolean,
|
||||||
|
val hasKeySecret: Boolean,
|
||||||
|
val hasWebhookSecret: Boolean,
|
||||||
|
val hasKeyIdTest: Boolean,
|
||||||
|
val hasKeySecretTest: Boolean,
|
||||||
|
val hasWebhookSecretTest: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RazorpayQrGenerateRequest(
|
||||||
|
val amount: Long? = null,
|
||||||
|
val customerName: String? = null,
|
||||||
|
val customerEmail: String? = null,
|
||||||
|
val customerPhone: String? = null,
|
||||||
|
val expiryMinutes: Int? = null,
|
||||||
|
val expirySeconds: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RazorpayQrGenerateResponse(
|
||||||
|
val qrId: String?,
|
||||||
|
val amount: Long,
|
||||||
|
val currency: String,
|
||||||
|
val imageUrl: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RazorpayPaymentLinkCreateRequest(
|
||||||
|
val amount: Long? = null,
|
||||||
|
val isPartialPaymentAllowed: Boolean? = null,
|
||||||
|
val minAmountForCustomer: Long? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val expiryDate: String? = null,
|
||||||
|
val successUrl: String? = null,
|
||||||
|
val failureUrl: String? = null,
|
||||||
|
val viaEmail: Boolean? = null,
|
||||||
|
val viaSms: Boolean? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RazorpayPaymentLinkCreateResponse(
|
||||||
|
val amount: Long,
|
||||||
|
val currency: String,
|
||||||
|
val paymentLink: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RazorpayQrEventResponse(
|
||||||
|
val event: String?,
|
||||||
|
val qrId: String?,
|
||||||
|
val status: String?,
|
||||||
|
val receivedAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RazorpayQrRecordResponse(
|
||||||
|
val qrId: String?,
|
||||||
|
val amount: Long,
|
||||||
|
val currency: String,
|
||||||
|
val status: String,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val expiryAt: String?,
|
||||||
|
val createdAt: String,
|
||||||
|
val responsePayload: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RazorpayPaymentRequestResponse(
|
||||||
|
val type: String,
|
||||||
|
val requestId: UUID,
|
||||||
|
val amount: Long,
|
||||||
|
val currency: String,
|
||||||
|
val status: String,
|
||||||
|
val createdAt: String,
|
||||||
|
val qrId: String? = null,
|
||||||
|
val imageUrl: String? = null,
|
||||||
|
val expiryAt: String? = null,
|
||||||
|
val paymentLinkId: String? = null,
|
||||||
|
val paymentLink: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RazorpayPaymentRequestCloseRequest(
|
||||||
|
val qrId: String? = null,
|
||||||
|
val paymentLinkId: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RazorpayPaymentRequestCloseResponse(
|
||||||
|
val type: String,
|
||||||
|
val qrId: String? = null,
|
||||||
|
val paymentLinkId: String? = null,
|
||||||
|
val status: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RazorpayRefundRequest(
|
||||||
|
val paymentId: UUID? = null,
|
||||||
|
val amount: Long? = null,
|
||||||
|
val notes: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RazorpayRefundResponse(
|
||||||
|
val refundId: String?,
|
||||||
|
val status: String?,
|
||||||
|
val amount: Long?,
|
||||||
|
val currency: String?
|
||||||
|
)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.android.trisolarisserver.controller.dto
|
package com.android.trisolarisserver.controller.dto.room
|
||||||
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -27,16 +27,10 @@ data class RoomAvailabilityResponse(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class RoomAvailabilityRangeResponse(
|
data class RoomAvailabilityRangeResponse(
|
||||||
val roomTypeName: String,
|
|
||||||
val freeRoomNumbers: List<Int>,
|
|
||||||
val freeCount: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
data class RoomAvailabilityWithRateResponse(
|
|
||||||
val roomId: UUID,
|
|
||||||
val roomNumber: Int,
|
|
||||||
val roomTypeCode: String,
|
val roomTypeCode: String,
|
||||||
val roomTypeName: String,
|
val roomTypeName: String,
|
||||||
|
val freeRoomNumbers: List<Int>,
|
||||||
|
val freeCount: Int,
|
||||||
val averageRate: Double?,
|
val averageRate: Double?,
|
||||||
val currency: String,
|
val currency: String,
|
||||||
val ratePlanCode: String? = null
|
val ratePlanCode: String? = null
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.android.trisolarisserver.controller.dto
|
package com.android.trisolarisserver.controller.dto.room
|
||||||
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -13,5 +13,6 @@ data class ActiveRoomStayResponse(
|
|||||||
val roomTypeName: String,
|
val roomTypeName: String,
|
||||||
val fromAt: String,
|
val fromAt: String,
|
||||||
val checkinAt: String?,
|
val checkinAt: String?,
|
||||||
val expectedCheckoutAt: String?
|
val expectedCheckoutAt: String?,
|
||||||
|
val nightlyRate: Long?
|
||||||
)
|
)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.android.trisolarisserver.controller.dto
|
package com.android.trisolarisserver.controller.dto.room
|
||||||
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
package com.android.trisolarisserver.controller
|
package com.android.trisolarisserver.controller.email
|
||||||
|
import com.android.trisolarisserver.controller.common.requireMember
|
||||||
|
import com.android.trisolarisserver.controller.common.requirePrincipal
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.EmailStorage
|
import com.android.trisolarisserver.component.storage.EmailStorage
|
||||||
import com.android.trisolarisserver.component.PropertyAccess
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
import com.android.trisolarisserver.db.repo.InboundEmailRepo
|
import com.android.trisolarisserver.repo.email.InboundEmailRepo
|
||||||
import com.android.trisolarisserver.models.booking.InboundEmail
|
import com.android.trisolarisserver.models.booking.InboundEmail
|
||||||
import com.android.trisolarisserver.models.booking.InboundEmailStatus
|
import com.android.trisolarisserver.models.booking.InboundEmailStatus
|
||||||
import com.android.trisolarisserver.models.property.Role
|
import com.android.trisolarisserver.models.property.Role
|
||||||
import com.android.trisolarisserver.repo.PropertyRepo
|
import com.android.trisolarisserver.repo.property.PropertyRepo
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
import com.android.trisolarisserver.service.EmailIngestionService
|
import com.android.trisolarisserver.service.email.EmailIngestionService
|
||||||
import org.apache.pdfbox.pdmodel.PDDocument
|
import org.apache.pdfbox.pdmodel.PDDocument
|
||||||
import org.apache.pdfbox.text.PDFTextStripper
|
import org.apache.pdfbox.text.PDFTextStripper
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.android.trisolarisserver.controller
|
package com.android.trisolarisserver.controller.email
|
||||||
|
import com.android.trisolarisserver.controller.common.requireMember
|
||||||
|
import com.android.trisolarisserver.controller.common.requirePrincipal
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.PropertyAccess
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
import com.android.trisolarisserver.db.repo.InboundEmailRepo
|
import com.android.trisolarisserver.repo.email.InboundEmailRepo
|
||||||
import com.android.trisolarisserver.models.property.Role
|
import com.android.trisolarisserver.models.property.Role
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
import org.springframework.core.io.FileSystemResource
|
import org.springframework.core.io.FileSystemResource
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.android.trisolarisserver.controller.guest
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.models.booking.GuestDocument
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class GuestDocumentResponse(
|
||||||
|
val id: UUID,
|
||||||
|
val propertyId: UUID,
|
||||||
|
val guestId: UUID,
|
||||||
|
val bookingId: UUID,
|
||||||
|
val uploadedByUserId: UUID,
|
||||||
|
val uploadedAt: String,
|
||||||
|
val originalFilename: String,
|
||||||
|
val contentType: String?,
|
||||||
|
val sizeBytes: Long,
|
||||||
|
val extractedData: Map<String, String>?,
|
||||||
|
val extractedAt: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
fun GuestDocument.toResponse(objectMapper: ObjectMapper): GuestDocumentResponse {
|
||||||
|
val id = id ?: throw IllegalStateException("Document id missing")
|
||||||
|
val extracted: Map<String, String>? = extractedData?.let {
|
||||||
|
try {
|
||||||
|
val raw = objectMapper.readValue(it, Map::class.java)
|
||||||
|
raw.entries.associate { entry ->
|
||||||
|
entry.key.toString() to (entry.value?.toString() ?: "")
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return GuestDocumentResponse(
|
||||||
|
id = id,
|
||||||
|
propertyId = property.id!!,
|
||||||
|
guestId = guest.id!!,
|
||||||
|
bookingId = booking.id!!,
|
||||||
|
uploadedByUserId = uploadedBy.id!!,
|
||||||
|
uploadedAt = uploadedAt.toString(),
|
||||||
|
originalFilename = originalFilename,
|
||||||
|
contentType = contentType,
|
||||||
|
sizeBytes = sizeBytes,
|
||||||
|
extractedData = extracted,
|
||||||
|
extractedAt = extractedAt?.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,18 +1,24 @@
|
|||||||
package com.android.trisolarisserver.controller
|
package com.android.trisolarisserver.controller.guest
|
||||||
|
import com.android.trisolarisserver.controller.common.requireMember
|
||||||
|
import com.android.trisolarisserver.controller.common.requireProperty
|
||||||
|
import com.android.trisolarisserver.controller.common.requirePropertyGuest
|
||||||
|
import com.android.trisolarisserver.controller.common.requireRole
|
||||||
|
import com.android.trisolarisserver.controller.common.requireUser
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.DocumentStorage
|
import com.android.trisolarisserver.component.storage.DocumentStorage
|
||||||
import com.android.trisolarisserver.component.DocumentTokenService
|
import com.android.trisolarisserver.component.document.DocumentTokenService
|
||||||
import com.android.trisolarisserver.component.ExtractionQueue
|
import com.android.trisolarisserver.component.ai.ExtractionQueue
|
||||||
import com.android.trisolarisserver.component.GuestDocumentEvents
|
import com.android.trisolarisserver.component.document.GuestDocumentEvents
|
||||||
import com.android.trisolarisserver.component.DocumentExtractionService
|
import com.android.trisolarisserver.component.document.DocumentExtractionService
|
||||||
import com.android.trisolarisserver.component.PropertyAccess
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
import com.android.trisolarisserver.db.repo.BookingRepo
|
import com.android.trisolarisserver.repo.booking.BookingRepo
|
||||||
import com.android.trisolarisserver.db.repo.GuestDocumentRepo
|
import com.android.trisolarisserver.repo.guest.GuestDocumentRepo
|
||||||
import com.android.trisolarisserver.db.repo.GuestRepo
|
import com.android.trisolarisserver.repo.guest.GuestRepo
|
||||||
|
import com.android.trisolarisserver.models.booking.BookingStatus
|
||||||
import com.android.trisolarisserver.models.booking.GuestDocument
|
import com.android.trisolarisserver.models.booking.GuestDocument
|
||||||
import com.android.trisolarisserver.models.property.Role
|
import com.android.trisolarisserver.models.property.Role
|
||||||
import com.android.trisolarisserver.repo.AppUserRepo
|
import com.android.trisolarisserver.repo.property.AppUserRepo
|
||||||
import com.android.trisolarisserver.repo.PropertyRepo
|
import com.android.trisolarisserver.repo.property.PropertyRepo
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import org.springframework.core.io.FileSystemResource
|
import org.springframework.core.io.FileSystemResource
|
||||||
@@ -31,6 +37,7 @@ import java.nio.file.Files
|
|||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/properties/{propertyId}/guests/{guestId}/documents")
|
@RequestMapping("/properties/{propertyId}/guests/{guestId}/documents")
|
||||||
@@ -52,6 +59,7 @@ class GuestDocuments(
|
|||||||
@org.springframework.beans.factory.annotation.Value("\${storage.documents.aiBaseUrl:\${storage.documents.publicBaseUrl}}")
|
@org.springframework.beans.factory.annotation.Value("\${storage.documents.aiBaseUrl:\${storage.documents.publicBaseUrl}}")
|
||||||
private val aiBaseUrl: String
|
private val aiBaseUrl: String
|
||||||
) {
|
) {
|
||||||
|
private val logger = LoggerFactory.getLogger(GuestDocuments::class.java)
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
@@ -136,6 +144,7 @@ class GuestDocuments(
|
|||||||
): org.springframework.web.servlet.mvc.method.annotation.SseEmitter {
|
): org.springframework.web.servlet.mvc.method.annotation.SseEmitter {
|
||||||
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
|
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
|
||||||
response.setHeader("Cache-Control", "no-cache")
|
response.setHeader("Cache-Control", "no-cache")
|
||||||
|
response.setHeader("Connection", "keep-alive")
|
||||||
response.setHeader("X-Accel-Buffering", "no")
|
response.setHeader("X-Accel-Buffering", "no")
|
||||||
return guestDocumentEvents.subscribe(propertyId, guestId)
|
return guestDocumentEvents.subscribe(propertyId, guestId)
|
||||||
}
|
}
|
||||||
@@ -184,9 +193,13 @@ class GuestDocuments(
|
|||||||
val document = guestDocumentRepo.findByIdAndPropertyIdAndGuestId(documentId, propertyId, guestId)
|
val document = guestDocumentRepo.findByIdAndPropertyIdAndGuestId(documentId, propertyId, guestId)
|
||||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Document not found")
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Document not found")
|
||||||
val status = document.booking.status
|
val status = document.booking.status
|
||||||
if (status != com.android.trisolarisserver.models.booking.BookingStatus.OPEN &&
|
val linkedBookingOpenOrCheckedIn = status == BookingStatus.OPEN || status == BookingStatus.CHECKED_IN
|
||||||
status != com.android.trisolarisserver.models.booking.BookingStatus.CHECKED_IN
|
val guestHasOpenOrCheckedInBooking = bookingRepo.existsByPropertyIdAndPrimaryGuestIdAndStatusIn(
|
||||||
) {
|
propertyId = propertyId,
|
||||||
|
primaryGuestId = guestId,
|
||||||
|
status = listOf(BookingStatus.OPEN, BookingStatus.CHECKED_IN)
|
||||||
|
)
|
||||||
|
if (!linkedBookingOpenOrCheckedIn && !guestHasOpenOrCheckedInBooking) {
|
||||||
throw ResponseStatusException(
|
throw ResponseStatusException(
|
||||||
HttpStatus.BAD_REQUEST,
|
HttpStatus.BAD_REQUEST,
|
||||||
"Documents can only be deleted for OPEN or CHECKED_IN bookings"
|
"Documents can only be deleted for OPEN or CHECKED_IN bookings"
|
||||||
@@ -211,15 +224,17 @@ class GuestDocuments(
|
|||||||
val imageUrl =
|
val imageUrl =
|
||||||
"${aiBaseUrl}/properties/$propertyId/guests/$guestId/documents/${document.id}/file?token=$token"
|
"${aiBaseUrl}/properties/$propertyId/guests/$guestId/documents/${document.id}/file?token=$token"
|
||||||
|
|
||||||
val extraction = extractionService.extractAndApply(imageUrl, document, propertyId)
|
val publicImageUrl =
|
||||||
|
"${publicBaseUrl.trimEnd('/')}/properties/$propertyId/guests/$guestId/documents/${document.id}/file?token=$token"
|
||||||
|
val extraction = extractionService.extractAndApply(imageUrl, publicImageUrl, document, propertyId)
|
||||||
val results = extraction.results
|
val results = extraction.results
|
||||||
|
|
||||||
document.extractedData = objectMapper.writeValueAsString(results)
|
document.extractedData = objectMapper.writeValueAsString(results)
|
||||||
document.extractedAt = OffsetDateTime.now()
|
document.extractedAt = OffsetDateTime.now()
|
||||||
guestDocumentRepo.save(document)
|
guestDocumentRepo.save(document)
|
||||||
guestDocumentEvents.emit(propertyId, guestId)
|
guestDocumentEvents.emit(propertyId, guestId)
|
||||||
} catch (_: Exception) {
|
} catch (ex: Exception) {
|
||||||
// Keep upload successful even if AI extraction fails.
|
logger.warn("Document extraction failed for documentId={}", document.id, ex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
package com.android.trisolarisserver.controller
|
package com.android.trisolarisserver.controller.guest
|
||||||
|
import com.android.trisolarisserver.controller.common.requireMember
|
||||||
|
import com.android.trisolarisserver.controller.common.requireProperty
|
||||||
|
import com.android.trisolarisserver.controller.common.requirePropertyGuest
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.PropertyAccess
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
import com.android.trisolarisserver.controller.dto.GuestRatingCreateRequest
|
import com.android.trisolarisserver.controller.dto.guest.GuestRatingCreateRequest
|
||||||
import com.android.trisolarisserver.controller.dto.GuestRatingResponse
|
import com.android.trisolarisserver.controller.dto.guest.GuestRatingResponse
|
||||||
import com.android.trisolarisserver.db.repo.BookingRepo
|
import com.android.trisolarisserver.repo.booking.BookingRepo
|
||||||
import com.android.trisolarisserver.db.repo.GuestRatingRepo
|
import com.android.trisolarisserver.repo.guest.GuestRatingRepo
|
||||||
import com.android.trisolarisserver.db.repo.GuestRepo
|
import com.android.trisolarisserver.repo.guest.GuestRepo
|
||||||
import com.android.trisolarisserver.repo.AppUserRepo
|
import com.android.trisolarisserver.repo.property.AppUserRepo
|
||||||
import com.android.trisolarisserver.repo.PropertyRepo
|
import com.android.trisolarisserver.repo.property.PropertyRepo
|
||||||
import com.android.trisolarisserver.models.booking.GuestRating
|
import com.android.trisolarisserver.models.booking.GuestRating
|
||||||
import com.android.trisolarisserver.models.booking.GuestRatingScore
|
import com.android.trisolarisserver.models.booking.GuestRatingScore
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
package com.android.trisolarisserver.controller
|
package com.android.trisolarisserver.controller.guest
|
||||||
|
import com.android.trisolarisserver.controller.common.requireMember
|
||||||
|
import com.android.trisolarisserver.controller.common.requireProperty
|
||||||
|
import com.android.trisolarisserver.controller.common.requirePropertyGuest
|
||||||
|
import com.android.trisolarisserver.controller.common.requireRole
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.PropertyAccess
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
import com.android.trisolarisserver.component.GuestSignatureStorage
|
import com.android.trisolarisserver.component.storage.GuestSignatureStorage
|
||||||
import com.android.trisolarisserver.controller.dto.GuestResponse
|
import com.android.trisolarisserver.controller.dto.property.GuestResponse
|
||||||
import com.android.trisolarisserver.controller.dto.GuestUpdateRequest
|
import com.android.trisolarisserver.controller.dto.property.GuestUpdateRequest
|
||||||
import com.android.trisolarisserver.controller.dto.GuestVehicleRequest
|
import com.android.trisolarisserver.controller.dto.property.GuestVehicleRequest
|
||||||
import com.android.trisolarisserver.controller.dto.GuestVisitCountResponse
|
import com.android.trisolarisserver.controller.dto.property.GuestVisitCountResponse
|
||||||
import com.android.trisolarisserver.models.booking.Guest
|
import com.android.trisolarisserver.models.booking.Guest
|
||||||
import com.android.trisolarisserver.models.booking.GuestVehicle
|
import com.android.trisolarisserver.models.booking.GuestVehicle
|
||||||
import com.android.trisolarisserver.db.repo.BookingRepo
|
import com.android.trisolarisserver.repo.booking.BookingRepo
|
||||||
import com.android.trisolarisserver.db.repo.GuestRepo
|
import com.android.trisolarisserver.repo.guest.GuestRepo
|
||||||
import com.android.trisolarisserver.db.repo.GuestRatingRepo
|
import com.android.trisolarisserver.repo.guest.GuestRatingRepo
|
||||||
import com.android.trisolarisserver.repo.GuestVehicleRepo
|
import com.android.trisolarisserver.repo.guest.GuestVehicleRepo
|
||||||
import com.android.trisolarisserver.repo.PropertyRepo
|
import com.android.trisolarisserver.repo.property.PropertyRepo
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
import org.springframework.core.io.FileSystemResource
|
import org.springframework.core.io.FileSystemResource
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
@@ -228,6 +232,7 @@ private fun Set<Guest>.toResponse(
|
|||||||
id = guest.id!!,
|
id = guest.id!!,
|
||||||
name = guest.name,
|
name = guest.name,
|
||||||
phoneE164 = guest.phoneE164,
|
phoneE164 = guest.phoneE164,
|
||||||
|
dob = guest.age?.trim()?.ifBlank { null },
|
||||||
nationality = guest.nationality,
|
nationality = guest.nationality,
|
||||||
addressText = guest.addressText,
|
addressText = guest.addressText,
|
||||||
signatureUrl = guest.signaturePath?.let {
|
signatureUrl = guest.signaturePath?.let {
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
package com.android.trisolarisserver.controller
|
package com.android.trisolarisserver.controller.payment
|
||||||
|
import com.android.trisolarisserver.controller.common.nowForProperty
|
||||||
|
import com.android.trisolarisserver.controller.common.parseOffset
|
||||||
|
import com.android.trisolarisserver.controller.common.requireMember
|
||||||
|
import com.android.trisolarisserver.controller.common.requireRole
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.PropertyAccess
|
import com.android.trisolarisserver.component.booking.BookingEvents
|
||||||
import com.android.trisolarisserver.controller.dto.ChargeCreateRequest
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
import com.android.trisolarisserver.controller.dto.ChargeResponse
|
import com.android.trisolarisserver.controller.dto.payment.ChargeCreateRequest
|
||||||
import com.android.trisolarisserver.db.repo.BookingRepo
|
import com.android.trisolarisserver.controller.dto.payment.ChargeResponse
|
||||||
|
import com.android.trisolarisserver.repo.booking.BookingRepo
|
||||||
import com.android.trisolarisserver.models.booking.Charge
|
import com.android.trisolarisserver.models.booking.Charge
|
||||||
import com.android.trisolarisserver.models.booking.ChargeType
|
import com.android.trisolarisserver.models.booking.ChargeType
|
||||||
import com.android.trisolarisserver.models.property.Role
|
import com.android.trisolarisserver.models.property.Role
|
||||||
import com.android.trisolarisserver.repo.AppUserRepo
|
import com.android.trisolarisserver.repo.property.AppUserRepo
|
||||||
import com.android.trisolarisserver.repo.ChargeRepo
|
import com.android.trisolarisserver.repo.booking.ChargeRepo
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
@@ -29,7 +34,8 @@ class Charges(
|
|||||||
private val propertyAccess: PropertyAccess,
|
private val propertyAccess: PropertyAccess,
|
||||||
private val bookingRepo: BookingRepo,
|
private val bookingRepo: BookingRepo,
|
||||||
private val chargeRepo: ChargeRepo,
|
private val chargeRepo: ChargeRepo,
|
||||||
private val appUserRepo: AppUserRepo
|
private val appUserRepo: AppUserRepo,
|
||||||
|
private val bookingEvents: BookingEvents
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@@ -65,7 +71,9 @@ class Charges(
|
|||||||
occurredAt = occurredAt,
|
occurredAt = occurredAt,
|
||||||
createdBy = createdBy
|
createdBy = createdBy
|
||||||
)
|
)
|
||||||
return chargeRepo.save(charge).toResponse()
|
val saved = chargeRepo.save(charge).toResponse()
|
||||||
|
bookingEvents.emit(propertyId, bookingId)
|
||||||
|
return saved
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
package com.android.trisolarisserver.controller
|
package com.android.trisolarisserver.controller.payment
|
||||||
|
import com.android.trisolarisserver.controller.common.parseOffset
|
||||||
|
import com.android.trisolarisserver.controller.common.requireMember
|
||||||
|
import com.android.trisolarisserver.controller.common.requireRole
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.PropertyAccess
|
import com.android.trisolarisserver.component.booking.BookingEvents
|
||||||
import com.android.trisolarisserver.controller.dto.PaymentCreateRequest
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
import com.android.trisolarisserver.controller.dto.PaymentResponse
|
import com.android.trisolarisserver.controller.dto.payment.PaymentCreateRequest
|
||||||
import com.android.trisolarisserver.db.repo.BookingRepo
|
import com.android.trisolarisserver.controller.dto.payment.PaymentResponse
|
||||||
|
import com.android.trisolarisserver.repo.booking.BookingRepo
|
||||||
import com.android.trisolarisserver.models.booking.BookingStatus
|
import com.android.trisolarisserver.models.booking.BookingStatus
|
||||||
import com.android.trisolarisserver.models.booking.Payment
|
import com.android.trisolarisserver.models.booking.Payment
|
||||||
import com.android.trisolarisserver.models.booking.PaymentMethod
|
import com.android.trisolarisserver.models.booking.PaymentMethod
|
||||||
import com.android.trisolarisserver.models.property.Role
|
import com.android.trisolarisserver.models.property.Role
|
||||||
import com.android.trisolarisserver.repo.AppUserRepo
|
import com.android.trisolarisserver.repo.property.AppUserRepo
|
||||||
import com.android.trisolarisserver.repo.PaymentRepo
|
import com.android.trisolarisserver.repo.booking.PaymentRepo
|
||||||
import com.android.trisolarisserver.repo.PropertyRepo
|
import com.android.trisolarisserver.repo.property.PropertyRepo
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
@@ -34,7 +38,8 @@ class Payments(
|
|||||||
private val propertyRepo: PropertyRepo,
|
private val propertyRepo: PropertyRepo,
|
||||||
private val bookingRepo: BookingRepo,
|
private val bookingRepo: BookingRepo,
|
||||||
private val paymentRepo: PaymentRepo,
|
private val paymentRepo: PaymentRepo,
|
||||||
private val appUserRepo: AppUserRepo
|
private val appUserRepo: AppUserRepo,
|
||||||
|
private val bookingEvents: BookingEvents
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@@ -72,7 +77,9 @@ class Payments(
|
|||||||
receivedAt = receivedAt,
|
receivedAt = receivedAt,
|
||||||
receivedBy = appUserRepo.findById(actor.userId).orElse(null)
|
receivedBy = appUserRepo.findById(actor.userId).orElse(null)
|
||||||
)
|
)
|
||||||
return paymentRepo.save(payment).toResponse()
|
val saved = paymentRepo.save(payment).toResponse()
|
||||||
|
bookingEvents.emit(propertyId, bookingId)
|
||||||
|
return saved
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -122,6 +129,7 @@ class Payments(
|
|||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Only CASH payments can be deleted")
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Only CASH payments can be deleted")
|
||||||
}
|
}
|
||||||
paymentRepo.delete(payment)
|
paymentRepo.delete(payment)
|
||||||
|
bookingEvents.emit(propertyId, bookingId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseMethod(value: String): PaymentMethod {
|
private fun parseMethod(value: String): PaymentMethod {
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.android.trisolarisserver.controller.property
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
|
import com.android.trisolarisserver.controller.common.requireRole
|
||||||
|
import com.android.trisolarisserver.controller.dto.property.CancellationPolicyResponse
|
||||||
|
import com.android.trisolarisserver.controller.dto.property.CancellationPolicyUpsertRequest
|
||||||
|
import com.android.trisolarisserver.models.property.CancellationPenaltyMode
|
||||||
|
import com.android.trisolarisserver.models.property.PropertyCancellationPolicy
|
||||||
|
import com.android.trisolarisserver.models.property.Role
|
||||||
|
import com.android.trisolarisserver.repo.property.PropertyCancellationPolicyRepo
|
||||||
|
import com.android.trisolarisserver.repo.property.PropertyRepo
|
||||||
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/properties/{propertyId}/cancellation-policy")
|
||||||
|
class CancellationPolicies(
|
||||||
|
private val propertyAccess: PropertyAccess,
|
||||||
|
private val propertyRepo: PropertyRepo,
|
||||||
|
private val policyRepo: PropertyCancellationPolicyRepo
|
||||||
|
) {
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun get(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
): CancellationPolicyResponse {
|
||||||
|
val policy = policyRepo.findByPropertyId(propertyId)
|
||||||
|
return if (policy != null) {
|
||||||
|
CancellationPolicyResponse(
|
||||||
|
freeDaysBeforeCheckin = policy.freeDaysBeforeCheckin,
|
||||||
|
penaltyMode = policy.penaltyMode.name
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CancellationPolicyResponse(
|
||||||
|
freeDaysBeforeCheckin = 0,
|
||||||
|
penaltyMode = CancellationPenaltyMode.FULL_STAY.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping
|
||||||
|
fun upsert(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@RequestBody request: CancellationPolicyUpsertRequest
|
||||||
|
): CancellationPolicyResponse {
|
||||||
|
requireRole(propertyAccess, propertyId, principal, Role.ADMIN)
|
||||||
|
if (request.freeDaysBeforeCheckin < 0) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "freeDaysBeforeCheckin must be >= 0")
|
||||||
|
}
|
||||||
|
val mode = try {
|
||||||
|
CancellationPenaltyMode.valueOf(request.penaltyMode.trim().uppercase())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown penaltyMode")
|
||||||
|
}
|
||||||
|
val property = propertyRepo.findById(propertyId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
||||||
|
}
|
||||||
|
val existing = policyRepo.findByPropertyId(propertyId)
|
||||||
|
val saved = if (existing != null) {
|
||||||
|
existing.freeDaysBeforeCheckin = request.freeDaysBeforeCheckin
|
||||||
|
existing.penaltyMode = mode
|
||||||
|
existing.updatedAt = OffsetDateTime.now()
|
||||||
|
policyRepo.save(existing)
|
||||||
|
} else {
|
||||||
|
policyRepo.save(
|
||||||
|
PropertyCancellationPolicy(
|
||||||
|
property = property,
|
||||||
|
freeDaysBeforeCheckin = request.freeDaysBeforeCheckin,
|
||||||
|
penaltyMode = mode
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return CancellationPolicyResponse(
|
||||||
|
freeDaysBeforeCheckin = saved.freeDaysBeforeCheckin,
|
||||||
|
penaltyMode = saved.penaltyMode.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,21 @@
|
|||||||
package com.android.trisolarisserver.controller
|
package com.android.trisolarisserver.controller.property
|
||||||
|
import com.android.trisolarisserver.controller.common.requireMember
|
||||||
|
import com.android.trisolarisserver.controller.common.requirePrincipal
|
||||||
|
import com.android.trisolarisserver.controller.common.requireUser
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.PropertyAccess
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
import com.android.trisolarisserver.controller.dto.PropertyCreateRequest
|
import com.android.trisolarisserver.controller.dto.property.PropertyCodeResponse
|
||||||
import com.android.trisolarisserver.controller.dto.PropertyResponse
|
import com.android.trisolarisserver.controller.dto.property.PropertyBillingPolicyRequest
|
||||||
import com.android.trisolarisserver.controller.dto.PropertyUpdateRequest
|
import com.android.trisolarisserver.controller.dto.property.PropertyBillingPolicyResponse
|
||||||
import com.android.trisolarisserver.controller.dto.PropertyUserResponse
|
import com.android.trisolarisserver.controller.dto.property.PropertyCreateRequest
|
||||||
import com.android.trisolarisserver.controller.dto.PropertyUserRoleRequest
|
import com.android.trisolarisserver.controller.dto.property.PropertyResponse
|
||||||
import com.android.trisolarisserver.repo.AppUserRepo
|
import com.android.trisolarisserver.controller.dto.property.PropertyUserDisableRequest
|
||||||
import com.android.trisolarisserver.repo.PropertyRepo
|
import com.android.trisolarisserver.controller.dto.property.PropertyUpdateRequest
|
||||||
import com.android.trisolarisserver.repo.PropertyUserRepo
|
import com.android.trisolarisserver.controller.dto.property.PropertyUserResponse
|
||||||
|
import com.android.trisolarisserver.controller.dto.property.PropertyUserRoleRequest
|
||||||
|
import com.android.trisolarisserver.repo.property.AppUserRepo
|
||||||
|
import com.android.trisolarisserver.repo.property.PropertyRepo
|
||||||
|
import com.android.trisolarisserver.repo.property.PropertyUserRepo
|
||||||
import com.android.trisolarisserver.models.property.Property
|
import com.android.trisolarisserver.models.property.Property
|
||||||
import com.android.trisolarisserver.models.property.PropertyUser
|
import com.android.trisolarisserver.models.property.PropertyUser
|
||||||
import com.android.trisolarisserver.models.property.PropertyUserId
|
import com.android.trisolarisserver.models.property.PropertyUserId
|
||||||
@@ -26,6 +33,9 @@ import org.springframework.web.bind.annotation.RequestBody
|
|||||||
import org.springframework.web.bind.annotation.ResponseStatus
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import org.springframework.web.server.ResponseStatusException
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.DateTimeParseException
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -35,6 +45,8 @@ class Properties(
|
|||||||
private val propertyUserRepo: PropertyUserRepo,
|
private val propertyUserRepo: PropertyUserRepo,
|
||||||
private val appUserRepo: AppUserRepo
|
private val appUserRepo: AppUserRepo
|
||||||
) {
|
) {
|
||||||
|
private val codeRandom = java.security.SecureRandom()
|
||||||
|
private val codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
|
||||||
@PostMapping("/properties")
|
@PostMapping("/properties")
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
@@ -42,17 +54,17 @@ class Properties(
|
|||||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
@RequestBody request: PropertyCreateRequest
|
@RequestBody request: PropertyCreateRequest
|
||||||
): PropertyResponse {
|
): PropertyResponse {
|
||||||
val user = requireUser(principal)
|
val user = requireUser(appUserRepo, principal)
|
||||||
if (propertyRepo.existsByCode(request.code)) {
|
val code = generatePropertyCode()
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Property code already exists")
|
|
||||||
}
|
|
||||||
|
|
||||||
val property = Property(
|
val property = Property(
|
||||||
code = request.code,
|
code = code,
|
||||||
name = request.name,
|
name = request.name,
|
||||||
addressText = request.addressText,
|
addressText = request.addressText,
|
||||||
timezone = request.timezone ?: "Asia/Kolkata",
|
timezone = request.timezone ?: "Asia/Kolkata",
|
||||||
currency = request.currency ?: "INR",
|
currency = request.currency ?: "INR",
|
||||||
|
billingCheckinTime = validateBillingTime(request.billingCheckinTime, "billingCheckinTime", "12:00"),
|
||||||
|
billingCheckoutTime = validateBillingTime(request.billingCheckoutTime, "billingCheckoutTime", "11:00"),
|
||||||
active = request.active ?: true,
|
active = request.active ?: true,
|
||||||
otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf(),
|
otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf(),
|
||||||
emailAddresses = request.emailAddresses?.toMutableSet() ?: mutableSetOf(),
|
emailAddresses = request.emailAddresses?.toMutableSet() ?: mutableSetOf(),
|
||||||
@@ -80,7 +92,7 @@ class Properties(
|
|||||||
fun listProperties(
|
fun listProperties(
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
): List<PropertyResponse> {
|
): List<PropertyResponse> {
|
||||||
val user = requireUser(principal)
|
val user = requireUser(appUserRepo, principal)
|
||||||
return if (user.superAdmin) {
|
return if (user.superAdmin) {
|
||||||
propertyRepo.findAll().map { it.toResponse() }
|
propertyRepo.findAll().map { it.toResponse() }
|
||||||
} else {
|
} else {
|
||||||
@@ -89,6 +101,66 @@ class Properties(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/properties/{propertyId}/code")
|
||||||
|
fun getPropertyCode(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
): PropertyCodeResponse {
|
||||||
|
requirePrincipal(principal)
|
||||||
|
propertyAccess.requireMember(propertyId, principal!!.userId)
|
||||||
|
val property = propertyRepo.findById(propertyId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
||||||
|
}
|
||||||
|
return PropertyCodeResponse(code = property.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/properties/{propertyId}/billing-policy")
|
||||||
|
fun getBillingPolicy(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
): PropertyBillingPolicyResponse {
|
||||||
|
requirePrincipal(principal)
|
||||||
|
propertyAccess.requireMember(propertyId, principal!!.userId)
|
||||||
|
val property = propertyRepo.findById(propertyId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
||||||
|
}
|
||||||
|
return PropertyBillingPolicyResponse(
|
||||||
|
propertyId = property.id!!,
|
||||||
|
billingCheckinTime = property.billingCheckinTime,
|
||||||
|
billingCheckoutTime = property.billingCheckoutTime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/properties/{propertyId}/billing-policy")
|
||||||
|
fun updateBillingPolicy(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@RequestBody request: PropertyBillingPolicyRequest
|
||||||
|
): PropertyBillingPolicyResponse {
|
||||||
|
requirePrincipal(principal)
|
||||||
|
propertyAccess.requireMember(propertyId, principal!!.userId)
|
||||||
|
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN)
|
||||||
|
val property = propertyRepo.findById(propertyId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
||||||
|
}
|
||||||
|
property.billingCheckinTime = validateBillingTime(
|
||||||
|
request.billingCheckinTime,
|
||||||
|
"billingCheckinTime",
|
||||||
|
property.billingCheckinTime
|
||||||
|
)
|
||||||
|
property.billingCheckoutTime = validateBillingTime(
|
||||||
|
request.billingCheckoutTime,
|
||||||
|
"billingCheckoutTime",
|
||||||
|
property.billingCheckoutTime
|
||||||
|
)
|
||||||
|
val saved = propertyRepo.save(property)
|
||||||
|
return PropertyBillingPolicyResponse(
|
||||||
|
propertyId = saved.id!!,
|
||||||
|
billingCheckinTime = saved.billingCheckinTime,
|
||||||
|
billingCheckoutTime = saved.billingCheckoutTime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/properties/{propertyId}/users")
|
@GetMapping("/properties/{propertyId}/users")
|
||||||
fun listPropertyUsers(
|
fun listPropertyUsers(
|
||||||
@PathVariable propertyId: UUID,
|
@PathVariable propertyId: UUID,
|
||||||
@@ -97,8 +169,14 @@ class Properties(
|
|||||||
requirePrincipal(principal)
|
requirePrincipal(principal)
|
||||||
propertyAccess.requireMember(propertyId, principal!!.userId)
|
propertyAccess.requireMember(propertyId, principal!!.userId)
|
||||||
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
|
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
|
||||||
val users = propertyUserRepo.findByIdPropertyId(propertyId)
|
val actorUser = appUserRepo.findById(principal.userId).orElse(null)
|
||||||
return users.map {
|
val actorRoles = propertyUserRepo.findRolesByPropertyAndUser(propertyId, principal.userId)
|
||||||
|
val actorRank = rankForUser(actorUser?.superAdmin == true, actorRoles)
|
||||||
|
val users = propertyUserRepo.findByPropertyIdWithUser(propertyId)
|
||||||
|
return users
|
||||||
|
.filter { it.id.userId != principal.userId }
|
||||||
|
.filter { actorRank >= rankForUser(it.user.superAdmin, it.roles) }
|
||||||
|
.map {
|
||||||
PropertyUserResponse(
|
PropertyUserResponse(
|
||||||
userId = it.id.userId!!,
|
userId = it.id.userId!!,
|
||||||
propertyId = it.id.propertyId!!,
|
propertyId = it.id.propertyId!!,
|
||||||
@@ -158,6 +236,55 @@ class Properties(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping("/properties/{propertyId}/users/{userId}/disabled")
|
||||||
|
fun updatePropertyUserDisabled(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@RequestBody request: PropertyUserDisableRequest
|
||||||
|
): PropertyUserResponse {
|
||||||
|
requirePrincipal(principal)
|
||||||
|
propertyAccess.requireMember(propertyId, principal!!.userId)
|
||||||
|
|
||||||
|
val actorUser = appUserRepo.findById(principal.userId).orElse(null)
|
||||||
|
val actorRoles = propertyUserRepo.findRolesByPropertyAndUser(propertyId, principal.userId)
|
||||||
|
|
||||||
|
val targetId = PropertyUserId(propertyId = propertyId, userId = userId)
|
||||||
|
val target = propertyUserRepo.findById(targetId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "User not found in property")
|
||||||
|
}
|
||||||
|
val targetRoles = target.roles
|
||||||
|
|
||||||
|
if (actorUser?.superAdmin != true) {
|
||||||
|
val canAdminManage = actorRoles.contains(Role.ADMIN)
|
||||||
|
val canManagerManage = actorRoles.contains(Role.MANAGER)
|
||||||
|
val allowedForManager = setOf(
|
||||||
|
Role.STAFF,
|
||||||
|
Role.AGENT,
|
||||||
|
Role.HOUSEKEEPING,
|
||||||
|
Role.FINANCE,
|
||||||
|
Role.GUIDE,
|
||||||
|
Role.SUPERVISOR
|
||||||
|
)
|
||||||
|
val allowed = when {
|
||||||
|
canAdminManage -> !targetRoles.contains(Role.ADMIN)
|
||||||
|
canManagerManage -> targetRoles.all { allowedForManager.contains(it) }
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
if (!allowed) {
|
||||||
|
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Role not allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target.disabled = request.disabled
|
||||||
|
val saved = propertyUserRepo.save(target)
|
||||||
|
return PropertyUserResponse(
|
||||||
|
userId = saved.id.userId!!,
|
||||||
|
propertyId = saved.id.propertyId!!,
|
||||||
|
roles = saved.roles.map { it.name }.toSet()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/properties/{propertyId}/users/{userId}")
|
@DeleteMapping("/properties/{propertyId}/users/{userId}")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
fun deletePropertyUser(
|
fun deletePropertyUser(
|
||||||
@@ -197,6 +324,16 @@ class Properties(
|
|||||||
property.addressText = request.addressText ?: property.addressText
|
property.addressText = request.addressText ?: property.addressText
|
||||||
property.timezone = request.timezone ?: property.timezone
|
property.timezone = request.timezone ?: property.timezone
|
||||||
property.currency = request.currency ?: property.currency
|
property.currency = request.currency ?: property.currency
|
||||||
|
property.billingCheckinTime = validateBillingTime(
|
||||||
|
request.billingCheckinTime,
|
||||||
|
"billingCheckinTime",
|
||||||
|
property.billingCheckinTime
|
||||||
|
)
|
||||||
|
property.billingCheckoutTime = validateBillingTime(
|
||||||
|
request.billingCheckoutTime,
|
||||||
|
"billingCheckoutTime",
|
||||||
|
property.billingCheckoutTime
|
||||||
|
)
|
||||||
property.active = request.active ?: property.active
|
property.active = request.active ?: property.active
|
||||||
if (request.otaAliases != null) {
|
if (request.otaAliases != null) {
|
||||||
property.otaAliases = request.otaAliases.toMutableSet()
|
property.otaAliases = request.otaAliases.toMutableSet()
|
||||||
@@ -211,21 +348,6 @@ class Properties(
|
|||||||
return propertyRepo.save(property).toResponse()
|
return propertyRepo.save(property).toResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requirePrincipal(principal: MyPrincipal?) {
|
|
||||||
if (principal == null) {
|
|
||||||
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requireUser(principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser {
|
|
||||||
if (principal == null) {
|
|
||||||
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
|
|
||||||
}
|
|
||||||
return appUserRepo.findById(principal.userId).orElseThrow {
|
|
||||||
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseTransportModes(modes: Set<String>): MutableSet<TransportMode> {
|
private fun parseTransportModes(modes: Set<String>): MutableSet<TransportMode> {
|
||||||
return try {
|
return try {
|
||||||
modes.map { TransportMode.valueOf(it) }.toMutableSet()
|
modes.map { TransportMode.valueOf(it) }.toMutableSet()
|
||||||
@@ -233,6 +355,47 @@ class Properties(
|
|||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown transport mode")
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown transport mode")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun validateBillingTime(value: String?, fieldName: String, fallback: String): String {
|
||||||
|
val candidate = value?.trim()?.takeIf { it.isNotEmpty() } ?: fallback
|
||||||
|
return try {
|
||||||
|
LocalTime.parse(candidate, BILLING_TIME_FORMATTER).format(BILLING_TIME_FORMATTER)
|
||||||
|
} catch (_: DateTimeParseException) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$fieldName must be HH:mm")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generatePropertyCode(): String {
|
||||||
|
repeat(10) {
|
||||||
|
val code = buildString(7) {
|
||||||
|
repeat(7) {
|
||||||
|
append(codeAlphabet[codeRandom.nextInt(codeAlphabet.length)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!propertyRepo.existsByCode(code)) {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw ResponseStatusException(HttpStatus.CONFLICT, "Unable to generate property code")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rankForUser(isSuperAdmin: Boolean, roles: Set<Role>): Int {
|
||||||
|
if (isSuperAdmin) return 500
|
||||||
|
return roles.maxOfOrNull { roleRank(it) } ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun roleRank(role: Role): Int {
|
||||||
|
return when (role) {
|
||||||
|
Role.ADMIN -> 400
|
||||||
|
Role.MANAGER -> 300
|
||||||
|
Role.STAFF, Role.HOUSEKEEPING, Role.FINANCE, Role.SUPERVISOR, Role.GUIDE -> 200
|
||||||
|
Role.AGENT -> 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val BILLING_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Property.toResponse(): PropertyResponse {
|
private fun Property.toResponse(): PropertyResponse {
|
||||||
@@ -244,6 +407,8 @@ private fun Property.toResponse(): PropertyResponse {
|
|||||||
addressText = addressText,
|
addressText = addressText,
|
||||||
timezone = timezone,
|
timezone = timezone,
|
||||||
currency = currency,
|
currency = currency,
|
||||||
|
billingCheckinTime = billingCheckinTime,
|
||||||
|
billingCheckoutTime = billingCheckoutTime,
|
||||||
active = active,
|
active = active,
|
||||||
otaAliases = otaAliases.toSet(),
|
otaAliases = otaAliases.toSet(),
|
||||||
emailAddresses = emailAddresses.toSet(),
|
emailAddresses = emailAddresses.toSet(),
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package com.android.trisolarisserver.controller.property
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
|
import com.android.trisolarisserver.controller.common.requirePrincipal
|
||||||
|
import com.android.trisolarisserver.controller.dto.property.PropertyAccessCodeCreateRequest
|
||||||
|
import com.android.trisolarisserver.controller.dto.property.PropertyAccessCodeJoinRequest
|
||||||
|
import com.android.trisolarisserver.controller.dto.property.PropertyAccessCodeResponse
|
||||||
|
import com.android.trisolarisserver.controller.dto.property.PropertyUserResponse
|
||||||
|
import com.android.trisolarisserver.models.property.PropertyAccessCode
|
||||||
|
import com.android.trisolarisserver.models.property.Property
|
||||||
|
import com.android.trisolarisserver.models.property.PropertyUser
|
||||||
|
import com.android.trisolarisserver.models.property.PropertyUserId
|
||||||
|
import com.android.trisolarisserver.models.property.Role
|
||||||
|
import com.android.trisolarisserver.repo.property.AppUserRepo
|
||||||
|
import com.android.trisolarisserver.repo.property.PropertyAccessCodeRepo
|
||||||
|
import com.android.trisolarisserver.repo.property.PropertyRepo
|
||||||
|
import com.android.trisolarisserver.repo.property.PropertyUserRepo
|
||||||
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
class PropertyAccessCodes(
|
||||||
|
private val propertyAccess: PropertyAccess,
|
||||||
|
private val propertyRepo: PropertyRepo,
|
||||||
|
private val propertyUserRepo: PropertyUserRepo,
|
||||||
|
private val accessCodeRepo: PropertyAccessCodeRepo,
|
||||||
|
private val appUserRepo: AppUserRepo
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val secureRandom = SecureRandom()
|
||||||
|
|
||||||
|
@PostMapping("/properties/{propertyId}/access-codes")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
fun createAccessCode(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@RequestBody request: PropertyAccessCodeCreateRequest
|
||||||
|
): PropertyAccessCodeResponse {
|
||||||
|
val resolved = requirePrincipal(principal)
|
||||||
|
propertyAccess.requireMember(propertyId, resolved.userId)
|
||||||
|
propertyAccess.requireAnyRole(propertyId, resolved.userId, Role.ADMIN)
|
||||||
|
|
||||||
|
val roles = parseRoles(request.roles)
|
||||||
|
if (roles.contains(Role.ADMIN)) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "ADMIN cannot be invited by code")
|
||||||
|
}
|
||||||
|
if (roles.isEmpty()) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one role is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
val property = propertyRepo.findById(propertyId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
||||||
|
}
|
||||||
|
val actor = appUserRepo.findById(resolved.userId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
|
||||||
|
}
|
||||||
|
val now = OffsetDateTime.now()
|
||||||
|
val expiresAt = now.plusMinutes(1)
|
||||||
|
val code = generateCode(propertyId, now)
|
||||||
|
|
||||||
|
val accessCode = PropertyAccessCode(
|
||||||
|
property = property,
|
||||||
|
createdBy = actor,
|
||||||
|
code = code,
|
||||||
|
expiresAt = expiresAt,
|
||||||
|
roles = roles.toMutableSet()
|
||||||
|
)
|
||||||
|
accessCodeRepo.save(accessCode)
|
||||||
|
|
||||||
|
return PropertyAccessCodeResponse(
|
||||||
|
propertyId = propertyId,
|
||||||
|
code = code,
|
||||||
|
expiresAt = expiresAt,
|
||||||
|
roles = roles.map { it.name }.toSet()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/properties/access-codes/join")
|
||||||
|
@Transactional
|
||||||
|
fun joinWithAccessCode(
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@RequestBody request: PropertyAccessCodeJoinRequest
|
||||||
|
): PropertyUserResponse {
|
||||||
|
val resolved = requirePrincipal(principal)
|
||||||
|
val code = request.code.trim()
|
||||||
|
if (code.length != 6 || !code.all { it.isDigit() }) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Invalid code")
|
||||||
|
}
|
||||||
|
val now = OffsetDateTime.now()
|
||||||
|
val property = resolveProperty(request)
|
||||||
|
val accessCode = accessCodeRepo.findActiveByPropertyAndCode(property.id!!, code, now)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Invalid code")
|
||||||
|
|
||||||
|
val membershipId = PropertyUserId(propertyId = property.id, userId = resolved.userId)
|
||||||
|
if (propertyUserRepo.existsById(membershipId)) {
|
||||||
|
throw ResponseStatusException(HttpStatus.CONFLICT, "User already a member")
|
||||||
|
}
|
||||||
|
|
||||||
|
val user = appUserRepo.findById(resolved.userId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
val propertyUser = PropertyUser(
|
||||||
|
id = membershipId,
|
||||||
|
property = property,
|
||||||
|
user = user,
|
||||||
|
roles = accessCode.roles.toMutableSet()
|
||||||
|
)
|
||||||
|
propertyUserRepo.save(propertyUser)
|
||||||
|
|
||||||
|
accessCode.usedAt = now
|
||||||
|
accessCode.usedBy = user
|
||||||
|
accessCodeRepo.save(accessCode)
|
||||||
|
|
||||||
|
return PropertyUserResponse(
|
||||||
|
userId = membershipId.userId!!,
|
||||||
|
propertyId = membershipId.propertyId!!,
|
||||||
|
roles = propertyUser.roles.map { it.name }.toSet()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveProperty(request: PropertyAccessCodeJoinRequest): Property {
|
||||||
|
val code = request.propertyCode?.trim().orEmpty()
|
||||||
|
if (code.isNotBlank()) {
|
||||||
|
return propertyRepo.findByCode(code)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
||||||
|
}
|
||||||
|
val rawId = request.propertyId?.trim().orEmpty()
|
||||||
|
if (rawId.isBlank()) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Property code required")
|
||||||
|
}
|
||||||
|
val asUuid = runCatching { UUID.fromString(rawId) }.getOrNull()
|
||||||
|
return if (asUuid != null) {
|
||||||
|
propertyRepo.findById(asUuid).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
propertyRepo.findByCode(rawId)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRoles(input: Set<String>): Set<Role> {
|
||||||
|
return try {
|
||||||
|
input.map { Role.valueOf(it) }.toSet()
|
||||||
|
} catch (ex: IllegalArgumentException) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown role")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateCode(propertyId: UUID, now: OffsetDateTime): String {
|
||||||
|
repeat(6) {
|
||||||
|
val value = secureRandom.nextInt(1_000_000)
|
||||||
|
val code = String.format("%06d", value)
|
||||||
|
if (!accessCodeRepo.existsActiveByPropertyAndCode(propertyId, code, now)) {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw ResponseStatusException(HttpStatus.CONFLICT, "Unable to generate code, try again")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package com.android.trisolarisserver.controller.property
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
|
import com.android.trisolarisserver.controller.common.requirePrincipal
|
||||||
|
import com.android.trisolarisserver.controller.common.requireSuperAdmin
|
||||||
|
import com.android.trisolarisserver.controller.dto.property.AppUserSummaryResponse
|
||||||
|
import com.android.trisolarisserver.controller.dto.property.PropertyUserDetailsResponse
|
||||||
|
import com.android.trisolarisserver.models.property.Role
|
||||||
|
import com.android.trisolarisserver.repo.property.AppUserRepo
|
||||||
|
import com.android.trisolarisserver.repo.property.PropertyUserRepo
|
||||||
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
class UserDirectory(
|
||||||
|
private val appUserRepo: AppUserRepo,
|
||||||
|
private val propertyUserRepo: PropertyUserRepo,
|
||||||
|
private val propertyAccess: PropertyAccess
|
||||||
|
) {
|
||||||
|
|
||||||
|
@GetMapping("/users")
|
||||||
|
fun listAppUsers(
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@RequestParam(required = false) phone: String?
|
||||||
|
): List<AppUserSummaryResponse> {
|
||||||
|
val actor = requireSuperAdmin(appUserRepo, principal)
|
||||||
|
val digits = phone?.filter { it.isDigit() }.orEmpty()
|
||||||
|
val users = when {
|
||||||
|
phone == null -> appUserRepo.findAll()
|
||||||
|
digits.length < 6 -> return emptyList()
|
||||||
|
else -> appUserRepo.findByPhoneE164Containing(digits)
|
||||||
|
}
|
||||||
|
return users.filter { it.id != actor.id }.map {
|
||||||
|
AppUserSummaryResponse(
|
||||||
|
id = it.id!!,
|
||||||
|
phoneE164 = it.phoneE164,
|
||||||
|
name = it.name,
|
||||||
|
disabled = it.disabled,
|
||||||
|
superAdmin = it.superAdmin
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/properties/{propertyId}/users/search")
|
||||||
|
fun searchPropertyUsers(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@RequestParam(required = false) phone: String?
|
||||||
|
): List<PropertyUserDetailsResponse> {
|
||||||
|
val resolved = requirePrincipal(principal)
|
||||||
|
propertyAccess.requireMember(propertyId, resolved.userId)
|
||||||
|
propertyAccess.requireAnyRole(propertyId, resolved.userId, Role.ADMIN)
|
||||||
|
|
||||||
|
val actorUser = appUserRepo.findById(resolved.userId).orElse(null)
|
||||||
|
val actorRoles = propertyUserRepo.findRolesByPropertyAndUser(propertyId, resolved.userId)
|
||||||
|
val actorRank = rankForUser(actorUser?.superAdmin == true, actorRoles)
|
||||||
|
|
||||||
|
val digits = phone?.filter { it.isDigit() }.orEmpty()
|
||||||
|
val users = when {
|
||||||
|
phone == null -> propertyUserRepo.findByPropertyIdWithUser(propertyId)
|
||||||
|
digits.length < 6 -> return emptyList()
|
||||||
|
else -> propertyUserRepo.findByPropertyIdAndPhoneLike(propertyId, digits)
|
||||||
|
}
|
||||||
|
return users
|
||||||
|
.filter { it.id.userId != resolved.userId }
|
||||||
|
.filter { actorRank >= rankForUser(it.user.superAdmin, it.roles) }
|
||||||
|
.map {
|
||||||
|
val user = it.user
|
||||||
|
PropertyUserDetailsResponse(
|
||||||
|
userId = it.id.userId!!,
|
||||||
|
propertyId = it.id.propertyId!!,
|
||||||
|
roles = it.roles.map { role -> role.name }.toSet(),
|
||||||
|
name = user.name,
|
||||||
|
phoneE164 = user.phoneE164,
|
||||||
|
disabled = user.disabled,
|
||||||
|
superAdmin = user.superAdmin
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rankForUser(isSuperAdmin: Boolean, roles: Set<Role>): Int {
|
||||||
|
if (isSuperAdmin) return 500
|
||||||
|
return roles.maxOfOrNull { roleRank(it) } ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun roleRank(role: Role): Int {
|
||||||
|
return when (role) {
|
||||||
|
Role.ADMIN -> 400
|
||||||
|
Role.MANAGER -> 300
|
||||||
|
Role.STAFF, Role.HOUSEKEEPING, Role.FINANCE, Role.SUPERVISOR, Role.GUIDE -> 200
|
||||||
|
Role.AGENT -> 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
package com.android.trisolarisserver.controller
|
package com.android.trisolarisserver.controller.rate
|
||||||
|
import com.android.trisolarisserver.controller.common.parseDate
|
||||||
|
import com.android.trisolarisserver.controller.common.requireMember
|
||||||
|
import com.android.trisolarisserver.controller.common.requireRole
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.PropertyAccess
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
import com.android.trisolarisserver.controller.dto.RateCalendarResponse
|
import com.android.trisolarisserver.controller.dto.rate.RateCalendarResponse
|
||||||
import com.android.trisolarisserver.controller.dto.RateCalendarAverageResponse
|
import com.android.trisolarisserver.controller.dto.rate.RateCalendarAverageResponse
|
||||||
import com.android.trisolarisserver.controller.dto.RateCalendarRangeUpsertRequest
|
import com.android.trisolarisserver.controller.dto.rate.RateCalendarRangeUpsertRequest
|
||||||
import com.android.trisolarisserver.controller.dto.RatePlanCreateRequest
|
import com.android.trisolarisserver.controller.dto.rate.RatePlanCreateRequest
|
||||||
import com.android.trisolarisserver.controller.dto.RatePlanResponse
|
import com.android.trisolarisserver.controller.dto.rate.RatePlanResponse
|
||||||
import com.android.trisolarisserver.controller.dto.RatePlanUpdateRequest
|
import com.android.trisolarisserver.controller.dto.rate.RatePlanUpdateRequest
|
||||||
import com.android.trisolarisserver.repo.PropertyRepo
|
import com.android.trisolarisserver.repo.property.PropertyRepo
|
||||||
import com.android.trisolarisserver.repo.RateCalendarRepo
|
import com.android.trisolarisserver.repo.rate.RateCalendarRepo
|
||||||
import com.android.trisolarisserver.repo.RatePlanRepo
|
import com.android.trisolarisserver.repo.rate.RatePlanRepo
|
||||||
import com.android.trisolarisserver.repo.RoomTypeRepo
|
import com.android.trisolarisserver.repo.room.RoomTypeRepo
|
||||||
import com.android.trisolarisserver.models.property.Role
|
import com.android.trisolarisserver.models.property.Role
|
||||||
import com.android.trisolarisserver.models.room.RateCalendar
|
import com.android.trisolarisserver.models.room.RateCalendar
|
||||||
import com.android.trisolarisserver.models.room.RatePlan
|
import com.android.trisolarisserver.models.room.RatePlan
|
||||||
@@ -132,8 +135,8 @@ class RatePlans(
|
|||||||
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
|
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
|
||||||
val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId)
|
val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId)
|
||||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found")
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found")
|
||||||
val fromDate = parseDate(request.from)
|
val fromDate = parseDate(request.from, "Invalid date")
|
||||||
val toDate = parseDate(request.to)
|
val toDate = parseDate(request.to, "Invalid date")
|
||||||
if (toDate.isBefore(fromDate)) {
|
if (toDate.isBefore(fromDate)) {
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "to must be on/after from")
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "to must be on/after from")
|
||||||
}
|
}
|
||||||
@@ -174,8 +177,8 @@ class RatePlans(
|
|||||||
requireMember(propertyAccess, propertyId, principal)
|
requireMember(propertyAccess, propertyId, principal)
|
||||||
val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId)
|
val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId)
|
||||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found")
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found")
|
||||||
val fromDate = parseDate(from)
|
val fromDate = parseDate(from, "Invalid date")
|
||||||
val toDate = parseDate(to)
|
val toDate = parseDate(to, "Invalid date")
|
||||||
if (toDate.isBefore(fromDate)) {
|
if (toDate.isBefore(fromDate)) {
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "to must be on/after from")
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "to must be on/after from")
|
||||||
}
|
}
|
||||||
@@ -209,20 +212,12 @@ class RatePlans(
|
|||||||
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
|
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
|
||||||
val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId)
|
val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId)
|
||||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found")
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found")
|
||||||
val date = parseDate(rateDate)
|
val date = parseDate(rateDate, "Invalid date")
|
||||||
val existing = rateCalendarRepo.findByRatePlanIdAndRateDate(plan.id!!, date)
|
val existing = rateCalendarRepo.findByRatePlanIdAndRateDate(plan.id!!, date)
|
||||||
?: return
|
?: return
|
||||||
rateCalendarRepo.delete(existing)
|
rateCalendarRepo.delete(existing)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseDate(value: String): LocalDate {
|
|
||||||
return try {
|
|
||||||
LocalDate.parse(value.trim())
|
|
||||||
} catch (_: Exception) {
|
|
||||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun datesBetween(from: LocalDate, to: LocalDate): Sequence<LocalDate> {
|
private fun datesBetween(from: LocalDate, to: LocalDate): Sequence<LocalDate> {
|
||||||
return generateSequence(from) { current ->
|
return generateSequence(from) { current ->
|
||||||
val next = current.plusDays(1)
|
val next = current.plusDays(1)
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package com.android.trisolarisserver.controller.razorpay
|
||||||
|
import com.android.trisolarisserver.controller.common.requireRole
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
|
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayPaymentLinkCreateRequest
|
||||||
|
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayPaymentLinkCreateResponse
|
||||||
|
import com.android.trisolarisserver.models.booking.BookingStatus
|
||||||
|
import com.android.trisolarisserver.models.payment.RazorpayPaymentLinkRequest
|
||||||
|
import com.android.trisolarisserver.models.property.Role
|
||||||
|
import com.android.trisolarisserver.repo.booking.BookingRepo
|
||||||
|
import com.android.trisolarisserver.repo.razorpay.RazorpayPaymentLinkRequestRepo
|
||||||
|
import com.android.trisolarisserver.repo.razorpay.RazorpaySettingsRepo
|
||||||
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import org.springframework.http.HttpEntity
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.HttpMethod
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.client.RestTemplate
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/properties/{propertyId}/bookings/{bookingId}/payments/razorpay")
|
||||||
|
class RazorpayPaymentLinksController(
|
||||||
|
private val propertyAccess: PropertyAccess,
|
||||||
|
private val bookingRepo: BookingRepo,
|
||||||
|
private val settingsRepo: RazorpaySettingsRepo,
|
||||||
|
private val linkRequestRepo: RazorpayPaymentLinkRequestRepo,
|
||||||
|
private val restTemplate: RestTemplate,
|
||||||
|
private val objectMapper: ObjectMapper
|
||||||
|
) {
|
||||||
|
|
||||||
|
@PostMapping("/payment-link")
|
||||||
|
@Transactional
|
||||||
|
fun createPaymentLink(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable bookingId: UUID,
|
||||||
|
@RequestBody request: RazorpayPaymentLinkCreateRequest,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
): RazorpayPaymentLinkCreateResponse {
|
||||||
|
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
|
||||||
|
val booking = bookingRepo.findById(bookingId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
|
||||||
|
}
|
||||||
|
if (booking.property.id != propertyId) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
|
||||||
|
}
|
||||||
|
if (booking.status == BookingStatus.CANCELLED || booking.status == BookingStatus.NO_SHOW) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Booking is not active")
|
||||||
|
}
|
||||||
|
|
||||||
|
val settings = settingsRepo.findByPropertyId(propertyId)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay settings not configured")
|
||||||
|
|
||||||
|
val amount = request.amount ?: 0L
|
||||||
|
if (amount <= 0) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "amount must be > 0")
|
||||||
|
}
|
||||||
|
val currency = booking.property.currency
|
||||||
|
|
||||||
|
val existing = linkRequestRepo.findTopByBookingIdAndAmountAndCurrencyAndStatusOrderByCreatedAtDesc(
|
||||||
|
bookingId,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
"created"
|
||||||
|
)
|
||||||
|
if (existing != null && existing.paymentLinkId != null) {
|
||||||
|
return RazorpayPaymentLinkCreateResponse(
|
||||||
|
amount = existing.amount,
|
||||||
|
currency = existing.currency,
|
||||||
|
paymentLink = existing.shortUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val guest = booking.primaryGuest
|
||||||
|
val guestName = guest?.name?.trim()?.ifBlank { null }
|
||||||
|
val guestPhone = guest?.phoneE164?.trim()?.ifBlank { null }
|
||||||
|
|
||||||
|
val notes = mapOf(
|
||||||
|
"bookingId" to bookingId.toString(),
|
||||||
|
"propertyId" to propertyId.toString()
|
||||||
|
)
|
||||||
|
|
||||||
|
val payload = linkedMapOf<String, Any>(
|
||||||
|
"amount" to amount * 100,
|
||||||
|
"currency" to currency,
|
||||||
|
"description" to (request.description ?: "Booking $bookingId"),
|
||||||
|
"notes" to notes,
|
||||||
|
// Razorpay requires reference_id to be unique per active link
|
||||||
|
"reference_id" to "bk_${bookingId.toString().replace("-", "").take(12)}_${OffsetDateTime.now().toEpochSecond()}"
|
||||||
|
)
|
||||||
|
if (guestName != null || guestPhone != null) {
|
||||||
|
val customer = linkedMapOf<String, Any>()
|
||||||
|
guestName?.let { customer["name"] = it }
|
||||||
|
guestPhone?.let { customer["contact"] = it }
|
||||||
|
payload["customer"] = customer
|
||||||
|
}
|
||||||
|
parseExpiryEpoch(request.expiryDate)?.let { payload["expire_by"] = it }
|
||||||
|
request.isPartialPaymentAllowed?.let { payload["partial_payment"] = it }
|
||||||
|
request.minAmountForCustomer?.let { payload["first_min_partial_amount"] = it * 100 }
|
||||||
|
request.successUrl?.let { payload["callback_url"] = it }
|
||||||
|
if (payload["callback_url"] == null && request.failureUrl != null) {
|
||||||
|
payload["callback_url"] = request.failureUrl
|
||||||
|
}
|
||||||
|
val notify = linkedMapOf<String, Any>()
|
||||||
|
request.viaEmail?.let { notify["email"] = it }
|
||||||
|
request.viaSms?.let { notify["sms"] = it }
|
||||||
|
if (notify.isNotEmpty()) {
|
||||||
|
payload["notify"] = notify
|
||||||
|
}
|
||||||
|
|
||||||
|
val requestPayload = objectMapper.writeValueAsString(payload)
|
||||||
|
val response = postJson(resolveBaseUrl(settings.isTest) + "/payment_links", settings, requestPayload)
|
||||||
|
val body = response.body ?: "{}"
|
||||||
|
val node = objectMapper.readTree(body)
|
||||||
|
val linkId = node.path("id").asText(null)
|
||||||
|
val status = node.path("status").asText("unknown")
|
||||||
|
val shortUrl = node.path("short_url").asText(null)
|
||||||
|
|
||||||
|
val record = linkRequestRepo.save(
|
||||||
|
RazorpayPaymentLinkRequest(
|
||||||
|
property = booking.property,
|
||||||
|
booking = booking,
|
||||||
|
paymentLinkId = linkId,
|
||||||
|
amount = amount,
|
||||||
|
currency = currency,
|
||||||
|
status = status,
|
||||||
|
shortUrl = shortUrl,
|
||||||
|
requestPayload = requestPayload,
|
||||||
|
responsePayload = body,
|
||||||
|
createdAt = OffsetDateTime.now()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.statusCode.is2xxSuccessful) {
|
||||||
|
record.status = "failed"
|
||||||
|
linkRequestRepo.save(record)
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "Razorpay request failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return RazorpayPaymentLinkCreateResponse(
|
||||||
|
amount = amount,
|
||||||
|
currency = currency,
|
||||||
|
paymentLink = shortUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun postJson(url: String, settings: com.android.trisolarisserver.models.payment.RazorpaySettings, json: String): ResponseEntity<String> {
|
||||||
|
val (keyId, keySecret) = requireKeys(settings)
|
||||||
|
val headers = HttpHeaders()
|
||||||
|
headers.contentType = MediaType.APPLICATION_JSON
|
||||||
|
headers.set(HttpHeaders.AUTHORIZATION, basicAuth(keyId, keySecret))
|
||||||
|
return restTemplate.exchange(url, HttpMethod.POST, HttpEntity(json, headers), String::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveBaseUrl(isTest: Boolean): String {
|
||||||
|
return if (isTest) {
|
||||||
|
"https://api.razorpay.com/v1"
|
||||||
|
} else {
|
||||||
|
"https://api.razorpay.com/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun basicAuth(keyId: String, keySecret: String): String {
|
||||||
|
val raw = "$keyId:$keySecret"
|
||||||
|
val encoded = Base64.getEncoder().encodeToString(raw.toByteArray())
|
||||||
|
return "Basic $encoded"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireKeys(settings: com.android.trisolarisserver.models.payment.RazorpaySettings): Pair<String, String> {
|
||||||
|
val keyId = settings.resolveKeyId()
|
||||||
|
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay test keys not configured")
|
||||||
|
val keySecret = settings.resolveKeySecret()
|
||||||
|
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay test keys not configured")
|
||||||
|
return keyId to keySecret
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseExpiryEpoch(value: String?): Long? {
|
||||||
|
if (value.isNullOrBlank()) return null
|
||||||
|
return value.trim().toLongOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package com.android.trisolarisserver.controller.razorpay
|
||||||
|
import com.android.trisolarisserver.controller.common.requireRole
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
|
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayPaymentRequestCloseRequest
|
||||||
|
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayPaymentRequestCloseResponse
|
||||||
|
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayPaymentRequestResponse
|
||||||
|
import com.android.trisolarisserver.repo.booking.BookingRepo
|
||||||
|
import com.android.trisolarisserver.models.property.Role
|
||||||
|
import com.android.trisolarisserver.repo.razorpay.RazorpayPaymentLinkRequestRepo
|
||||||
|
import com.android.trisolarisserver.repo.razorpay.RazorpayQrRequestRepo
|
||||||
|
import com.android.trisolarisserver.repo.razorpay.RazorpaySettingsRepo
|
||||||
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import org.springframework.http.HttpEntity
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.HttpMethod
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.client.RestTemplate
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/properties/{propertyId}/bookings/{bookingId}/payments/razorpay")
|
||||||
|
class RazorpayPaymentRequestsController(
|
||||||
|
private val propertyAccess: PropertyAccess,
|
||||||
|
private val bookingRepo: BookingRepo,
|
||||||
|
private val qrRequestRepo: RazorpayQrRequestRepo,
|
||||||
|
private val paymentLinkRequestRepo: RazorpayPaymentLinkRequestRepo,
|
||||||
|
private val settingsRepo: RazorpaySettingsRepo,
|
||||||
|
private val restTemplate: RestTemplate,
|
||||||
|
private val objectMapper: ObjectMapper
|
||||||
|
) {
|
||||||
|
|
||||||
|
@GetMapping("/requests")
|
||||||
|
fun listRequests(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable bookingId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
): List<RazorpayPaymentRequestResponse> {
|
||||||
|
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
|
||||||
|
val booking = bookingRepo.findById(bookingId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
|
||||||
|
}
|
||||||
|
if (booking.property.id != propertyId) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
|
||||||
|
}
|
||||||
|
|
||||||
|
val qrItems = qrRequestRepo.findByBookingIdOrderByCreatedAtDesc(bookingId)
|
||||||
|
.filter { !isQrClosed(it.status) }
|
||||||
|
.map { qr ->
|
||||||
|
RazorpayPaymentRequestResponse(
|
||||||
|
type = "QR",
|
||||||
|
requestId = qr.id!!,
|
||||||
|
amount = qr.amount,
|
||||||
|
currency = qr.currency,
|
||||||
|
status = qr.status,
|
||||||
|
createdAt = qr.createdAt.toString(),
|
||||||
|
qrId = qr.qrId,
|
||||||
|
imageUrl = qr.imageUrl,
|
||||||
|
expiryAt = qr.expiryAt?.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val linkItems = paymentLinkRequestRepo.findByBookingIdOrderByCreatedAtDesc(bookingId)
|
||||||
|
.filter { !isLinkClosed(it.status) }
|
||||||
|
.map { link ->
|
||||||
|
RazorpayPaymentRequestResponse(
|
||||||
|
type = "PAYMENT_LINK",
|
||||||
|
requestId = link.id!!,
|
||||||
|
amount = link.amount,
|
||||||
|
currency = link.currency,
|
||||||
|
status = link.status,
|
||||||
|
createdAt = link.createdAt.toString(),
|
||||||
|
paymentLinkId = link.paymentLinkId,
|
||||||
|
paymentLink = link.shortUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (qrItems + linkItems).sortedByDescending { it.createdAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/close")
|
||||||
|
fun closeRequest(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable bookingId: UUID,
|
||||||
|
@RequestBody request: RazorpayPaymentRequestCloseRequest,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
): RazorpayPaymentRequestCloseResponse {
|
||||||
|
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
|
||||||
|
val booking = bookingRepo.findById(bookingId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
|
||||||
|
}
|
||||||
|
if (booking.property.id != propertyId) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
|
||||||
|
}
|
||||||
|
|
||||||
|
val qrId = request.qrId?.trim()?.ifBlank { null }
|
||||||
|
val linkId = request.paymentLinkId?.trim()?.ifBlank { null }
|
||||||
|
if ((qrId == null && linkId == null) || (qrId != null && linkId != null)) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Provide exactly one of qrId or paymentLinkId")
|
||||||
|
}
|
||||||
|
|
||||||
|
val settings = settingsRepo.findByPropertyId(propertyId)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay settings not configured")
|
||||||
|
|
||||||
|
if (qrId != null) {
|
||||||
|
val record = qrRequestRepo.findTopByQrIdOrderByCreatedAtDesc(qrId)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "QR not found")
|
||||||
|
if (record.booking.id != bookingId) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "QR not found for booking")
|
||||||
|
}
|
||||||
|
val response = postJson(resolveBaseUrl(settings.isTest) + "/payments/qr_codes/$qrId/close", settings, "{}")
|
||||||
|
if (!response.statusCode.is2xxSuccessful) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "Razorpay close request failed")
|
||||||
|
}
|
||||||
|
record.status = "closed"
|
||||||
|
qrRequestRepo.save(record)
|
||||||
|
return RazorpayPaymentRequestCloseResponse(
|
||||||
|
type = "QR",
|
||||||
|
qrId = qrId,
|
||||||
|
status = "closed"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val paymentLinkId = linkId!!
|
||||||
|
val record = paymentLinkRequestRepo.findTopByPaymentLinkIdOrderByCreatedAtDesc(paymentLinkId)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Payment link not found")
|
||||||
|
if (record.booking.id != bookingId) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Payment link not found for booking")
|
||||||
|
}
|
||||||
|
val response = postJson(resolveBaseUrl(settings.isTest) + "/payment_links/$paymentLinkId/cancel", settings, "{}")
|
||||||
|
if (!response.statusCode.is2xxSuccessful) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "Razorpay cancel request failed")
|
||||||
|
}
|
||||||
|
val body = response.body ?: "{}"
|
||||||
|
val status = runCatching { objectMapper.readTree(body).path("status").asText(null) }.getOrNull()
|
||||||
|
?: "cancelled"
|
||||||
|
record.status = status
|
||||||
|
paymentLinkRequestRepo.save(record)
|
||||||
|
return RazorpayPaymentRequestCloseResponse(
|
||||||
|
type = "PAYMENT_LINK",
|
||||||
|
paymentLinkId = paymentLinkId,
|
||||||
|
status = status
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun postJson(
|
||||||
|
url: String,
|
||||||
|
settings: com.android.trisolarisserver.models.payment.RazorpaySettings,
|
||||||
|
json: String
|
||||||
|
): org.springframework.http.ResponseEntity<String> {
|
||||||
|
val (keyId, keySecret) = requireKeys(settings)
|
||||||
|
val headers = HttpHeaders()
|
||||||
|
headers.contentType = MediaType.APPLICATION_JSON
|
||||||
|
headers.set(HttpHeaders.AUTHORIZATION, basicAuth(keyId, keySecret))
|
||||||
|
return restTemplate.exchange(url, HttpMethod.POST, HttpEntity(json, headers), String::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveBaseUrl(isTest: Boolean): String {
|
||||||
|
return if (isTest) {
|
||||||
|
"https://api.razorpay.com/v1"
|
||||||
|
} else {
|
||||||
|
"https://api.razorpay.com/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun basicAuth(keyId: String, keySecret: String): String {
|
||||||
|
val raw = "$keyId:$keySecret"
|
||||||
|
val encoded = Base64.getEncoder().encodeToString(raw.toByteArray())
|
||||||
|
return "Basic $encoded"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireKeys(settings: com.android.trisolarisserver.models.payment.RazorpaySettings): Pair<String, String> {
|
||||||
|
val keyId = settings.resolveKeyId()
|
||||||
|
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay test keys not configured")
|
||||||
|
val keySecret = settings.resolveKeySecret()
|
||||||
|
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay test keys not configured")
|
||||||
|
return keyId to keySecret
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isQrClosed(status: String?): Boolean {
|
||||||
|
return when (status?.lowercase()) {
|
||||||
|
"closed", "expired", "credited" -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isLinkClosed(status: String?): Boolean {
|
||||||
|
return when (status?.lowercase()) {
|
||||||
|
"cancelled", "canceled", "paid", "expired" -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
package com.android.trisolarisserver.controller.razorpay
|
||||||
|
import com.android.trisolarisserver.controller.common.requireRole
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
|
import com.android.trisolarisserver.component.razorpay.RazorpayQrEvents
|
||||||
|
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayQrGenerateRequest
|
||||||
|
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayQrEventResponse
|
||||||
|
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayQrGenerateResponse
|
||||||
|
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayQrRecordResponse
|
||||||
|
import com.android.trisolarisserver.models.booking.BookingStatus
|
||||||
|
import com.android.trisolarisserver.models.payment.RazorpayQrRequest
|
||||||
|
import com.android.trisolarisserver.models.property.Role
|
||||||
|
import com.android.trisolarisserver.repo.booking.BookingRepo
|
||||||
|
import com.android.trisolarisserver.repo.razorpay.RazorpayQrRequestRepo
|
||||||
|
import com.android.trisolarisserver.repo.razorpay.RazorpaySettingsRepo
|
||||||
|
import com.android.trisolarisserver.repo.razorpay.RazorpayWebhookLogRepo
|
||||||
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import jakarta.servlet.http.HttpServletResponse
|
||||||
|
import org.springframework.http.HttpEntity
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.HttpMethod
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.client.RestTemplate
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/properties/{propertyId}/bookings/{bookingId}/payments/razorpay")
|
||||||
|
class RazorpayQrPayments(
|
||||||
|
private val propertyAccess: PropertyAccess,
|
||||||
|
private val bookingRepo: BookingRepo,
|
||||||
|
private val settingsRepo: RazorpaySettingsRepo,
|
||||||
|
private val qrRequestRepo: RazorpayQrRequestRepo,
|
||||||
|
private val webhookLogRepo: RazorpayWebhookLogRepo,
|
||||||
|
private val qrEvents: RazorpayQrEvents,
|
||||||
|
private val restTemplate: RestTemplate,
|
||||||
|
private val objectMapper: ObjectMapper
|
||||||
|
) {
|
||||||
|
|
||||||
|
@PostMapping("/qr")
|
||||||
|
@Transactional
|
||||||
|
fun createQr(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable bookingId: UUID,
|
||||||
|
@RequestBody request: RazorpayQrGenerateRequest,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
): RazorpayQrGenerateResponse {
|
||||||
|
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
|
||||||
|
val booking = bookingRepo.findById(bookingId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
|
||||||
|
}
|
||||||
|
if (booking.property.id != propertyId) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
|
||||||
|
}
|
||||||
|
if (booking.status == BookingStatus.CANCELLED || booking.status == BookingStatus.NO_SHOW) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Booking is not active")
|
||||||
|
}
|
||||||
|
|
||||||
|
val settings = settingsRepo.findByPropertyId(propertyId)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay settings not configured")
|
||||||
|
|
||||||
|
val amount = request.amount ?: 0L
|
||||||
|
if (amount <= 0) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "amount must be > 0")
|
||||||
|
}
|
||||||
|
val currency = booking.property.currency
|
||||||
|
|
||||||
|
val existing = qrRequestRepo.findTopByBookingIdAndAmountAndCurrencyAndStatusOrderByCreatedAtDesc(
|
||||||
|
bookingId,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
"active"
|
||||||
|
)
|
||||||
|
if (existing != null && existing.qrId != null) {
|
||||||
|
return RazorpayQrGenerateResponse(
|
||||||
|
qrId = existing.qrId,
|
||||||
|
amount = existing.amount,
|
||||||
|
currency = existing.currency,
|
||||||
|
imageUrl = existing.imageUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val expirySeconds = request.expirySeconds ?: request.expiryMinutes?.let { it * 60 } ?: 600
|
||||||
|
val expiresAt = expirySeconds?.let { OffsetDateTime.now().plusSeconds(it.toLong()) }
|
||||||
|
val guest = booking.primaryGuest
|
||||||
|
val guestName = guest?.name?.trim()?.ifBlank { null }
|
||||||
|
val guestPhone = guest?.phoneE164?.trim()?.ifBlank { null }
|
||||||
|
val notes = linkedMapOf(
|
||||||
|
"bookingId" to bookingId.toString(),
|
||||||
|
"propertyId" to propertyId.toString()
|
||||||
|
)
|
||||||
|
guestName?.let { notes["guestName"] = it }
|
||||||
|
guestPhone?.let { notes["guestPhone"] = it }
|
||||||
|
|
||||||
|
val payload = linkedMapOf<String, Any>(
|
||||||
|
"type" to "upi_qr",
|
||||||
|
"name" to "Booking $bookingId",
|
||||||
|
"usage" to "single_use",
|
||||||
|
"fixed_amount" to true,
|
||||||
|
"payment_amount" to amount * 100,
|
||||||
|
"notes" to notes
|
||||||
|
)
|
||||||
|
payload["close_by"] = OffsetDateTime.now().plusSeconds(expirySeconds.toLong()).toEpochSecond()
|
||||||
|
|
||||||
|
val requestPayload = objectMapper.writeValueAsString(payload)
|
||||||
|
val response = postJson(resolveBaseUrl(settings.isTest) + "/payments/qr_codes", settings, requestPayload)
|
||||||
|
val body = response.body ?: "{}"
|
||||||
|
val node = objectMapper.readTree(body)
|
||||||
|
val qrId = node.path("id").asText(null)
|
||||||
|
val status = node.path("status").asText("unknown")
|
||||||
|
val imageUrl = node.path("image_url").asText(null)
|
||||||
|
|
||||||
|
val record = qrRequestRepo.save(
|
||||||
|
RazorpayQrRequest(
|
||||||
|
property = booking.property,
|
||||||
|
booking = booking,
|
||||||
|
qrId = qrId,
|
||||||
|
amount = amount,
|
||||||
|
currency = currency,
|
||||||
|
status = status,
|
||||||
|
imageUrl = imageUrl,
|
||||||
|
requestPayload = requestPayload,
|
||||||
|
responsePayload = body,
|
||||||
|
expiryAt = expiresAt
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.statusCode.is2xxSuccessful) {
|
||||||
|
record.status = "failed"
|
||||||
|
qrRequestRepo.save(record)
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "Razorpay request failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return RazorpayQrGenerateResponse(
|
||||||
|
qrId = qrId,
|
||||||
|
amount = amount,
|
||||||
|
currency = currency,
|
||||||
|
imageUrl = imageUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/qr/active")
|
||||||
|
fun getActiveQr(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable bookingId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
): RazorpayQrGenerateResponse? {
|
||||||
|
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
|
||||||
|
val booking = bookingRepo.findById(bookingId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
|
||||||
|
}
|
||||||
|
if (booking.property.id != propertyId) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
|
||||||
|
}
|
||||||
|
val active = qrRequestRepo.findTopByBookingIdAndStatusOrderByCreatedAtDesc(bookingId, "active") ?: return null
|
||||||
|
return RazorpayQrGenerateResponse(
|
||||||
|
qrId = active.qrId,
|
||||||
|
amount = active.amount,
|
||||||
|
currency = active.currency,
|
||||||
|
imageUrl = active.imageUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/qr/close")
|
||||||
|
@Transactional
|
||||||
|
fun closeActiveQr(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable bookingId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
): RazorpayQrGenerateResponse? {
|
||||||
|
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
|
||||||
|
val booking = bookingRepo.findById(bookingId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
|
||||||
|
}
|
||||||
|
if (booking.property.id != propertyId) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
|
||||||
|
}
|
||||||
|
val active = qrRequestRepo.findTopByBookingIdAndStatusOrderByCreatedAtDesc(bookingId, "active") ?: return null
|
||||||
|
val settings = settingsRepo.findByPropertyId(propertyId)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay settings not configured")
|
||||||
|
val qrId = active.qrId ?: return null
|
||||||
|
val response = postJson(resolveBaseUrl(settings.isTest) + "/payments/qr_codes/$qrId/close", settings, "{}")
|
||||||
|
if (!response.statusCode.is2xxSuccessful) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "Razorpay close request failed")
|
||||||
|
}
|
||||||
|
active.status = "closed"
|
||||||
|
qrRequestRepo.save(active)
|
||||||
|
return RazorpayQrGenerateResponse(
|
||||||
|
qrId = active.qrId,
|
||||||
|
amount = active.amount,
|
||||||
|
currency = active.currency,
|
||||||
|
imageUrl = active.imageUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/qr/{qrId}/close")
|
||||||
|
@Transactional
|
||||||
|
fun closeQrById(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable bookingId: UUID,
|
||||||
|
@PathVariable qrId: String,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
): RazorpayQrGenerateResponse? {
|
||||||
|
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
|
||||||
|
val booking = bookingRepo.findById(bookingId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
|
||||||
|
}
|
||||||
|
if (booking.property.id != propertyId) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
|
||||||
|
}
|
||||||
|
val record = qrRequestRepo.findTopByQrIdOrderByCreatedAtDesc(qrId)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "QR not found")
|
||||||
|
val settings = settingsRepo.findByPropertyId(propertyId)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay settings not configured")
|
||||||
|
val response = postJson(resolveBaseUrl(settings.isTest) + "/payments/qr_codes/$qrId/close", settings, "{}")
|
||||||
|
if (!response.statusCode.is2xxSuccessful) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "Razorpay close request failed")
|
||||||
|
}
|
||||||
|
record.status = "closed"
|
||||||
|
qrRequestRepo.save(record)
|
||||||
|
return RazorpayQrGenerateResponse(
|
||||||
|
qrId = record.qrId,
|
||||||
|
amount = record.amount,
|
||||||
|
currency = record.currency,
|
||||||
|
imageUrl = record.imageUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/qr/{qrId}/events")
|
||||||
|
fun qrEvents(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable qrId: String,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
): List<RazorpayQrEventResponse> {
|
||||||
|
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
|
||||||
|
val logs = webhookLogRepo.findByPropertyIdOrderByReceivedAtDesc(propertyId)
|
||||||
|
val out = mutableListOf<RazorpayQrEventResponse>()
|
||||||
|
for (log in logs) {
|
||||||
|
val payload = log.payload ?: continue
|
||||||
|
val node = runCatching { objectMapper.readTree(payload) }.getOrNull() ?: continue
|
||||||
|
val event = node.path("event").asText(null)
|
||||||
|
val qrEntity = node.path("payload").path("qr_code").path("entity")
|
||||||
|
val eventQrId = qrEntity.path("id").asText(null)
|
||||||
|
if (eventQrId != qrId) continue
|
||||||
|
val status = qrEntity.path("status").asText(null)
|
||||||
|
out.add(
|
||||||
|
RazorpayQrEventResponse(
|
||||||
|
event = event,
|
||||||
|
qrId = eventQrId,
|
||||||
|
status = status,
|
||||||
|
receivedAt = log.receivedAt.toString()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/qr/{qrId}/events/stream", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
|
||||||
|
fun streamQrEvents(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable bookingId: UUID,
|
||||||
|
@PathVariable qrId: String,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
response: HttpServletResponse
|
||||||
|
): SseEmitter {
|
||||||
|
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
|
||||||
|
prepareSse(response)
|
||||||
|
return qrEvents.subscribe(propertyId, qrId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prepareSse(response: HttpServletResponse) {
|
||||||
|
response.setHeader("Cache-Control", "no-cache")
|
||||||
|
response.setHeader("Connection", "keep-alive")
|
||||||
|
response.setHeader("X-Accel-Buffering", "no")
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/qr")
|
||||||
|
fun listQr(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable bookingId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
): List<RazorpayQrRecordResponse> {
|
||||||
|
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
|
||||||
|
val booking = bookingRepo.findById(bookingId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
|
||||||
|
}
|
||||||
|
if (booking.property.id != propertyId) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
|
||||||
|
}
|
||||||
|
return qrRequestRepo.findByBookingIdOrderByCreatedAtDesc(bookingId).map { qr ->
|
||||||
|
RazorpayQrRecordResponse(
|
||||||
|
qrId = qr.qrId,
|
||||||
|
amount = qr.amount,
|
||||||
|
currency = qr.currency,
|
||||||
|
status = qr.status,
|
||||||
|
imageUrl = qr.imageUrl,
|
||||||
|
expiryAt = qr.expiryAt?.toString(),
|
||||||
|
createdAt = qr.createdAt.toString(),
|
||||||
|
responsePayload = qr.responsePayload
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun postJson(url: String, settings: com.android.trisolarisserver.models.payment.RazorpaySettings, json: String): ResponseEntity<String> {
|
||||||
|
val (keyId, keySecret) = requireKeys(settings)
|
||||||
|
val headers = HttpHeaders()
|
||||||
|
headers.contentType = MediaType.APPLICATION_JSON
|
||||||
|
headers.set(HttpHeaders.AUTHORIZATION, basicAuth(keyId, keySecret))
|
||||||
|
return restTemplate.exchange(url, HttpMethod.POST, HttpEntity(json, headers), String::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveBaseUrl(isTest: Boolean): String {
|
||||||
|
return if (isTest) {
|
||||||
|
"https://api.razorpay.com/v1"
|
||||||
|
} else {
|
||||||
|
"https://api.razorpay.com/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun basicAuth(keyId: String, keySecret: String): String {
|
||||||
|
val raw = "$keyId:$keySecret"
|
||||||
|
val encoded = Base64.getEncoder().encodeToString(raw.toByteArray())
|
||||||
|
return "Basic $encoded"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireKeys(settings: com.android.trisolarisserver.models.payment.RazorpaySettings): Pair<String, String> {
|
||||||
|
val keyId = settings.resolveKeyId()
|
||||||
|
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay test keys not configured")
|
||||||
|
val keySecret = settings.resolveKeySecret()
|
||||||
|
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay test keys not configured")
|
||||||
|
return keyId to keySecret
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package com.android.trisolarisserver.controller.razorpay
|
||||||
|
import com.android.trisolarisserver.controller.common.requireRole
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
|
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayRefundRequest
|
||||||
|
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayRefundResponse
|
||||||
|
import com.android.trisolarisserver.repo.booking.BookingRepo
|
||||||
|
import com.android.trisolarisserver.models.property.Role
|
||||||
|
import com.android.trisolarisserver.repo.booking.PaymentRepo
|
||||||
|
import com.android.trisolarisserver.repo.razorpay.RazorpaySettingsRepo
|
||||||
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import org.springframework.http.HttpEntity
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.HttpMethod
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.client.RestTemplate
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/properties/{propertyId}/bookings/{bookingId}/payments/razorpay")
|
||||||
|
class RazorpayRefundsController(
|
||||||
|
private val propertyAccess: PropertyAccess,
|
||||||
|
private val bookingRepo: BookingRepo,
|
||||||
|
private val paymentRepo: PaymentRepo,
|
||||||
|
private val settingsRepo: RazorpaySettingsRepo,
|
||||||
|
private val restTemplate: RestTemplate,
|
||||||
|
private val objectMapper: ObjectMapper
|
||||||
|
) {
|
||||||
|
|
||||||
|
@PostMapping("/refund")
|
||||||
|
fun refund(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable bookingId: UUID,
|
||||||
|
@RequestBody request: RazorpayRefundRequest,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
): RazorpayRefundResponse {
|
||||||
|
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.FINANCE)
|
||||||
|
val booking = bookingRepo.findById(bookingId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
|
||||||
|
}
|
||||||
|
if (booking.property.id != propertyId) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
|
||||||
|
}
|
||||||
|
|
||||||
|
val paymentId = request.paymentId
|
||||||
|
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "paymentId is required")
|
||||||
|
|
||||||
|
val payment = paymentRepo.findById(paymentId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Payment not found")
|
||||||
|
}
|
||||||
|
if (payment.booking.id != bookingId || payment.property.id != propertyId) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Payment not found for booking")
|
||||||
|
}
|
||||||
|
request.amount?.let {
|
||||||
|
if (it > payment.amount) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "amount must be <= payment amount")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val gatewayPaymentId = payment.gatewayPaymentId
|
||||||
|
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Payment is missing gateway id")
|
||||||
|
if (!gatewayPaymentId.startsWith("pay_")) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Payment is not a Razorpay payment")
|
||||||
|
}
|
||||||
|
|
||||||
|
val settings = settingsRepo.findByPropertyId(propertyId)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay settings not configured")
|
||||||
|
|
||||||
|
val payload = linkedMapOf<String, Any>()
|
||||||
|
request.amount?.let {
|
||||||
|
if (it <= 0) throw ResponseStatusException(HttpStatus.BAD_REQUEST, "amount must be > 0")
|
||||||
|
payload["amount"] = it * 100
|
||||||
|
}
|
||||||
|
request.notes?.trim()?.takeIf { it.isNotBlank() }?.let { payload["notes"] = mapOf("note" to it) }
|
||||||
|
|
||||||
|
val response = postJson(resolveBaseUrl(settings.isTest) + "/payments/$gatewayPaymentId/refund", settings, objectMapper.writeValueAsString(payload))
|
||||||
|
if (!response.statusCode.is2xxSuccessful) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "Razorpay refund request failed")
|
||||||
|
}
|
||||||
|
val body = response.body ?: "{}"
|
||||||
|
val node = objectMapper.readTree(body)
|
||||||
|
val refundId = node.path("id").asText(null)
|
||||||
|
val status = node.path("status").asText(null)
|
||||||
|
val amount = node.path("amount").asLong(0).let { if (it == 0L) null else it / 100 }
|
||||||
|
val currency = node.path("currency").asText(null)
|
||||||
|
return RazorpayRefundResponse(
|
||||||
|
refundId = refundId,
|
||||||
|
status = status,
|
||||||
|
amount = amount,
|
||||||
|
currency = currency
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun postJson(
|
||||||
|
url: String,
|
||||||
|
settings: com.android.trisolarisserver.models.payment.RazorpaySettings,
|
||||||
|
json: String
|
||||||
|
): ResponseEntity<String> {
|
||||||
|
val (keyId, keySecret) = requireKeys(settings)
|
||||||
|
val headers = HttpHeaders()
|
||||||
|
headers.contentType = MediaType.APPLICATION_JSON
|
||||||
|
headers.set(HttpHeaders.AUTHORIZATION, basicAuth(keyId, keySecret))
|
||||||
|
return restTemplate.exchange(url, HttpMethod.POST, HttpEntity(json, headers), String::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveBaseUrl(isTest: Boolean): String {
|
||||||
|
return if (isTest) {
|
||||||
|
"https://api.razorpay.com/v1"
|
||||||
|
} else {
|
||||||
|
"https://api.razorpay.com/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun basicAuth(keyId: String, keySecret: String): String {
|
||||||
|
val raw = "$keyId:$keySecret"
|
||||||
|
val encoded = Base64.getEncoder().encodeToString(raw.toByteArray())
|
||||||
|
return "Basic $encoded"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireKeys(settings: com.android.trisolarisserver.models.payment.RazorpaySettings): Pair<String, String> {
|
||||||
|
val keyId = settings.resolveKeyId()
|
||||||
|
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay test keys not configured")
|
||||||
|
val keySecret = settings.resolveKeySecret()
|
||||||
|
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay test keys not configured")
|
||||||
|
return keyId to keySecret
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.android.trisolarisserver.controller
|
package com.android.trisolarisserver.controller.razorpay
|
||||||
|
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
@@ -10,30 +9,17 @@ import org.springframework.web.bind.annotation.RestController
|
|||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/properties/{propertyId}/payu/return")
|
@RequestMapping("/properties/{propertyId}/razorpay/return")
|
||||||
class PayuReturnController {
|
class RazorpayReturnController {
|
||||||
|
|
||||||
@GetMapping("/success")
|
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
|
||||||
fun success(@PathVariable propertyId: UUID) {
|
|
||||||
// PayU redirect target; no-op.
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/success")
|
@PostMapping("/success")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
fun successPost(@PathVariable propertyId: UUID) {
|
fun success(@PathVariable propertyId: UUID) {
|
||||||
// PayU redirect target; no-op.
|
// Razorpay redirect target; no-op.
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/failure")
|
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
|
||||||
fun failure(@PathVariable propertyId: UUID) {
|
|
||||||
// PayU redirect target; no-op.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/failure")
|
@PostMapping("/failure")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
fun failurePost(@PathVariable propertyId: UUID) {
|
fun failure(@PathVariable propertyId: UUID) {
|
||||||
// PayU redirect target; no-op.
|
// Razorpay redirect target; no-op.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package com.android.trisolarisserver.controller.razorpay
|
||||||
|
import com.android.trisolarisserver.controller.common.requireRole
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
|
import com.android.trisolarisserver.controller.dto.razorpay.RazorpaySettingsResponse
|
||||||
|
import com.android.trisolarisserver.controller.dto.razorpay.RazorpaySettingsUpsertRequest
|
||||||
|
import com.android.trisolarisserver.models.payment.RazorpaySettings
|
||||||
|
import com.android.trisolarisserver.models.property.Role
|
||||||
|
import com.android.trisolarisserver.repo.property.PropertyRepo
|
||||||
|
import com.android.trisolarisserver.repo.razorpay.RazorpaySettingsRepo
|
||||||
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/properties/{propertyId}/razorpay-settings")
|
||||||
|
class RazorpaySettingsController(
|
||||||
|
private val propertyAccess: PropertyAccess,
|
||||||
|
private val propertyRepo: PropertyRepo,
|
||||||
|
private val settingsRepo: RazorpaySettingsRepo
|
||||||
|
) {
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun getSettings(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
): RazorpaySettingsResponse {
|
||||||
|
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
|
||||||
|
val settings = settingsRepo.findByPropertyId(propertyId)
|
||||||
|
return if (settings == null) {
|
||||||
|
RazorpaySettingsResponse(
|
||||||
|
propertyId = propertyId,
|
||||||
|
configured = false,
|
||||||
|
isTest = false,
|
||||||
|
hasKeyId = false,
|
||||||
|
hasKeySecret = false,
|
||||||
|
hasWebhookSecret = false,
|
||||||
|
hasKeyIdTest = false,
|
||||||
|
hasKeySecretTest = false,
|
||||||
|
hasWebhookSecretTest = false
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
settings.toResponse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping
|
||||||
|
fun upsertSettings(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@RequestBody request: RazorpaySettingsUpsertRequest
|
||||||
|
): RazorpaySettingsResponse {
|
||||||
|
requireRole(propertyAccess, propertyId, principal, Role.ADMIN)
|
||||||
|
val property = propertyRepo.findById(propertyId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
||||||
|
}
|
||||||
|
val existing = settingsRepo.findByPropertyId(propertyId)
|
||||||
|
val keyId = request.keyId?.trim()?.ifBlank { null } ?: request.merchantKey?.trim()?.ifBlank { null }
|
||||||
|
val keySecret = request.keySecret?.trim()?.ifBlank { null }
|
||||||
|
?: run {
|
||||||
|
val prefer256 = request.useSalt256 == true
|
||||||
|
val candidate = if (prefer256) request.salt256 else request.salt32
|
||||||
|
candidate?.trim()?.ifBlank { null } ?: request.salt256?.trim()?.ifBlank { null } ?: request.salt32?.trim()?.ifBlank { null }
|
||||||
|
}
|
||||||
|
val webhookSecret = request.webhookSecret?.trim()?.ifBlank { null }
|
||||||
|
val keyIdTest = request.keyIdTest?.trim()?.ifBlank { null }
|
||||||
|
val keySecretTest = request.keySecretTest?.trim()?.ifBlank { null }
|
||||||
|
val webhookSecretTest = request.webhookSecretTest?.trim()?.ifBlank { null }
|
||||||
|
val isTest = request.isTest
|
||||||
|
val hasKeyId = keyId != null
|
||||||
|
val hasKeySecret = keySecret != null
|
||||||
|
if (hasKeyId != hasKeySecret) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "keyId and keySecret must be provided together")
|
||||||
|
}
|
||||||
|
val hasKeyIdTest = keyIdTest != null
|
||||||
|
val hasKeySecretTest = keySecretTest != null
|
||||||
|
if (hasKeyIdTest != hasKeySecretTest) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "keyIdTest and keySecretTest must be provided together")
|
||||||
|
}
|
||||||
|
if (existing == null && (keyId == null || keySecret == null)) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "keyId/keySecret required")
|
||||||
|
}
|
||||||
|
if (isTest == true || existing?.isTest == true) {
|
||||||
|
val effectiveKeyIdTest = keyIdTest ?: existing?.keyIdTest
|
||||||
|
val effectiveKeySecretTest = keySecretTest ?: existing?.keySecretTest
|
||||||
|
if (effectiveKeyIdTest.isNullOrBlank() || effectiveKeySecretTest.isNullOrBlank()) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "keyIdTest/keySecretTest required when isTest=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val updated = if (existing == null) {
|
||||||
|
RazorpaySettings(
|
||||||
|
property = property,
|
||||||
|
keyId = keyId!!,
|
||||||
|
keySecret = keySecret!!,
|
||||||
|
webhookSecret = webhookSecret,
|
||||||
|
keyIdTest = keyIdTest,
|
||||||
|
keySecretTest = keySecretTest,
|
||||||
|
webhookSecretTest = webhookSecretTest,
|
||||||
|
isTest = isTest ?: false,
|
||||||
|
updatedAt = OffsetDateTime.now()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if (keyId != null) existing.keyId = keyId
|
||||||
|
if (keySecret != null) existing.keySecret = keySecret
|
||||||
|
if (webhookSecret != null) existing.webhookSecret = webhookSecret
|
||||||
|
if (keyIdTest != null) existing.keyIdTest = keyIdTest
|
||||||
|
if (keySecretTest != null) existing.keySecretTest = keySecretTest
|
||||||
|
if (webhookSecretTest != null) existing.webhookSecretTest = webhookSecretTest
|
||||||
|
isTest?.let { existing.isTest = it }
|
||||||
|
existing.updatedAt = OffsetDateTime.now()
|
||||||
|
existing
|
||||||
|
}
|
||||||
|
return settingsRepo.save(updated).toResponse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RazorpaySettings.toResponse(): RazorpaySettingsResponse {
|
||||||
|
return RazorpaySettingsResponse(
|
||||||
|
propertyId = property.id!!,
|
||||||
|
configured = true,
|
||||||
|
isTest = isTest,
|
||||||
|
hasKeyId = keyId.isNotBlank(),
|
||||||
|
hasKeySecret = keySecret.isNotBlank(),
|
||||||
|
hasWebhookSecret = !webhookSecret.isNullOrBlank(),
|
||||||
|
hasKeyIdTest = !keyIdTest.isNullOrBlank(),
|
||||||
|
hasKeySecretTest = !keySecretTest.isNullOrBlank(),
|
||||||
|
hasWebhookSecretTest = !webhookSecretTest.isNullOrBlank()
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package com.android.trisolarisserver.controller.razorpay
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.repo.booking.BookingRepo
|
||||||
|
import com.android.trisolarisserver.models.booking.Payment
|
||||||
|
import com.android.trisolarisserver.models.booking.PaymentMethod
|
||||||
|
import com.android.trisolarisserver.models.payment.RazorpayPaymentAttempt
|
||||||
|
import com.android.trisolarisserver.models.payment.RazorpayWebhookLog
|
||||||
|
import com.android.trisolarisserver.component.razorpay.RazorpayQrEvents
|
||||||
|
import com.android.trisolarisserver.component.booking.BookingEvents
|
||||||
|
import com.android.trisolarisserver.controller.dto.razorpay.RazorpayQrEventResponse
|
||||||
|
import com.android.trisolarisserver.repo.booking.PaymentRepo
|
||||||
|
import com.android.trisolarisserver.repo.property.PropertyRepo
|
||||||
|
import com.android.trisolarisserver.repo.razorpay.RazorpayPaymentAttemptRepo
|
||||||
|
import com.android.trisolarisserver.repo.razorpay.RazorpayPaymentLinkRequestRepo
|
||||||
|
import com.android.trisolarisserver.repo.razorpay.RazorpayQrRequestRepo
|
||||||
|
import com.android.trisolarisserver.repo.razorpay.RazorpaySettingsRepo
|
||||||
|
import com.android.trisolarisserver.repo.razorpay.RazorpayWebhookLogRepo
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.crypto.Mac
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/properties/{propertyId}/razorpay/webhook")
|
||||||
|
class RazorpayWebhookCapture(
|
||||||
|
private val propertyRepo: PropertyRepo,
|
||||||
|
private val bookingRepo: BookingRepo,
|
||||||
|
private val paymentRepo: PaymentRepo,
|
||||||
|
private val settingsRepo: RazorpaySettingsRepo,
|
||||||
|
private val razorpayPaymentAttemptRepo: RazorpayPaymentAttemptRepo,
|
||||||
|
private val razorpayPaymentLinkRequestRepo: RazorpayPaymentLinkRequestRepo,
|
||||||
|
private val razorpayQrRequestRepo: RazorpayQrRequestRepo,
|
||||||
|
private val razorpayWebhookLogRepo: RazorpayWebhookLogRepo,
|
||||||
|
private val razorpayQrEvents: RazorpayQrEvents,
|
||||||
|
private val bookingEvents: BookingEvents,
|
||||||
|
private val objectMapper: ObjectMapper
|
||||||
|
) {
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
@Transactional
|
||||||
|
fun capture(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@RequestBody(required = false) body: String?,
|
||||||
|
request: HttpServletRequest
|
||||||
|
) {
|
||||||
|
val property = propertyRepo.findById(propertyId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
||||||
|
}
|
||||||
|
val settings = settingsRepo.findByPropertyId(propertyId)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Razorpay settings not configured")
|
||||||
|
|
||||||
|
val headers = request.headerNames.toList().associateWith { request.getHeader(it) }
|
||||||
|
val headersText = headers.entries.joinToString("\n") { (k, v) -> "$k: $v" }
|
||||||
|
razorpayWebhookLogRepo.save(
|
||||||
|
RazorpayWebhookLog(
|
||||||
|
property = property,
|
||||||
|
headers = headersText,
|
||||||
|
payload = body,
|
||||||
|
contentType = request.contentType,
|
||||||
|
receivedAt = OffsetDateTime.now()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (body.isNullOrBlank()) return
|
||||||
|
val signature = request.getHeader("X-Razorpay-Signature")
|
||||||
|
?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing signature")
|
||||||
|
val secret = settings.resolveWebhookSecret()
|
||||||
|
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Webhook secret not configured")
|
||||||
|
if (!verifySignature(body, secret, signature)) {
|
||||||
|
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid signature")
|
||||||
|
}
|
||||||
|
|
||||||
|
val root = objectMapper.readTree(body)
|
||||||
|
val event = root.path("event").asText(null)
|
||||||
|
val paymentEntity = root.path("payload").path("payment").path("entity")
|
||||||
|
val orderEntity = root.path("payload").path("order").path("entity")
|
||||||
|
val qrEntity = root.path("payload").path("qr_code").path("entity")
|
||||||
|
val paymentLinkEntity = root.path("payload").path("payment_link").path("entity")
|
||||||
|
val refundEntity = root.path("payload").path("refund").path("entity")
|
||||||
|
val paymentId = paymentEntity.path("id").asText(null)
|
||||||
|
val orderId = paymentEntity.path("order_id").asText(null)?.takeIf { it.isNotBlank() }
|
||||||
|
?: orderEntity.path("id").asText(null)?.takeIf { it.isNotBlank() }
|
||||||
|
val status = paymentEntity.path("status").asText(null)
|
||||||
|
val amountPaise = paymentEntity.path("amount").asLong(0)
|
||||||
|
val currency = paymentEntity.path("currency").asText(property.currency)
|
||||||
|
val notes = paymentEntity.path("notes")
|
||||||
|
val bookingId = notes.path("bookingId").asText(null)?.let { runCatching { UUID.fromString(it) }.getOrNull() }
|
||||||
|
?: orderEntity.path("receipt").asText(null)?.let { runCatching { UUID.fromString(it) }.getOrNull() }
|
||||||
|
val booking = bookingId?.let { bookingRepo.findById(it).orElse(null) }
|
||||||
|
if (booking != null && booking.property.id != propertyId) return
|
||||||
|
|
||||||
|
val qrId = qrEntity.path("id").asText(null)
|
||||||
|
val qrStatus = qrEntity.path("status").asText(null)
|
||||||
|
if (event != null && event.startsWith("qr_code.") && qrId != null) {
|
||||||
|
val existingQr = razorpayQrRequestRepo.findTopByQrIdOrderByCreatedAtDesc(qrId)
|
||||||
|
if (existingQr != null) {
|
||||||
|
if (!qrStatus.isNullOrBlank()) {
|
||||||
|
existingQr.status = qrStatus
|
||||||
|
razorpayQrRequestRepo.save(existingQr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
razorpayQrEvents.emit(
|
||||||
|
propertyId,
|
||||||
|
qrId,
|
||||||
|
RazorpayQrEventResponse(
|
||||||
|
event = event,
|
||||||
|
qrId = qrId,
|
||||||
|
status = qrStatus,
|
||||||
|
receivedAt = OffsetDateTime.now().toString()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val paymentLinkId = paymentLinkEntity.path("id").asText(null)
|
||||||
|
val paymentLinkStatus = paymentLinkEntity.path("status").asText(null)
|
||||||
|
if (event != null && event.startsWith("payment_link.") && paymentLinkId != null) {
|
||||||
|
val existingLink = razorpayPaymentLinkRequestRepo.findTopByPaymentLinkIdOrderByCreatedAtDesc(paymentLinkId)
|
||||||
|
if (existingLink != null) {
|
||||||
|
if (!paymentLinkStatus.isNullOrBlank()) {
|
||||||
|
existingLink.status = paymentLinkStatus
|
||||||
|
razorpayPaymentLinkRequestRepo.save(existingLink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
razorpayPaymentAttemptRepo.save(
|
||||||
|
RazorpayPaymentAttempt(
|
||||||
|
property = property,
|
||||||
|
booking = booking,
|
||||||
|
event = event,
|
||||||
|
status = status,
|
||||||
|
amount = paiseToAmount(amountPaise),
|
||||||
|
currency = currency,
|
||||||
|
paymentId = paymentId,
|
||||||
|
orderId = orderId,
|
||||||
|
payload = body,
|
||||||
|
receivedAt = OffsetDateTime.now()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (event == null || paymentId == null || booking == null) return
|
||||||
|
if (event != "payment.captured" && event != "refund.processed") return
|
||||||
|
|
||||||
|
val refundId = refundEntity.path("id").asText(null)
|
||||||
|
if (event == "refund.processed") {
|
||||||
|
refundId?.let {
|
||||||
|
val existingRefund = paymentRepo.findByReference("razorpay_refund:$it")
|
||||||
|
if (existingRefund != null) return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (paymentRepo.findByGatewayPaymentId(paymentId) != null) return
|
||||||
|
}
|
||||||
|
|
||||||
|
val refundAmountPaise = refundEntity.path("amount").asLong(0)
|
||||||
|
val resolvedAmountPaise = if (event == "refund.processed" && refundAmountPaise > 0) refundAmountPaise else amountPaise
|
||||||
|
val signedAmount = if (event == "refund.processed") -paiseToAmount(resolvedAmountPaise) else paiseToAmount(resolvedAmountPaise)
|
||||||
|
val notesText = "razorpay event=$event status=$status order_id=$orderId refund_id=${refundId ?: "-"}"
|
||||||
|
paymentRepo.save(
|
||||||
|
Payment(
|
||||||
|
property = booking.property,
|
||||||
|
booking = booking,
|
||||||
|
amount = signedAmount,
|
||||||
|
currency = booking.property.currency,
|
||||||
|
method = PaymentMethod.ONLINE,
|
||||||
|
gatewayPaymentId = paymentId,
|
||||||
|
gatewayTxnId = orderId,
|
||||||
|
reference = if (event == "refund.processed" && refundId != null) "razorpay_refund:$refundId" else "razorpay:$paymentId",
|
||||||
|
notes = notesText,
|
||||||
|
receivedAt = OffsetDateTime.now()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
bookingEvents.emit(propertyId, booking.id!!)
|
||||||
|
if (qrId != null) {
|
||||||
|
razorpayQrEvents.emit(
|
||||||
|
propertyId,
|
||||||
|
qrId,
|
||||||
|
RazorpayQrEventResponse(
|
||||||
|
event = event,
|
||||||
|
qrId = qrId,
|
||||||
|
status = qrStatus,
|
||||||
|
receivedAt = OffsetDateTime.now().toString()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun verifySignature(payload: String, secret: String, signature: String): Boolean {
|
||||||
|
return try {
|
||||||
|
val mac = Mac.getInstance("HmacSHA256")
|
||||||
|
mac.init(SecretKeySpec(secret.toByteArray(), "HmacSHA256"))
|
||||||
|
val hash = mac.doFinal(payload.toByteArray()).joinToString("") { "%02x".format(it) }
|
||||||
|
hash.equals(signature, ignoreCase = true)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun paiseToAmount(paise: Long): Long {
|
||||||
|
return paise / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
package com.android.trisolarisserver.controller
|
package com.android.trisolarisserver.controller.room
|
||||||
|
import com.android.trisolarisserver.controller.common.requirePrincipal
|
||||||
|
import com.android.trisolarisserver.controller.common.requireSuperAdmin
|
||||||
|
|
||||||
import com.android.trisolarisserver.controller.dto.AmenityResponse
|
import com.android.trisolarisserver.controller.dto.room.AmenityResponse
|
||||||
import com.android.trisolarisserver.controller.dto.AmenityUpsertRequest
|
import com.android.trisolarisserver.controller.dto.room.AmenityUpsertRequest
|
||||||
import com.android.trisolarisserver.models.room.RoomAmenity
|
import com.android.trisolarisserver.models.room.RoomAmenity
|
||||||
import com.android.trisolarisserver.repo.AppUserRepo
|
import com.android.trisolarisserver.repo.property.AppUserRepo
|
||||||
import com.android.trisolarisserver.repo.RoomAmenityRepo
|
import com.android.trisolarisserver.repo.room.RoomAmenityRepo
|
||||||
import com.android.trisolarisserver.repo.RoomTypeRepo
|
import com.android.trisolarisserver.repo.room.RoomTypeRepo
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
@@ -48,7 +50,7 @@ class RoomAmenities(
|
|||||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
@RequestBody request: AmenityUpsertRequest
|
@RequestBody request: AmenityUpsertRequest
|
||||||
): AmenityResponse {
|
): AmenityResponse {
|
||||||
requireSuperAdmin(principal)
|
requireSuperAdmin(appUserRepo, principal)
|
||||||
|
|
||||||
if (roomAmenityRepo.existsByName(request.name)) {
|
if (roomAmenityRepo.existsByName(request.name)) {
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Amenity already exists")
|
throw ResponseStatusException(HttpStatus.CONFLICT, "Amenity already exists")
|
||||||
@@ -68,7 +70,7 @@ class RoomAmenities(
|
|||||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
@RequestBody request: AmenityUpsertRequest
|
@RequestBody request: AmenityUpsertRequest
|
||||||
): AmenityResponse {
|
): AmenityResponse {
|
||||||
requireSuperAdmin(principal)
|
requireSuperAdmin(appUserRepo, principal)
|
||||||
|
|
||||||
val amenity = roomAmenityRepo.findById(amenityId).orElse(null)
|
val amenity = roomAmenityRepo.findById(amenityId).orElse(null)
|
||||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Amenity not found")
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Amenity not found")
|
||||||
@@ -91,7 +93,7 @@ class RoomAmenities(
|
|||||||
@PathVariable amenityId: UUID,
|
@PathVariable amenityId: UUID,
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
) {
|
) {
|
||||||
requireSuperAdmin(principal)
|
requireSuperAdmin(appUserRepo, principal)
|
||||||
|
|
||||||
val amenity = roomAmenityRepo.findById(amenityId).orElse(null)
|
val amenity = roomAmenityRepo.findById(amenityId).orElse(null)
|
||||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Amenity not found")
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Amenity not found")
|
||||||
@@ -106,24 +108,6 @@ class RoomAmenities(
|
|||||||
roomAmenityRepo.delete(amenity)
|
roomAmenityRepo.delete(amenity)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requirePrincipal(principal: MyPrincipal?) {
|
|
||||||
if (principal == null) {
|
|
||||||
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requireSuperAdmin(principal: MyPrincipal?) {
|
|
||||||
if (principal == null) {
|
|
||||||
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
|
|
||||||
}
|
|
||||||
val user = appUserRepo.findById(principal.userId).orElseThrow {
|
|
||||||
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
|
|
||||||
}
|
|
||||||
if (!user.superAdmin) {
|
|
||||||
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Super admin only")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun validateIconKey(iconKey: String?) {
|
private fun validateIconKey(iconKey: String?) {
|
||||||
if (iconKey.isNullOrBlank()) return
|
if (iconKey.isNullOrBlank()) return
|
||||||
val file = Paths.get(pngRoot, "${iconKey}.png")
|
val file = Paths.get(pngRoot, "${iconKey}.png")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user