Compare commits

..

159 Commits

Author SHA1 Message Date
androidlover5842
83c7b45e89 Allow guest document delete when reused guest has active booking
All checks were successful
build-and-deploy / build-deploy (push) Successful in 36s
2026-02-08 18:56:13 +05:30
androidlover5842
dc55df42bc Revert "Sync active room stays when booking expected dates change"
All checks were successful
build-and-deploy / build-deploy (push) Successful in 17s
This reverts commit 189fdb33de.
2026-02-08 18:09:56 +05:30
androidlover5842
189fdb33de Sync active room stays when booking expected dates change
All checks were successful
build-and-deploy / build-deploy (push) Successful in 37s
2026-02-08 18:01:36 +05:30
androidlover5842
924bf2c614 Validate room-stay bounds on booking expected-date updates
All checks were successful
build-and-deploy / build-deploy (push) Successful in 39s
2026-02-08 16:56:24 +05:30
androidlover5842
cac3f272a2 Fix booking profile request parsing without JsonNode
All checks were successful
build-and-deploy / build-deploy (push) Successful in 54s
2026-02-07 22:30:54 +05:30
androidlover5842
e15c72a159 Update city search to return district/locality string suggestions
All checks were successful
build-and-deploy / build-deploy (push) Successful in 36s
2026-02-07 22:20:48 +05:30
androidlover5842
c5dc32d9af Add admin/manager booking profile update API
All checks were successful
build-and-deploy / build-deploy (push) Successful in 37s
2026-02-07 21:45:55 +05:30
androidlover5842
1f770c37e2 Include guest dob in guest search and detail responses
All checks were successful
build-and-deploy / build-deploy (push) Successful in 36s
2026-02-07 18:13:46 +05:30
androidlover5842
0441683d55 Return uppercase country name list for country search API
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
2026-02-07 17:01:56 +05:30
androidlover5842
c39188d453 Add public country search API backed by country_reference
All checks were successful
build-and-deploy / build-deploy (push) Successful in 36s
2026-02-07 16:47:11 +05:30
androidlover5842
71c10193a3 Add city prefix search API using local pincode DB
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
2026-02-05 20:02:35 +05:30
androidlover5842
a0e354b464 Use JPA repo for local pincode resolution
All checks were successful
build-and-deploy / build-deploy (push) Successful in 40s
2026-02-05 19:56:59 +05:30
androidlover5842
1153193723 Resolve pincode from local DB before external fallbacks
All checks were successful
build-and-deploy / build-deploy (push) Successful in 36s
2026-02-05 19:52:35 +05:30
androidlover5842
8d1d80bb60 Add nightlyRate in active room stays response
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
2026-02-05 10:42:46 +05:30
androidlover5842
f46893e0c3 Require guest identity and signature before checkout
All checks were successful
build-and-deploy / build-deploy (push) Successful in 36s
2026-02-05 09:56:32 +05:30
androidlover5842
cb6fb94bf7 Merge range rate data into availability-range and remove old rate endpoint
All checks were successful
build-and-deploy / build-deploy (push) Successful in 37s
2026-02-04 17:38:02 +05:30
androidlover5842
2950af3332 Add forecast occupancy logic for room availability range APIs
All checks were successful
build-and-deploy / build-deploy (push) Successful in 37s
2026-02-04 17:22:28 +05:30
androidlover5842
0a65e022e0 Block check-in for bookings not scheduled for today
All checks were successful
build-and-deploy / build-deploy (push) Successful in 36s
2026-02-04 16:32:31 +05:30
androidlover5842
bc13816cbf Add non-null vehicleNumbers to booking list response
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
2026-02-04 15:30:40 +05:30
androidlover5842
c549418c42 Rename default booking source from WALKIN to DIRECT
All checks were successful
build-and-deploy / build-deploy (push) Successful in 1m8s
2026-02-04 15:23:46 +05:30
androidlover5842
59a50d4313 Add expected checkout preview API using property billing policy defaults
All checks were successful
build-and-deploy / build-deploy (push) Successful in 36s
2026-02-04 14:27:15 +05:30
androidlover5842
002f11240a Fix lazy-loading in booking balance endpoint
All checks were successful
build-and-deploy / build-deploy (push) Successful in 36s
2026-02-04 14:09:18 +05:30
androidlover5842
f1ee4584a4 Fix lazy-loading in booking billable nights endpoint
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
2026-02-04 13:59:14 +05:30
androidlover5842
0694cf0b8a Add booking billable nights preview API and detail field
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
2026-02-04 13:52:39 +05:30
androidlover5842
ff911661a4 docs: enforce manual API_REFERENCE update policy in AGENTS
All checks were successful
build-and-deploy / build-deploy (push) Successful in 19s
2026-02-04 13:11:41 +05:30
androidlover5842
5254254b6d docs: rewrite API docs in txt endpoint-by-endpoint format
All checks were successful
build-and-deploy / build-deploy (push) Successful in 19s
2026-02-04 13:08:13 +05:30
androidlover5842
1f9fedc3e1 docs: add manual API and data-store reference
All checks were successful
build-and-deploy / build-deploy (push) Successful in 17s
2026-02-04 12:47:03 +05:30
androidlover5842
d94b9dc337 Make custom booking policy use single checkout cutoff time
All checks were successful
build-and-deploy / build-deploy (push) Successful in 17s
2026-02-02 11:31:46 +05:30
androidlover5842
8ba0fedd8b Move billing policy to booking level with override modes and audit logs
All checks were successful
build-and-deploy / build-deploy (push) Successful in 36s
2026-02-02 10:43:47 +05:30
androidlover5842
776ed6dc4e Add property billing policy times and policy-based night calculation
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
2026-02-02 10:17:00 +05:30
androidlover5842
734591807f Make cancellation policy read endpoint public
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
2026-02-02 09:34:58 +05:30
androidlover5842
4c20cbd7ca Add advance-booking cancellation policy engine
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
2026-02-02 09:20:27 +05:30
androidlover5842
30c37affb4 Add room-type quantity reservation APIs
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
2026-02-02 09:09:40 +05:30
androidlover5842
247d6e4961 Disallow checkoutAt in bulk check-in stays
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
2026-02-02 09:01:38 +05:30
androidlover5842
e3563dd259 Remove single-payload booking check-in API
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-02 08:49:14 +05:30
androidlover5842
7ec818714c Remove room-stay change-room API
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-02 08:42:25 +05:30
androidlover5842
7defe26cc9 Remove booking room-stay preassign API
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
2026-02-02 08:39:23 +05:30
androidlover5842
4747352e21 Remove room-stay change-rate endpoint
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-02 08:32:35 +05:30
androidlover5842
a7aa842cbb Close booking on single-stay specific checkout
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-02 08:25:14 +05:30
androidlover5842
e81a656254 Clarify agent should run required SSH ops directly
All checks were successful
build-and-deploy / build-deploy (push) Successful in 49s
2026-02-02 08:23:22 +05:30
androidlover5842
c081f21688 Document SSH SQL steps for room-stay schema rollout
All checks were successful
build-and-deploy / build-deploy (push) Successful in 49s
2026-02-02 08:21:58 +05:30
androidlover5842
e77ae6396e Add room-stay void/checkout controls and audit logs
All checks were successful
build-and-deploy / build-deploy (push) Successful in 39s
2026-02-02 08:19:40 +05:30
androidlover5842
240e8fca25 Allow staff rate changes only before first payment
All checks were successful
build-and-deploy / build-deploy (push) Successful in 36s
2026-02-02 07:26:49 +05:30
androidlover5842
f33d0f1f39 Allow staff to create bookings
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 23:33:39 +05:30
androidlover5842
ba5bd0ca02 Restrict room create/update to admins
All checks were successful
build-and-deploy / build-deploy (push) Successful in 36s
2026-02-01 23:32:04 +05:30
androidlover5842
d01b853f5e Allow access code join by property code or id
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 23:09:47 +05:30
androidlover5842
4bcac9cb6a Return only property code in code lookup
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 23:05:22 +05:30
androidlover5842
36b2d04de4 Expose property code and reduce retry count
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 23:04:07 +05:30
androidlover5842
0a11e765ad Randomize property codes with 7-char alphabet
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 23:00:03 +05:30
androidlover5842
5df019ed6e Auto-generate property codes and join by code
All checks were successful
build-and-deploy / build-deploy (push) Successful in 40s
2026-02-01 22:58:25 +05:30
androidlover5842
7aca2361ca Allow auth verify/me to auto-create users
All checks were successful
build-and-deploy / build-deploy (push) Successful in 40s
2026-02-01 22:49:02 +05:30
androidlover5842
4fc9be14c6 Filter user lists by role hierarchy
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 22:18:40 +05:30
androidlover5842
f9929064fb Hide requesting user from user lists
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
2026-02-01 22:15:25 +05:30
androidlover5842
c4b83d2122 Remove schema fix classes and document manual schema updates
All checks were successful
build-and-deploy / build-deploy (push) Successful in 36s
2026-02-01 20:24:43 +05:30
androidlover5842
fbb06ca709 Add property user disable flag and endpoint
All checks were successful
build-and-deploy / build-deploy (push) Successful in 36s
2026-02-01 18:27:51 +05:30
androidlover5842
82486bac53 Add user search and property access code flow
All checks were successful
build-and-deploy / build-deploy (push) Successful in 36s
2026-02-01 17:55:06 +05:30
androidlover5842
9621c2d652 Document new package layout
All checks were successful
build-and-deploy / build-deploy (push) Successful in 49s
2026-02-01 17:28:56 +05:30
androidlover5842
9076ae6c93 Reorganize packages by domain
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 17:23:21 +05:30
androidlover5842
04d41979d7 Reorganize Razorpay code into subpackages
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 17:12:37 +05:30
androidlover5842
d98f634f02 Require paymentId for Razorpay refunds
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 16:59:59 +05:30
androidlover5842
5d4748043f Validate Razorpay refund amount against payment
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
2026-02-01 16:56:07 +05:30
androidlover5842
758919c969 Send guest details to Razorpay
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
2026-02-01 16:39:48 +05:30
androidlover5842
ab7d13c1ad Add Razorpay docs reference
All checks were successful
build-and-deploy / build-deploy (push) Successful in 49s
2026-02-01 16:15:49 +05:30
androidlover5842
b7a76a8daa Shorten payment link reference_id
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 16:08:44 +05:30
androidlover5842
5e8651d82f Record refund webhooks as negative payments
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 16:05:44 +05:30
androidlover5842
66fc03d855 Use unique reference_id for Razorpay payment links
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
2026-02-01 15:57:23 +05:30
androidlover5842
c8bea2bcc7 Update payment link status from webhooks
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 15:46:55 +05:30
androidlover5842
14c86210c2 Add Razorpay refund endpoint
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
2026-02-01 15:34:46 +05:30
androidlover5842
2421ba5edf Store Razorpay test keys alongside live keys
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 15:19:26 +05:30
androidlover5842
06ffbd86f5 Hide closed Razorpay requests from list
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 14:34:03 +05:30
androidlover5842
5e9e0d0742 Add unified close endpoint for Razorpay requests
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 14:25:50 +05:30
androidlover5842
4e89336652 Add unified Razorpay payment requests list
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 14:08:45 +05:30
androidlover5842
ab2330b593 Disable buffering on SSE endpoints
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 13:57:20 +05:30
androidlover5842
42d91cc09a Document Android run workflow
All checks were successful
build-and-deploy / build-deploy (push) Successful in 50s
2026-02-01 13:49:32 +05:30
androidlover5842
673a43db7d Emit booking and QR events on payment capture
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
2026-02-01 13:21:32 +05:30
androidlover5842
35b15f37ec Add SSE for Razorpay QR events
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 12:22:16 +05:30
androidlover5842
c74944711e Include closed QR events in webhook log endpoint
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-02-01 12:12:27 +05:30
androidlover5842
cf0c38deb5 Fix Razorpay webhook payload size limits
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 12:03:23 +05:30
androidlover5842
9c80a15130 Filter QR events to active status
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 11:46:00 +05:30
androidlover5842
a86b8ef88d Add Razorpay QR close by id
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
2026-02-01 11:36:29 +05:30
androidlover5842
168fa7af23 Add Razorpay QR list endpoint
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 11:35:02 +05:30
androidlover5842
08a7aaee1f Add Razorpay QR event fetch endpoint
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 11:30:27 +05:30
androidlover5842
e17eea741a Handle Razorpay QR code webhook events
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-02-01 11:23:48 +05:30
androidlover5842
357f5337cd Fix null order id handling in Razorpay webhook
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 11:11:33 +05:30
androidlover5842
5e8b8438d9 Default Razorpay QR expiry to 10 minutes
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 11:00:02 +05:30
androidlover5842
d53d179963 Add Razorpay active QR fetch and close
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 10:52:46 +05:30
androidlover5842
132c3b19c0 Trim Razorpay responses
All checks were successful
build-and-deploy / build-deploy (push) Successful in 36s
2026-02-01 10:50:12 +05:30
androidlover5842
c0d1ea2b0c Widen Razorpay QR payload columns
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 10:46:04 +05:30
androidlover5842
10a82f544f Widen Razorpay QR request columns
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-02-01 10:43:46 +05:30
androidlover5842
2b0e24e613 Fix Razorpay QR create endpoint path
All checks were successful
build-and-deploy / build-deploy (push) Successful in 1m6s
2026-02-01 10:41:20 +05:30
androidlover5842
66d5684752 Fix lazy loading in Razorpay payment endpoints
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-02-01 10:38:50 +05:30
androidlover5842
13a2eb8afd Require keyId and keySecret together
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
2026-02-01 10:35:56 +05:30
androidlover5842
cc402067c7 Allow partial Razorpay settings updates
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 10:32:00 +05:30
androidlover5842
58e8cffe9b Hide Razorpay key id in settings response
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 10:16:33 +05:30
androidlover5842
2deecf1bf2 Accept legacy settings payload for Razorpay
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-02-01 10:12:40 +05:30
androidlover5842
0624e6bcc8 Fix Razorpay auth principal and document ops
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-02-01 10:09:53 +05:30
androidlover5842
ebaef53f98 Replace PayU integration with Razorpay
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-02-01 09:44:57 +05:30
androidlover5842
93ac0dbc9e Store DOB in guest age and return in booking detail
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-02-01 01:55:29 +05:30
androidlover5842
e68e7c685c Compute age from valid DOB in extraction
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 01:51:20 +05:30
androidlover5842
8e73217792 Update Aadhaar matches with verified number
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 01:45:46 +05:30
androidlover5842
683b0f133e extractor: back has id too you know
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-02-01 01:41:41 +05:30
androidlover5842
1b7ee1004c switch to qwen back
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-02-01 01:28:25 +05:30
androidlover5842
f51a1a80e8 static model load
All checks were successful
build-and-deploy / build-deploy (push) Successful in 36s
2026-02-01 00:50:21 +05:30
androidlover5842
aab9b02659 Track Aadhaar verification and similarity
All checks were successful
build-and-deploy / build-deploy (push) Successful in 40s
2026-01-31 23:33:02 +05:30
androidlover5842
f5c6406e31 Add PaddleOCR debug for Aadhaar candidates
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
2026-01-31 22:25:14 +05:30
androidlover5842
b6d613b743 allow testing for now
All checks were successful
build-and-deploy / build-deploy (push) Successful in 18s
2026-01-31 22:21:33 +05:30
androidlover5842
92cc07186e Add debug logs for Aadhaar extraction
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-01-31 15:41:37 +05:30
androidlover5842
8c2117d369 dont read carefully id
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-01-31 15:37:02 +05:30
androidlover5842
25003dbc0c Ensure Aadhaar checksum runs before doc type
All checks were successful
build-and-deploy / build-deploy (push) Successful in 32s
2026-01-31 15:26:45 +05:30
androidlover5842
34fc7ca7d2 Fix booking snapshot lazy loading
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-01-31 14:45:40 +05:30
androidlover5842
d692deb402 Include vehicle numbers in booking detail
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-01-31 14:07:00 +05:30
androidlover5842
d44ae36473 Set guest nationality from pin resolve
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-01-31 13:37:13 +05:30
androidlover5842
69df1429fa Add booking SSE stream and emit on updates
All checks were successful
build-and-deploy / build-deploy (push) Successful in 32s
2026-01-31 13:30:51 +05:30
androidlover5842
db6ea5d529 Normalize extracted address fields
All checks were successful
build-and-deploy / build-deploy (push) Successful in 32s
2026-01-31 12:46:48 +05:30
androidlover5842
7469d8824b Tweak data.gov query and add postal retries
All checks were successful
build-and-deploy / build-deploy (push) Successful in 18s
2026-01-31 12:17:22 +05:30
androidlover5842
3b2733e7cb Record pincode request URLs and harden fetches
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-01-31 12:11:22 +05:30
androidlover5842
d594e40051 Harden pincode lookups and retry postal http
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-01-31 12:05:35 +05:30
androidlover5842
901247a920 Expose pincode resolver errors in extraction
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-01-31 12:02:23 +05:30
androidlover5842
1b45a38c78 Filter data.gov records by pin code
All checks were successful
build-and-deploy / build-deploy (push) Successful in 32s
2026-01-31 11:55:18 +05:30
androidlover5842
e9e39e645c Resolve pin via data.gov.in with fallbacks
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-01-31 11:48:02 +05:30
androidlover5842
796d9f35b0 Prefer main city from postcode localities
All checks were successful
build-and-deploy / build-deploy (push) Successful in 32s
2026-01-31 11:15:06 +05:30
androidlover5842
6202a0e814 Fallback geocode when postal code has zero results
All checks were successful
build-and-deploy / build-deploy (push) Successful in 32s
2026-01-31 11:12:51 +05:30
androidlover5842
0771631b5a Ignore SSE send failures
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-01-31 11:10:21 +05:30
androidlover5842
b7b1975c5c Log geocode response in extracted data
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-01-31 11:07:15 +05:30
androidlover5842
41452ccd3d Prefer district for pin geocode city
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-01-31 11:01:40 +05:30
androidlover5842
8e547570e1 Improve pin geocoding for city/state
All checks were successful
build-and-deploy / build-deploy (push) Successful in 37s
2026-01-31 11:00:30 +05:30
androidlover5842
c21bb53382 Fix lazy booking load in doc city update
All checks were successful
build-and-deploy / build-deploy (push) Successful in 32s
2026-01-31 10:57:09 +05:30
androidlover5842
1d1cb9c040 guest docs: log errors
All checks were successful
build-and-deploy / build-deploy (push) Successful in 32s
2026-01-31 10:54:33 +05:30
androidlover5842
e148549b6c Auto-fill booking city via pin geocoding
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-01-31 10:48:25 +05:30
androidlover5842
366673c690 Fix Aadhaar docType when handled false
All checks were successful
build-and-deploy / build-deploy (push) Successful in 31s
2026-01-31 10:19:17 +05:30
androidlover5842
9299d22c5b extractor: improve rules for extracting aadhar by verifying checksum
All checks were successful
build-and-deploy / build-deploy (push) Successful in 32s
2026-01-31 10:16:20 +05:30
androidlover5842
12d1327525 ocr: allow 75% indians poor
All checks were successful
build-and-deploy / build-deploy (push) Successful in 32s
2026-01-31 10:03:50 +05:30
androidlover5842
9f28580cc9 ocr: allow 75% indians poor
All checks were successful
build-and-deploy / build-deploy (push) Successful in 48s
2026-01-31 09:55:24 +05:30
androidlover5842
c2d786e4e0 ocr: attach high confidance stuff to all images
All checks were successful
build-and-deploy / build-deploy (push) Successful in 32s
2026-01-31 09:49:37 +05:30
androidlover5842
b1efb2828f profile:fix url ocr
All checks were successful
build-and-deploy / build-deploy (push) Successful in 16s
2026-01-31 09:28:59 +05:30
androidlover5842
cba6f25ff2 profile:dont reuse commons
All checks were successful
build-and-deploy / build-deploy (push) Successful in 15s
2026-01-31 09:27:10 +05:30
androidlover5842
a6684a8bb4 nigx error
All checks were successful
build-and-deploy / build-deploy (push) Successful in 16s
2026-01-31 09:26:16 +05:30
androidlover5842
ab5a8c0154 extractor :ocr score below 80% reject doc
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-01-31 09:15:46 +05:30
androidlover5842
ced92c34f8 try orc on faliled attempt
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-01-31 09:10:47 +05:30
androidlover5842
f7c0cf5c18 ai removed boilerplate
All checks were successful
build-and-deploy / build-deploy (push) Successful in 31s
2026-01-31 06:35:06 +05:30
androidlover5842
812211f62b Remove OpenAI fallback
All checks were successful
build-and-deploy / build-deploy (push) Successful in 32s
2026-01-31 05:44:59 +05:30
androidlover5842
5b7e2d4393 Fix Aadhaar fallback build error
All checks were successful
build-and-deploy / build-deploy (push) Successful in 31s
2026-01-31 05:40:13 +05:30
androidlover5842
dd20571679 Send OpenAI fallback images as data URLs
Some checks failed
build-and-deploy / build-deploy (push) Failing after 29s
2026-01-31 05:36:26 +05:30
androidlover5842
9ec9ac86c9 Log OpenAI fallback prompt and output summary
All checks were successful
build-and-deploy / build-deploy (push) Successful in 32s
2026-01-31 05:33:11 +05:30
androidlover5842
e60e70a6b0 Retry OpenAI fallback with focused Aadhaar prompt
All checks were successful
build-and-deploy / build-deploy (push) Successful in 32s
2026-01-31 05:30:07 +05:30
androidlover5842
124945dddb Set OpenAI max_output_tokens to 16
All checks were successful
build-and-deploy / build-deploy (push) Successful in 1m4s
2026-01-31 05:26:55 +05:30
androidlover5842
7914da0e99 Fix OpenAI image input schema
All checks were successful
build-and-deploy / build-deploy (push) Successful in 32s
2026-01-31 05:24:27 +05:30
androidlover5842
f743d50d7f Improve OpenAI fallback diagnostics
All checks were successful
build-and-deploy / build-deploy (push) Successful in 32s
2026-01-31 05:21:11 +05:30
androidlover5842
cf82446641 Use gpt-5.1 for OpenAI fallback
All checks were successful
build-and-deploy / build-deploy (push) Successful in 15s
2026-01-31 05:17:36 +05:30
androidlover5842
616a06387b Add logging for OpenAI fallback
All checks were successful
build-and-deploy / build-deploy (push) Successful in 32s
2026-01-31 05:08:20 +05:30
androidlover5842
bd2bca9f33 Add OpenAI fallback for Aadhaar extraction
All checks were successful
build-and-deploy / build-deploy (push) Successful in 32s
2026-01-31 05:03:28 +05:30
androidlover5842
d90b0bb260 Validate Aadhaar and retry extraction
All checks were successful
build-and-deploy / build-deploy (push) Successful in 32s
2026-01-31 04:42:55 +05:30
androidlover5842
c04acb972f Normalize PIN codes and tighten prompt
All checks were successful
build-and-deploy / build-deploy (push) Successful in 31s
2026-01-31 04:20:07 +05:30
androidlover5842
9c1952cc7a Harden vehicle detection and prompt
All checks were successful
build-and-deploy / build-deploy (push) Successful in 32s
2026-01-31 04:12:33 +05:30
androidlover5842
2b52d70696 Restore base application properties
All checks were successful
build-and-deploy / build-deploy (push) Successful in 16s
2026-01-31 04:02:52 +05:30
androidlover5842
122619cab1 guestDocument: add missing files
All checks were successful
build-and-deploy / build-deploy (push) Successful in 32s
2026-01-31 03:55:02 +05:30
androidlover5842
b4ef2da167 guestDocument: split codes
Some checks failed
build-and-deploy / build-deploy (push) Failing after 30s
2026-01-31 03:54:40 +05:30
androidlover5842
73f7d41619 guest docs: improve extractor logic even more 2026-01-31 03:37:12 +05:30
androidlover5842
b5aacfc8c6 llama settings 2026-01-31 03:19:06 +05:30
179 changed files with 13305 additions and 4192 deletions

View File

@@ -40,6 +40,13 @@ Repository
- Root: /home/androidlover5842/IdeaProjects/TrisolarisServer
- Entry: src/main/kotlin/com/android/trisolarisserver/TrisolarisServerApplication.kt
- 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
- Firebase Admin auth for every request; Firebase UID required.
@@ -99,12 +106,15 @@ Properties
Booking flow
- 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}/room-stays/{roomStayId}/check-out (closes specific stay; single-stay booking auto-closes booking)
- /properties/{propertyId}/bookings/{bookingId}/cancel
- /properties/{propertyId}/bookings/{bookingId}/no-show
- /properties/{propertyId}/bookings/{bookingId}/room-stays (pre-assign RoomStay with date range)
- /properties/{propertyId}/room-stays/{roomStayId}/change-room (idempotent via RoomStayChange)
- /properties/{propertyId}/bookings/{bookingId}/room-requests (room-type quantity reservation)
- /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
- /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.
- Admin can assign ADMIN/MANAGER/STAFF/AGENT; Manager can assign STAFF/AGENT.
- 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

File diff suppressed because it is too large Load Diff

2353
razoryPayDocs.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,108 +0,0 @@
package com.android.trisolarisserver.component
import com.android.trisolarisserver.controller.GuestDocumentResponse
import com.android.trisolarisserver.db.repo.GuestDocumentRepo
import com.android.trisolarisserver.models.booking.GuestDocument
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
)
private 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()
)
}

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package com.android.trisolarisserver.component
package com.android.trisolarisserver.component.ai
import jakarta.annotation.PreDestroy
import org.springframework.stereotype.Component

View File

@@ -1,4 +1,4 @@
package com.android.trisolarisserver.component
package com.android.trisolarisserver.component.ai
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.beans.factory.annotation.Value
@@ -13,17 +13,34 @@ class LlamaClient(
private val restTemplate: RestTemplate,
private val objectMapper: ObjectMapper,
@Value("\${ai.llama.baseUrl}")
private val baseUrl: String
private val baseUrl: String,
@Value("\${ai.llama.temperature:0.7}")
private val temperature: Double,
@Value("\${ai.llama.topP:0.8}")
private val topP: Double,
@Value("\${ai.llama.minP:0.2}")
private val minP: Double,
@Value("\${ai.llama.repeatPenalty:1.0}")
private val repeatPenalty: Double,
@Value("\${ai.llama.topK:40}")
private val topK: Int,
@Value("\${ai.llama.model}")
private val model: String
) {
private val systemPrompt =
"Look only at visible text. " +
"Read extremely carefully. Look only at visible text. " +
"Return the exact text you can read verbatim. " +
"If the text is unclear, partial, or inferred, return NOT CLEARLY VISIBLE. " +
"Do not guess. Do not explain."
fun ask(imageUrl: String, question: String): String {
val payload = mapOf(
"model" to "qwen",
"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",
@@ -41,9 +58,42 @@ class LlamaClient(
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 {
val payload = mapOf(
"model" to "qwen",
"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",

View File

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

View File

@@ -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.PropertyUserRepo
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.property.PropertyUserRepo
import com.android.trisolarisserver.models.property.Role
import org.springframework.security.access.AccessDeniedException
import org.springframework.stereotype.Component

View File

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

View File

@@ -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(", ")
}

View File

@@ -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.stereotype.Component

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.stereotype.Component

View File

@@ -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.PDPage

View File

@@ -1,4 +1,4 @@
package com.android.trisolarisserver.component
package com.android.trisolarisserver.component.storage
import java.nio.file.Files
import java.nio.file.Path

View File

@@ -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.stereotype.Component

View File

@@ -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.stereotype.Component

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
package com.android.trisolarisserver.config
package com.android.trisolarisserver.config.core
import jakarta.servlet.http.HttpServletRequest
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.AccessDeniedException
import org.springframework.web.bind.annotation.ExceptionHandler
@@ -18,7 +19,9 @@ class ApiExceptionHandler {
request: HttpServletRequest
): ResponseEntity<ApiError> {
val status = ex.statusCode as HttpStatus
return ResponseEntity.status(status).body(
return ResponseEntity.status(status)
.contentType(MediaType.APPLICATION_JSON)
.body(
ApiError(
timestamp = OffsetDateTime.now().toString(),
status = status.value(),
@@ -34,7 +37,9 @@ class ApiExceptionHandler {
ex: AccessDeniedException,
request: HttpServletRequest
): ResponseEntity<ApiError> {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.contentType(MediaType.APPLICATION_JSON)
.body(
ApiError(
timestamp = OffsetDateTime.now().toString(),
status = HttpStatus.FORBIDDEN.value(),
@@ -50,7 +55,9 @@ class ApiExceptionHandler {
ex: Exception,
request: HttpServletRequest
): ResponseEntity<ApiError> {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.contentType(MediaType.APPLICATION_JSON)
.body(
ApiError(
timestamp = OffsetDateTime.now().toString(),
status = HttpStatus.INTERNAL_SERVER_ERROR.value(),

View File

@@ -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.Configuration

View File

@@ -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.module.kotlin.jacksonObjectMapper

View File

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

View File

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

View File

@@ -1,515 +0,0 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.DocumentStorage
import com.android.trisolarisserver.component.DocumentTokenService
import com.android.trisolarisserver.component.ExtractionQueue
import com.android.trisolarisserver.component.GuestDocumentEvents
import com.android.trisolarisserver.component.LlamaClient
import com.android.trisolarisserver.component.PropertyAccess
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.models.booking.GuestDocument
import com.android.trisolarisserver.models.booking.GuestVehicle
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.GuestVehicleRepo
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.security.MyPrincipal
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.core.io.FileSystemResource
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.annotation.*
import org.springframework.http.MediaType
import jakarta.servlet.http.HttpServletResponse
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.server.ResponseStatusException
import java.time.OffsetDateTime
import java.nio.file.Files
import java.nio.file.Paths
import java.util.UUID
import java.security.MessageDigest
@RestController
@RequestMapping("/properties/{propertyId}/guests/{guestId}/documents")
class GuestDocuments(
private val propertyAccess: PropertyAccess,
private val propertyRepo: PropertyRepo,
private val guestRepo: GuestRepo,
private val bookingRepo: BookingRepo,
private val guestDocumentRepo: GuestDocumentRepo,
private val appUserRepo: AppUserRepo,
private val guestVehicleRepo: GuestVehicleRepo,
private val storage: DocumentStorage,
private val tokenService: DocumentTokenService,
private val extractionQueue: ExtractionQueue,
private val guestDocumentEvents: GuestDocumentEvents,
private val llamaClient: LlamaClient,
private val objectMapper: ObjectMapper,
@org.springframework.beans.factory.annotation.Value("\${storage.documents.publicBaseUrl}")
private val publicBaseUrl: String,
@org.springframework.beans.factory.annotation.Value("\${storage.documents.aiBaseUrl:\${storage.documents.publicBaseUrl}}")
private val aiBaseUrl: String
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun uploadDocument(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestParam("bookingId") bookingId: UUID,
@RequestPart("file") file: MultipartFile
): GuestDocumentResponse {
val user = requireUser(appUserRepo, principal)
propertyAccess.requireMember(propertyId, user.id!!)
propertyAccess.requireAnyRole(propertyId, user.id!!, Role.ADMIN, Role.MANAGER)
if (file.isEmpty) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "File is empty")
}
val contentType = file.contentType
if (contentType != null && contentType.startsWith("video/")) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Video files are not allowed")
}
val (property, guest) = requirePropertyGuest(propertyRepo, guestRepo, propertyId, guestId)
val booking = bookingRepo.findById(bookingId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
}
if (booking.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Booking not in property")
}
if (booking.primaryGuest?.id != guestId) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Booking not linked to guest")
}
val stored = storage.store(propertyId, guestId, bookingId, file)
val fileHash = hashFile(stored.storagePath)
if (fileHash != null && guestDocumentRepo.existsByPropertyIdAndGuestIdAndBookingIdAndFileHash(
propertyId,
guestId,
bookingId,
fileHash
)
) {
Files.deleteIfExists(Paths.get(stored.storagePath))
throw ResponseStatusException(HttpStatus.CONFLICT, "Duplicate document")
}
val document = GuestDocument(
property = property,
guest = guest,
booking = booking,
uploadedBy = user,
originalFilename = stored.originalFilename,
contentType = stored.contentType,
sizeBytes = stored.sizeBytes,
storagePath = stored.storagePath,
fileHash = fileHash
)
val saved = guestDocumentRepo.save(document)
runExtraction(saved.id!!, propertyId, guestId)
guestDocumentEvents.emit(propertyId, guestId)
return saved.toResponse(objectMapper)
}
@GetMapping
fun listDocuments(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<GuestDocumentResponse> {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
return guestDocumentRepo
.findByPropertyIdAndGuestIdOrderByUploadedAtDesc(propertyId, guestId)
.map { it.toResponse(objectMapper) }
}
@GetMapping("/stream", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun streamDocuments(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
response: HttpServletResponse
): org.springframework.web.servlet.mvc.method.annotation.SseEmitter {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
response.setHeader("Cache-Control", "no-cache")
response.setHeader("X-Accel-Buffering", "no")
return guestDocumentEvents.subscribe(propertyId, guestId)
}
@GetMapping("/{documentId}/file")
fun downloadDocument(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@PathVariable documentId: UUID,
@RequestParam(required = false) token: String?,
@AuthenticationPrincipal principal: MyPrincipal?
): ResponseEntity<FileSystemResource> {
if (token == null) {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
} else if (!tokenService.validateToken(token, documentId.toString())) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid token")
}
val document = guestDocumentRepo.findByIdAndPropertyIdAndGuestId(documentId, propertyId, guestId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Document not found")
val path = Paths.get(document.storagePath)
if (!Files.exists(path)) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "File missing")
}
val resource = FileSystemResource(path)
val type = document.contentType ?: MediaType.APPLICATION_OCTET_STREAM_VALUE
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(type))
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"${document.originalFilename}\"")
.contentLength(document.sizeBytes)
.body(resource)
}
@DeleteMapping("/{documentId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun deleteDocument(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@PathVariable documentId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
) {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
val document = guestDocumentRepo.findByIdAndPropertyIdAndGuestId(documentId, propertyId, guestId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Document not found")
val status = document.booking.status
if (status != com.android.trisolarisserver.models.booking.BookingStatus.OPEN &&
status != com.android.trisolarisserver.models.booking.BookingStatus.CHECKED_IN
) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Documents can only be deleted for OPEN or CHECKED_IN bookings"
)
}
val path = Paths.get(document.storagePath)
try {
Files.deleteIfExists(path)
} catch (_: Exception) {
throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to delete file")
}
guestDocumentRepo.delete(document)
guestDocumentEvents.emit(propertyId, guestId)
}
private fun runExtraction(documentId: UUID, propertyId: UUID, guestId: UUID) {
extractionQueue.enqueue {
val document = guestDocumentRepo.findById(documentId).orElse(null) ?: return@enqueue
try {
val token = tokenService.createToken(document.id.toString())
val imageUrl =
"${aiBaseUrl}/properties/$propertyId/guests/$guestId/documents/${document.id}/file?token=$token"
val results = linkedMapOf<String, String>()
results["isVehiclePhoto"] = llamaClient.ask(
imageUrl,
"IS THIS A VEHICLE NUMBER PLATE PHOTO? Answer YES or NO only."
)
if (isYes(results["isVehiclePhoto"])) {
results["vehicleNumber"] = llamaClient.ask(
imageUrl,
"VEHICLE NUMBER PLATE? Reply only number or NONE."
)
}
results["hasAadhar"] = llamaClient.ask(imageUrl, "CONTAINS AADHAAR? Answer YES or NO only.")
results["hasUidai"] = llamaClient.ask(imageUrl, "CONTAINS UIDAI? Answer YES or NO only.")
val hasAadhar = isYes(results["hasAadhar"]) || isYes(results["hasUidai"])
if (hasAadhar) {
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] = llamaClient.ask(imageUrl, question)
}
val hasAddress = isYes(results["hasAddress"])
if (hasAddress) {
val addressQuestions = linkedMapOf(
DocumentPrompts.PIN_CODE,
DocumentPrompts.ADDRESS
)
for ((key, question) in addressQuestions) {
results[key] = llamaClient.ask(imageUrl, 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] = llamaClient.ask(imageUrl, question)
}
}
} else {
results["hasDrivingLicence"] = llamaClient.ask(
imageUrl,
"CONTAINS DRIVING LICENCE? Answer YES or NO only."
)
results["hasTransportDept"] = llamaClient.ask(
imageUrl,
"CONTAINS TRANSPORT DEPARTMENT? Answer YES or NO only."
)
val isDriving = isYes(results["hasDrivingLicence"]) || isYes(results["hasTransportDept"])
if (isDriving) {
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] = llamaClient.ask(imageUrl, question)
}
} else {
results["hasElectionCommission"] = llamaClient.ask(
imageUrl,
"CONTAINS ELECTION COMMISSION OF INDIA? Answer YES or NO only."
)
if (isYes(results["hasElectionCommission"])) {
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] = llamaClient.ask(imageUrl, question)
}
} else {
results["hasIncomeTaxDept"] = llamaClient.ask(
imageUrl,
"CONTAINS INCOME TAX DEPARTMENT? Answer YES or NO only."
)
if (isYes(results["hasIncomeTaxDept"])) {
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] = llamaClient.ask(imageUrl, question)
}
} else {
results["hasPassport"] = llamaClient.ask(
imageUrl,
"CONTAINS PASSPORT? Answer YES or NO only."
)
if (isYes(results["hasPassport"])) {
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] = llamaClient.ask(imageUrl, question)
}
} else {
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] = llamaClient.ask(imageUrl, question)
}
}
}
}
}
}
results["docType"] = 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"
}
document.extractedData = objectMapper.writeValueAsString(results)
document.extractedAt = OffsetDateTime.now()
guestDocumentRepo.save(document)
val extractedName = cleanedValue(results[DocumentPrompts.NAME.first])
val extractedAddress = cleanedValue(results[DocumentPrompts.ADDRESS.first])
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 (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) {
val booking = bookingRepo.findById(document.booking.id!!).orElse(null)
guestVehicleRepo.save(
GuestVehicle(
property = property,
guest = guestEntity,
booking = booking,
vehicleNumber = extractedVehicle
)
)
}
}
}
guestDocumentEvents.emit(propertyId, guestId)
} catch (_: Exception) {
// Keep upload successful even if AI extraction fails.
}
}
}
private fun hashFile(storagePath: String): String? {
return try {
val path = Paths.get(storagePath)
if (!Files.exists(path)) return null
val digest = MessageDigest.getInstance("SHA-256")
Files.newInputStream(path).use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var read = input.read(buffer)
while (read >= 0) {
if (read > 0) {
digest.update(buffer, 0, read)
}
read = input.read(buffer)
}
}
digest.digest().joinToString("") { "%02x".format(it) }
} catch (_: Exception) {
null
}
}
}
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?
)
private fun GuestDocument.toResponse(objectMapper: ObjectMapper): GuestDocumentResponse {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "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()
)
}
private fun isYes(value: String?): Boolean {
return value.orEmpty().contains("YES", ignoreCase = true)
}
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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.core.io.FileSystemResource

View File

@@ -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.UserResponse
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.PropertyUserRepo
import com.android.trisolarisserver.controller.dto.property.PropertyUserResponse
import com.android.trisolarisserver.controller.dto.property.UserResponse
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.property.PropertyUserRepo
import com.android.trisolarisserver.security.MyPrincipal
import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory

View File

@@ -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.controller.dto.BookingBalanceResponse
import com.android.trisolarisserver.db.repo.BookingRepo
import com.android.trisolarisserver.repo.PaymentRepo
import com.android.trisolarisserver.repo.RoomStayRepo
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.payment.BookingBalanceResponse
import com.android.trisolarisserver.repo.booking.BookingRepo
import com.android.trisolarisserver.repo.booking.ChargeRepo
import com.android.trisolarisserver.repo.booking.PaymentRepo
import com.android.trisolarisserver.repo.room.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.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.time.LocalDate
import java.util.UUID
@RestController
@@ -22,10 +25,12 @@ class BookingBalances(
private val propertyAccess: PropertyAccess,
private val bookingRepo: BookingRepo,
private val roomStayRepo: RoomStayRepo,
private val chargeRepo: ChargeRepo,
private val paymentRepo: PaymentRepo
) {
@GetMapping
@Transactional(readOnly = true)
fun getBalance(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@@ -38,35 +43,20 @@ class BookingBalances(
if (booking.property.id != propertyId) {
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 pending = expected - collected
val pending = expected + charges - collected
return BookingBalanceResponse(
expectedPay = expected,
expectedPay = expected + charges,
amountCollected = collected,
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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package com.android.trisolarisserver.controller
package com.android.trisolarisserver.controller.card
import java.time.OffsetDateTime

View File

@@ -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
internal fun IssuedCard.toResponse(): IssuedCardResponse {

View File

@@ -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.controller.dto.CardPrepareRequest
import com.android.trisolarisserver.controller.dto.CardPrepareResponse
import com.android.trisolarisserver.controller.dto.CardRevokeResponse
import com.android.trisolarisserver.controller.dto.IssueCardRequest
import com.android.trisolarisserver.controller.dto.IssuedCardResponse
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.booking.CardPrepareRequest
import com.android.trisolarisserver.controller.dto.booking.CardPrepareResponse
import com.android.trisolarisserver.controller.dto.booking.CardRevokeResponse
import com.android.trisolarisserver.controller.dto.booking.IssueCardRequest
import com.android.trisolarisserver.controller.dto.booking.IssuedCardResponse
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.models.room.IssuedCard
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.IssuedCardRepo
import com.android.trisolarisserver.repo.PropertyCardCounterRepo
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.repo.RoomStayRepo
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.card.IssuedCardRepo
import com.android.trisolarisserver.repo.card.PropertyCardCounterRepo
import com.android.trisolarisserver.repo.property.PropertyRepo
import com.android.trisolarisserver.repo.room.RoomStayRepo
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal

View File

@@ -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.controller.dto.CardPrepareResponse
import com.android.trisolarisserver.controller.dto.IssueTempCardRequest
import com.android.trisolarisserver.controller.dto.IssuedCardResponse
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.booking.CardPrepareResponse
import com.android.trisolarisserver.controller.dto.booking.IssueTempCardRequest
import com.android.trisolarisserver.controller.dto.booking.IssuedCardResponse
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.models.room.IssuedCard
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.IssuedCardRepo
import com.android.trisolarisserver.repo.PropertyCardCounterRepo
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.repo.RoomRepo
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.card.IssuedCardRepo
import com.android.trisolarisserver.repo.card.PropertyCardCounterRepo
import com.android.trisolarisserver.repo.property.PropertyRepo
import com.android.trisolarisserver.repo.room.RoomRepo
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal

View File

@@ -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.Role
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
@@ -40,3 +45,11 @@ internal fun requireRole(
propertyAccess.requireAnyRole(propertyId, resolved.userId, *roles)
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
}

View File

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

View File

@@ -1,11 +1,11 @@
package com.android.trisolarisserver.controller
package com.android.trisolarisserver.controller.document
object DocumentPrompts {
val NAME = "name" to "NAME? Reply only the name 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 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 GENDER = "gender" to "GENDER? Reply only MALE/FEMALE/OTHER or NONE."
val NATIONALITY = "nationality" to "NATIONALITY? Reply only nationality or NONE."

View File

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

View File

@@ -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
data class BookingCheckInRequest(
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
)
@JsonIgnoreProperties(ignoreUnknown = false)
data class BookingCheckInStayRequest(
val roomId: UUID,
val checkInAt: String? = null,
val checkOutAt: String? = null,
val nightlyRate: Long? = null,
val rateSource: String? = null,
val ratePlanCode: String? = null,
val currency: String? = null
)
@JsonIgnoreProperties(ignoreUnknown = false)
data class BookingBulkCheckInRequest(
val stays: List<BookingCheckInStayRequest>,
val transportMode: String? = null,
@@ -33,6 +24,8 @@ data class BookingCreateRequest(
val source: String? = null,
val expectedCheckInAt: String,
val expectedCheckOutAt: String,
val billingMode: String? = null,
val billingCheckoutTime: String? = null,
val guestPhoneE164: String? = null,
val fromCity: String? = null,
val toCity: String? = null,
@@ -48,6 +41,9 @@ data class BookingCreateRequest(
data class BookingCreateResponse(
val id: UUID,
val status: String,
val billingMode: String,
val billingCheckinTime: String,
val billingCheckoutTime: String,
val guestId: UUID?,
val checkInAt: String?,
val expectedCheckInAt: String?,
@@ -60,8 +56,12 @@ data class BookingListItem(
val guestId: UUID?,
val guestName: String?,
val guestPhone: String?,
val vehicleNumbers: List<String>,
val roomNumbers: List<Int>,
val source: String?,
val billingMode: String,
val billingCheckinTime: String,
val billingCheckoutTime: String,
val expectedCheckInAt: String?,
val expectedCheckOutAt: String?,
val checkInAt: String?,
@@ -84,9 +84,14 @@ data class BookingDetailResponse(
val guestPhone: String?,
val guestNationality: String?,
val guestAddressText: String?,
val guestAge: String?,
val guestSignatureUrl: String?,
val vehicleNumbers: List<String>,
val roomNumbers: List<Int>,
val source: String?,
val billingMode: String,
val billingCheckinTime: String,
val billingCheckoutTime: String,
val fromCity: String?,
val toCity: String?,
val memberRelation: String?,
@@ -105,6 +110,7 @@ data class BookingDetailResponse(
val registeredByName: String?,
val registeredByPhone: String?,
val totalNightlyRate: Long,
val billableNights: Long?,
val expectedPay: Long,
val amountCollected: Long,
val pending: Long
@@ -119,11 +125,43 @@ data class BookingExpectedDatesUpdateRequest(
val expectedCheckOutAt: String? = null
)
data class BookingBillingPolicyUpdateRequest(
val billingMode: String,
val billingCheckoutTime: String? = null
)
data class BookingCheckOutRequest(
val checkOutAt: 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(
val cancelledAt: String? = null,
val reason: String? = null
@@ -134,29 +172,8 @@ data class BookingNoShowRequest(
val reason: String? = null
)
data class RoomChangeRequest(
val newRoomId: UUID,
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 RoomStayVoidRequest(
val reason: String? = null
)
data class IssueCardRequest(

View File

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

View File

@@ -1,4 +1,4 @@
package com.android.trisolarisserver.controller.dto
package com.android.trisolarisserver.controller.dto.guest
import java.util.UUID

View File

@@ -1,4 +1,4 @@
package com.android.trisolarisserver.controller.dto
package com.android.trisolarisserver.controller.dto.payment
import java.time.OffsetDateTime
import java.util.UUID

View File

@@ -1,4 +1,4 @@
package com.android.trisolarisserver.controller.dto
package com.android.trisolarisserver.controller.dto.payment
import java.util.UUID

View File

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

View File

@@ -1,13 +1,14 @@
package com.android.trisolarisserver.controller.dto
package com.android.trisolarisserver.controller.dto.property
import java.util.UUID
data class PropertyCreateRequest(
val code: String,
val name: String,
val addressText: String? = null,
val timezone: String? = null,
val currency: String? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null,
val active: Boolean? = null,
val otaAliases: Set<String>? = null,
val emailAddresses: Set<String>? = null,
@@ -20,6 +21,8 @@ data class PropertyUpdateRequest(
val addressText: String? = null,
val timezone: String? = null,
val currency: String? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null,
val active: Boolean? = null,
val otaAliases: Set<String>? = null,
val emailAddresses: Set<String>? = null,
@@ -33,16 +36,34 @@ data class PropertyResponse(
val addressText: String?,
val timezone: String,
val currency: String,
val billingCheckinTime: String,
val billingCheckoutTime: String,
val active: Boolean,
val otaAliases: Set<String>,
val emailAddresses: 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(
val id: UUID,
val name: String?,
val phoneE164: String?,
val dob: String?,
val nationality: String?,
val addressText: String?,
val signatureUrl: String?,
@@ -85,6 +106,10 @@ data class PropertyUserRoleRequest(
val roles: Set<String>
)
data class PropertyUserDisableRequest(
val disabled: Boolean
)
data class PropertyUserResponse(
val userId: UUID,
val propertyId: UUID,

View File

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

View File

@@ -1,4 +1,4 @@
package com.android.trisolarisserver.controller.dto
package com.android.trisolarisserver.controller.dto.rate
import java.time.LocalDate
import java.util.UUID

View File

@@ -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?
)

View File

@@ -1,4 +1,4 @@
package com.android.trisolarisserver.controller.dto
package com.android.trisolarisserver.controller.dto.room
import java.util.UUID
@@ -27,16 +27,10 @@ data class RoomAvailabilityResponse(
)
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 roomTypeName: String,
val freeRoomNumbers: List<Int>,
val freeCount: Int,
val averageRate: Double?,
val currency: String,
val ratePlanCode: String? = null

View File

@@ -1,4 +1,4 @@
package com.android.trisolarisserver.controller.dto
package com.android.trisolarisserver.controller.dto.room
import java.util.UUID
@@ -13,5 +13,6 @@ data class ActiveRoomStayResponse(
val roomTypeName: String,
val fromAt: String,
val checkinAt: String?,
val expectedCheckoutAt: String?
val expectedCheckoutAt: String?,
val nightlyRate: Long?
)

View File

@@ -1,4 +1,4 @@
package com.android.trisolarisserver.controller.dto
package com.android.trisolarisserver.controller.dto.room
import java.util.UUID

View File

@@ -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.PropertyAccess
import com.android.trisolarisserver.db.repo.InboundEmailRepo
import com.android.trisolarisserver.component.storage.EmailStorage
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.repo.email.InboundEmailRepo
import com.android.trisolarisserver.models.booking.InboundEmail
import com.android.trisolarisserver.models.booking.InboundEmailStatus
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.service.EmailIngestionService
import com.android.trisolarisserver.service.email.EmailIngestionService
import org.apache.pdfbox.pdmodel.PDDocument
import org.apache.pdfbox.text.PDFTextStripper
import org.springframework.http.HttpStatus

View File

@@ -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.db.repo.InboundEmailRepo
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.repo.email.InboundEmailRepo
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.core.io.FileSystemResource

View File

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

View File

@@ -0,0 +1,262 @@
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.storage.DocumentStorage
import com.android.trisolarisserver.component.document.DocumentTokenService
import com.android.trisolarisserver.component.ai.ExtractionQueue
import com.android.trisolarisserver.component.document.GuestDocumentEvents
import com.android.trisolarisserver.component.document.DocumentExtractionService
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.repo.booking.BookingRepo
import com.android.trisolarisserver.repo.guest.GuestDocumentRepo
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.property.Role
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.property.PropertyRepo
import com.android.trisolarisserver.security.MyPrincipal
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.core.io.FileSystemResource
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.annotation.*
import org.springframework.http.MediaType
import jakarta.servlet.http.HttpServletResponse
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.server.ResponseStatusException
import java.time.OffsetDateTime
import java.nio.file.Files
import java.nio.file.Paths
import java.util.UUID
import java.security.MessageDigest
import org.slf4j.LoggerFactory
@RestController
@RequestMapping("/properties/{propertyId}/guests/{guestId}/documents")
class GuestDocuments(
private val propertyAccess: PropertyAccess,
private val propertyRepo: PropertyRepo,
private val guestRepo: GuestRepo,
private val bookingRepo: BookingRepo,
private val guestDocumentRepo: GuestDocumentRepo,
private val appUserRepo: AppUserRepo,
private val storage: DocumentStorage,
private val tokenService: DocumentTokenService,
private val extractionQueue: ExtractionQueue,
private val guestDocumentEvents: GuestDocumentEvents,
private val extractionService: DocumentExtractionService,
private val objectMapper: ObjectMapper,
@org.springframework.beans.factory.annotation.Value("\${storage.documents.publicBaseUrl}")
private val publicBaseUrl: String,
@org.springframework.beans.factory.annotation.Value("\${storage.documents.aiBaseUrl:\${storage.documents.publicBaseUrl}}")
private val aiBaseUrl: String
) {
private val logger = LoggerFactory.getLogger(GuestDocuments::class.java)
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun uploadDocument(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestParam("bookingId") bookingId: UUID,
@RequestPart("file") file: MultipartFile
): GuestDocumentResponse {
val user = requireUser(appUserRepo, principal)
propertyAccess.requireMember(propertyId, user.id!!)
propertyAccess.requireAnyRole(propertyId, user.id!!, Role.ADMIN, Role.MANAGER)
if (file.isEmpty) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "File is empty")
}
val contentType = file.contentType
if (contentType != null && contentType.startsWith("video/")) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Video files are not allowed")
}
val (property, guest) = requirePropertyGuest(propertyRepo, guestRepo, propertyId, guestId)
val booking = bookingRepo.findById(bookingId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
}
if (booking.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Booking not in property")
}
if (booking.primaryGuest?.id != guestId) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Booking not linked to guest")
}
val stored = storage.store(propertyId, guestId, bookingId, file)
val fileHash = hashFile(stored.storagePath)
if (fileHash != null && guestDocumentRepo.existsByPropertyIdAndGuestIdAndBookingIdAndFileHash(
propertyId,
guestId,
bookingId,
fileHash
)
) {
Files.deleteIfExists(Paths.get(stored.storagePath))
throw ResponseStatusException(HttpStatus.CONFLICT, "Duplicate document")
}
val document = GuestDocument(
property = property,
guest = guest,
booking = booking,
uploadedBy = user,
originalFilename = stored.originalFilename,
contentType = stored.contentType,
sizeBytes = stored.sizeBytes,
storagePath = stored.storagePath,
fileHash = fileHash
)
val saved = guestDocumentRepo.save(document)
runExtraction(saved.id!!, propertyId, guestId)
guestDocumentEvents.emit(propertyId, guestId)
return saved.toResponse(objectMapper)
}
@GetMapping
fun listDocuments(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<GuestDocumentResponse> {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
return guestDocumentRepo
.findByPropertyIdAndGuestIdOrderByUploadedAtDesc(propertyId, guestId)
.map { it.toResponse(objectMapper) }
}
@GetMapping("/stream", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun streamDocuments(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
response: HttpServletResponse
): org.springframework.web.servlet.mvc.method.annotation.SseEmitter {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
response.setHeader("Cache-Control", "no-cache")
response.setHeader("Connection", "keep-alive")
response.setHeader("X-Accel-Buffering", "no")
return guestDocumentEvents.subscribe(propertyId, guestId)
}
@GetMapping("/{documentId}/file")
fun downloadDocument(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@PathVariable documentId: UUID,
@RequestParam(required = false) token: String?,
@AuthenticationPrincipal principal: MyPrincipal?
): ResponseEntity<FileSystemResource> {
if (token == null) {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
} else if (!tokenService.validateToken(token, documentId.toString())) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid token")
}
val document = guestDocumentRepo.findByIdAndPropertyIdAndGuestId(documentId, propertyId, guestId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Document not found")
val path = Paths.get(document.storagePath)
if (!Files.exists(path)) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "File missing")
}
val resource = FileSystemResource(path)
val type = document.contentType ?: MediaType.APPLICATION_OCTET_STREAM_VALUE
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(type))
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"${document.originalFilename}\"")
.contentLength(document.sizeBytes)
.body(resource)
}
@DeleteMapping("/{documentId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun deleteDocument(
@PathVariable propertyId: UUID,
@PathVariable guestId: UUID,
@PathVariable documentId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
) {
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
val document = guestDocumentRepo.findByIdAndPropertyIdAndGuestId(documentId, propertyId, guestId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Document not found")
val status = document.booking.status
val linkedBookingOpenOrCheckedIn = status == BookingStatus.OPEN || status == BookingStatus.CHECKED_IN
val guestHasOpenOrCheckedInBooking = bookingRepo.existsByPropertyIdAndPrimaryGuestIdAndStatusIn(
propertyId = propertyId,
primaryGuestId = guestId,
status = listOf(BookingStatus.OPEN, BookingStatus.CHECKED_IN)
)
if (!linkedBookingOpenOrCheckedIn && !guestHasOpenOrCheckedInBooking) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Documents can only be deleted for OPEN or CHECKED_IN bookings"
)
}
val path = Paths.get(document.storagePath)
try {
Files.deleteIfExists(path)
} catch (_: Exception) {
throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to delete file")
}
guestDocumentRepo.delete(document)
guestDocumentEvents.emit(propertyId, guestId)
}
private fun runExtraction(documentId: UUID, propertyId: UUID, guestId: UUID) {
extractionQueue.enqueue {
val document = guestDocumentRepo.findById(documentId).orElse(null) ?: return@enqueue
try {
val token = tokenService.createToken(document.id.toString())
val imageUrl =
"${aiBaseUrl}/properties/$propertyId/guests/$guestId/documents/${document.id}/file?token=$token"
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
document.extractedData = objectMapper.writeValueAsString(results)
document.extractedAt = OffsetDateTime.now()
guestDocumentRepo.save(document)
guestDocumentEvents.emit(propertyId, guestId)
} catch (ex: Exception) {
logger.warn("Document extraction failed for documentId={}", document.id, ex)
}
}
}
private fun hashFile(storagePath: String): String? {
return try {
val path = Paths.get(storagePath)
if (!Files.exists(path)) return null
val digest = MessageDigest.getInstance("SHA-256")
Files.newInputStream(path).use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var read = input.read(buffer)
while (read >= 0) {
if (read > 0) {
digest.update(buffer, 0, read)
}
read = input.read(buffer)
}
}
digest.digest().joinToString("") { "%02x".format(it) }
} catch (_: Exception) {
null
}
}
}

View File

@@ -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.controller.dto.GuestRatingCreateRequest
import com.android.trisolarisserver.controller.dto.GuestRatingResponse
import com.android.trisolarisserver.db.repo.BookingRepo
import com.android.trisolarisserver.db.repo.GuestRatingRepo
import com.android.trisolarisserver.db.repo.GuestRepo
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.guest.GuestRatingCreateRequest
import com.android.trisolarisserver.controller.dto.guest.GuestRatingResponse
import com.android.trisolarisserver.repo.booking.BookingRepo
import com.android.trisolarisserver.repo.guest.GuestRatingRepo
import com.android.trisolarisserver.repo.guest.GuestRepo
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.property.PropertyRepo
import com.android.trisolarisserver.models.booking.GuestRating
import com.android.trisolarisserver.models.booking.GuestRatingScore
import com.android.trisolarisserver.security.MyPrincipal

View File

@@ -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.GuestSignatureStorage
import com.android.trisolarisserver.controller.dto.GuestResponse
import com.android.trisolarisserver.controller.dto.GuestUpdateRequest
import com.android.trisolarisserver.controller.dto.GuestVehicleRequest
import com.android.trisolarisserver.controller.dto.GuestVisitCountResponse
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.component.storage.GuestSignatureStorage
import com.android.trisolarisserver.controller.dto.property.GuestResponse
import com.android.trisolarisserver.controller.dto.property.GuestUpdateRequest
import com.android.trisolarisserver.controller.dto.property.GuestVehicleRequest
import com.android.trisolarisserver.controller.dto.property.GuestVisitCountResponse
import com.android.trisolarisserver.models.booking.Guest
import com.android.trisolarisserver.models.booking.GuestVehicle
import com.android.trisolarisserver.db.repo.BookingRepo
import com.android.trisolarisserver.db.repo.GuestRepo
import com.android.trisolarisserver.db.repo.GuestRatingRepo
import com.android.trisolarisserver.repo.GuestVehicleRepo
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.repo.booking.BookingRepo
import com.android.trisolarisserver.repo.guest.GuestRepo
import com.android.trisolarisserver.repo.guest.GuestRatingRepo
import com.android.trisolarisserver.repo.guest.GuestVehicleRepo
import com.android.trisolarisserver.repo.property.PropertyRepo
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.core.io.FileSystemResource
import org.springframework.http.HttpStatus
@@ -228,6 +232,7 @@ private fun Set<Guest>.toResponse(
id = guest.id!!,
name = guest.name,
phoneE164 = guest.phoneE164,
dob = guest.age?.trim()?.ifBlank { null },
nationality = guest.nationality,
addressText = guest.addressText,
signatureUrl = guest.signaturePath?.let {

View File

@@ -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.controller.dto.ChargeCreateRequest
import com.android.trisolarisserver.controller.dto.ChargeResponse
import com.android.trisolarisserver.db.repo.BookingRepo
import com.android.trisolarisserver.component.booking.BookingEvents
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.payment.ChargeCreateRequest
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.ChargeType
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.ChargeRepo
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.booking.ChargeRepo
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
@@ -29,7 +34,8 @@ class Charges(
private val propertyAccess: PropertyAccess,
private val bookingRepo: BookingRepo,
private val chargeRepo: ChargeRepo,
private val appUserRepo: AppUserRepo
private val appUserRepo: AppUserRepo,
private val bookingEvents: BookingEvents
) {
@PostMapping
@@ -65,7 +71,9 @@ class Charges(
occurredAt = occurredAt,
createdBy = createdBy
)
return chargeRepo.save(charge).toResponse()
val saved = chargeRepo.save(charge).toResponse()
bookingEvents.emit(propertyId, bookingId)
return saved
}
@GetMapping

View File

@@ -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.controller.dto.PaymentCreateRequest
import com.android.trisolarisserver.controller.dto.PaymentResponse
import com.android.trisolarisserver.db.repo.BookingRepo
import com.android.trisolarisserver.component.booking.BookingEvents
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.payment.PaymentCreateRequest
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.Payment
import com.android.trisolarisserver.models.booking.PaymentMethod
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.PaymentRepo
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.booking.PaymentRepo
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
@@ -34,7 +38,8 @@ class Payments(
private val propertyRepo: PropertyRepo,
private val bookingRepo: BookingRepo,
private val paymentRepo: PaymentRepo,
private val appUserRepo: AppUserRepo
private val appUserRepo: AppUserRepo,
private val bookingEvents: BookingEvents
) {
@PostMapping
@@ -72,7 +77,9 @@ class Payments(
receivedAt = receivedAt,
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
@@ -122,6 +129,7 @@ class Payments(
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Only CASH payments can be deleted")
}
paymentRepo.delete(payment)
bookingEvents.emit(propertyId, bookingId)
}
private fun parseMethod(value: String): PaymentMethod {

View File

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

View File

@@ -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.controller.dto.PropertyCreateRequest
import com.android.trisolarisserver.controller.dto.PropertyResponse
import com.android.trisolarisserver.controller.dto.PropertyUpdateRequest
import com.android.trisolarisserver.controller.dto.PropertyUserResponse
import com.android.trisolarisserver.controller.dto.PropertyUserRoleRequest
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.repo.PropertyUserRepo
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.property.PropertyCodeResponse
import com.android.trisolarisserver.controller.dto.property.PropertyBillingPolicyRequest
import com.android.trisolarisserver.controller.dto.property.PropertyBillingPolicyResponse
import com.android.trisolarisserver.controller.dto.property.PropertyCreateRequest
import com.android.trisolarisserver.controller.dto.property.PropertyResponse
import com.android.trisolarisserver.controller.dto.property.PropertyUserDisableRequest
import com.android.trisolarisserver.controller.dto.property.PropertyUpdateRequest
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.PropertyUser
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.RestController
import org.springframework.web.server.ResponseStatusException
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.util.UUID
@RestController
@@ -35,6 +45,8 @@ class Properties(
private val propertyUserRepo: PropertyUserRepo,
private val appUserRepo: AppUserRepo
) {
private val codeRandom = java.security.SecureRandom()
private val codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
@PostMapping("/properties")
@ResponseStatus(HttpStatus.CREATED)
@@ -42,17 +54,17 @@ class Properties(
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: PropertyCreateRequest
): PropertyResponse {
val user = requireUser(principal)
if (propertyRepo.existsByCode(request.code)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Property code already exists")
}
val user = requireUser(appUserRepo, principal)
val code = generatePropertyCode()
val property = Property(
code = request.code,
code = code,
name = request.name,
addressText = request.addressText,
timezone = request.timezone ?: "Asia/Kolkata",
currency = request.currency ?: "INR",
billingCheckinTime = validateBillingTime(request.billingCheckinTime, "billingCheckinTime", "12:00"),
billingCheckoutTime = validateBillingTime(request.billingCheckoutTime, "billingCheckoutTime", "11:00"),
active = request.active ?: true,
otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf(),
emailAddresses = request.emailAddresses?.toMutableSet() ?: mutableSetOf(),
@@ -80,7 +92,7 @@ class Properties(
fun listProperties(
@AuthenticationPrincipal principal: MyPrincipal?
): List<PropertyResponse> {
val user = requireUser(principal)
val user = requireUser(appUserRepo, principal)
return if (user.superAdmin) {
propertyRepo.findAll().map { it.toResponse() }
} 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")
fun listPropertyUsers(
@PathVariable propertyId: UUID,
@@ -97,8 +169,14 @@ class Properties(
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
val users = propertyUserRepo.findByIdPropertyId(propertyId)
return users.map {
val actorUser = appUserRepo.findById(principal.userId).orElse(null)
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(
userId = it.id.userId!!,
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}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deletePropertyUser(
@@ -197,6 +324,16 @@ class Properties(
property.addressText = request.addressText ?: property.addressText
property.timezone = request.timezone ?: property.timezone
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
if (request.otaAliases != null) {
property.otaAliases = request.otaAliases.toMutableSet()
@@ -211,21 +348,6 @@ class Properties(
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> {
return try {
modes.map { TransportMode.valueOf(it) }.toMutableSet()
@@ -233,6 +355,47 @@ class Properties(
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 {
@@ -244,6 +407,8 @@ private fun Property.toResponse(): PropertyResponse {
addressText = addressText,
timezone = timezone,
currency = currency,
billingCheckinTime = billingCheckinTime,
billingCheckoutTime = billingCheckoutTime,
active = active,
otaAliases = otaAliases.toSet(),
emailAddresses = emailAddresses.toSet(),

View File

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

View File

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

View File

@@ -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.controller.dto.RateCalendarResponse
import com.android.trisolarisserver.controller.dto.RateCalendarAverageResponse
import com.android.trisolarisserver.controller.dto.RateCalendarRangeUpsertRequest
import com.android.trisolarisserver.controller.dto.RatePlanCreateRequest
import com.android.trisolarisserver.controller.dto.RatePlanResponse
import com.android.trisolarisserver.controller.dto.RatePlanUpdateRequest
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.repo.RateCalendarRepo
import com.android.trisolarisserver.repo.RatePlanRepo
import com.android.trisolarisserver.repo.RoomTypeRepo
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.rate.RateCalendarResponse
import com.android.trisolarisserver.controller.dto.rate.RateCalendarAverageResponse
import com.android.trisolarisserver.controller.dto.rate.RateCalendarRangeUpsertRequest
import com.android.trisolarisserver.controller.dto.rate.RatePlanCreateRequest
import com.android.trisolarisserver.controller.dto.rate.RatePlanResponse
import com.android.trisolarisserver.controller.dto.rate.RatePlanUpdateRequest
import com.android.trisolarisserver.repo.property.PropertyRepo
import com.android.trisolarisserver.repo.rate.RateCalendarRepo
import com.android.trisolarisserver.repo.rate.RatePlanRepo
import com.android.trisolarisserver.repo.room.RoomTypeRepo
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.models.room.RateCalendar
import com.android.trisolarisserver.models.room.RatePlan
@@ -132,8 +135,8 @@ class RatePlans(
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found")
val fromDate = parseDate(request.from)
val toDate = parseDate(request.to)
val fromDate = parseDate(request.from, "Invalid date")
val toDate = parseDate(request.to, "Invalid date")
if (toDate.isBefore(fromDate)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "to must be on/after from")
}
@@ -174,8 +177,8 @@ class RatePlans(
requireMember(propertyAccess, propertyId, principal)
val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found")
val fromDate = parseDate(from)
val toDate = parseDate(to)
val fromDate = parseDate(from, "Invalid date")
val toDate = parseDate(to, "Invalid date")
if (toDate.isBefore(fromDate)) {
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)
val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId)
?: 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)
?: return
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> {
return generateSequence(from) { current ->
val next = current.plusDays(1)

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
package com.android.trisolarisserver.controller
package com.android.trisolarisserver.controller.razorpay
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.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
@@ -10,30 +9,17 @@ import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/payu/return")
class PayuReturnController {
@GetMapping("/success")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun success(@PathVariable propertyId: UUID) {
// PayU redirect target; no-op.
}
@RequestMapping("/properties/{propertyId}/razorpay/return")
class RazorpayReturnController {
@PostMapping("/success")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun successPost(@PathVariable propertyId: UUID) {
// PayU redirect target; no-op.
}
@GetMapping("/failure")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun failure(@PathVariable propertyId: UUID) {
// PayU redirect target; no-op.
fun success(@PathVariable propertyId: UUID) {
// Razorpay redirect target; no-op.
}
@PostMapping("/failure")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun failurePost(@PathVariable propertyId: UUID) {
// PayU redirect target; no-op.
fun failure(@PathVariable propertyId: UUID) {
// Razorpay redirect target; no-op.
}
}

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More