Compare commits

..

164 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
androidlover5842
3af51b77b1 Update guest profile and vehicles from extraction
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
2026-01-31 02:33:05 +05:30
androidlover5842
607346905c no idea 0_0
All checks were successful
build-and-deploy / build-deploy (push) Successful in 31s
2026-01-31 02:19:23 +05:30
androidlover5842
e0e7b63bf9 doc extractor: force if else on number plate extraction
All checks were successful
build-and-deploy / build-deploy (push) Successful in 1m5s
2026-01-31 02:13:00 +05:30
androidlover5842
e0643ce695 Centralize document prompts
All checks were successful
build-and-deploy / build-deploy (push) Successful in 31s
2026-01-31 02:05:11 +05:30
androidlover5842
d4a9a06725 guest docs: improve question logic 2026-01-31 01:52:28 +05:30
179 changed files with 13315 additions and 4107 deletions

View File

@@ -40,6 +40,13 @@ Repository
- Root: /home/androidlover5842/IdeaProjects/TrisolarisServer - Root: /home/androidlover5842/IdeaProjects/TrisolarisServer
- Entry: src/main/kotlin/com/android/trisolarisserver/TrisolarisServerApplication.kt - Entry: src/main/kotlin/com/android/trisolarisserver/TrisolarisServerApplication.kt
- Scheduling enabled (@EnableScheduling) - Scheduling enabled (@EnableScheduling)
- Package layout (domain subpackages; keep top-level grouping):
- controller/{auth,booking,guest,room,rate,property,payment,card,email,document,common,system,assets,transport,razorpay}
- controller/dto/{booking,guest,payment,property,rate,room,razorpay}
- repo/{booking,guest,room,rate,property,card,email,razorpay}
- component/{ai,auth,booking,document,geo,room,sse,storage,razorpay}
- config/{core,db,booking,room,rate,guest,payment,card,razorpay}
- service/email
Security/Auth Security/Auth
- Firebase Admin auth for every request; Firebase UID required. - Firebase Admin auth for every request; Firebase UID required.
@@ -99,12 +106,15 @@ Properties
Booking flow Booking flow
- POST /properties/{propertyId}/bookings (create booking) - POST /properties/{propertyId}/bookings (create booking)
- /properties/{propertyId}/bookings/{bookingId}/check-in (creates RoomStay rows) - /properties/{propertyId}/bookings/{bookingId}/check-in/bulk (creates RoomStay rows with per-stay rates)
- /properties/{propertyId}/bookings/{bookingId}/check-out (closes RoomStay) - /properties/{propertyId}/bookings/{bookingId}/check-out (closes RoomStay)
- /properties/{propertyId}/bookings/{bookingId}/room-stays/{roomStayId}/check-out (closes specific stay; single-stay booking auto-closes booking)
- /properties/{propertyId}/bookings/{bookingId}/cancel - /properties/{propertyId}/bookings/{bookingId}/cancel
- /properties/{propertyId}/bookings/{bookingId}/no-show - /properties/{propertyId}/bookings/{bookingId}/no-show
- /properties/{propertyId}/bookings/{bookingId}/room-stays (pre-assign RoomStay with date range) - /properties/{propertyId}/bookings/{bookingId}/room-requests (room-type quantity reservation)
- /properties/{propertyId}/room-stays/{roomStayId}/change-room (idempotent via RoomStayChange) - /properties/{propertyId}/bookings/{bookingId}/room-requests/{requestId} (cancel reservation)
- /properties/{propertyId}/room-stays/{roomStayId}/void (soft-void active stay)
- /properties/{propertyId}/cancellation-policy (get/update policy)
Card issuing Card issuing
- /properties/{propertyId}/room-stays/{roomStayId}/cards/prepare -> returns cardIndex + sector0 payload - /properties/{propertyId}/room-stays/{roomStayId}/cards/prepare -> returns cardIndex + sector0 payload
@@ -185,3 +195,33 @@ Notes / constraints
- Super admin can create properties and assign users to properties. - Super admin can create properties and assign users to properties.
- Admin can assign ADMIN/MANAGER/STAFF/AGENT; Manager can assign STAFF/AGENT. - Admin can assign ADMIN/MANAGER/STAFF/AGENT; Manager can assign STAFF/AGENT.
- Agents can only see free rooms. - Agents can only see free rooms.
- Role hierarchy for visibility/management: SUPER_ADMIN > ADMIN > MANAGER > STAFF/HOUSEKEEPING/FINANCE/SUPERVISOR/GUIDE > AGENT. Users cannot see anyone above their rank in property user lists. Access code invites cannot assign ADMIN.
- Property code is auto-generated (7-char random, no fixed prefix). Property create no longer accepts `code` in request. Join-by-code uses property code, not propertyId.
- Property access codes: 6-digit PIN, 1-minute expiry, single-use. Admin generates; staff joins with property code + PIN.
- Property user disable is property-scoped (not global); hierarchy applies for who can disable.
- Room stay lifecycle: `RoomStay` now supports soft void (`is_voided`), and room-stay audit events are written to `room_stay_audit_log`.
- Checkout supports both booking-level and specific room-stay checkout; specific checkout endpoint: `POST /properties/{propertyId}/bookings/{bookingId}/room-stays/{roomStayId}/check-out`.
- Staff can change/void stays only before first payment on booking; manager/admin can act after payments.
- Checkout validation: nightly rate must be within +/-20% of room type default rate (when default rate exists), and minimum stay duration must be at least 1 hour.
- Room-type reservations: use booking room requests (`booking_room_request`) for quantity holds without room numbers; availability checks include active requests + occupied stays.
- Cancellation policy engine (advance bookings): policy per property with `freeDaysBeforeCheckin` + `penaltyMode` (`NO_CHARGE`, `ONE_NIGHT`, `FULL_STAY`). On cancel/no-show, penalty charge ledger rows are auto-created (`CANCELLATION_PENALTY` / `NO_SHOW_PENALTY`) when within penalty window.
Operational notes
- Payment provider migrated: PayU removed; Razorpay now used for settings, QR, payment links, and webhooks.
- Server access: SSH host alias `hotel` is available for server operations (e.g., `ssh hotel`). Use carefully; DB changes were done via `sudo -u postgres psql` on the server when needed.
- Schema changes: schema fix classes have been removed. If a new column/table is required, apply it manually on the server using `ssh hotel` and `sudo -u postgres psql -d trisolaris`, e.g. `alter table ... add column ...`. Keep a note of the exact SQL applied.
- Agent workflow expectation: when schema/runtime issues require server-side SQL or service checks, execute the required `ssh hotel` operations directly and report what was changed; do not block on asking for confirmation in normal flow.
Access / ops notes (prod)
- Service: `TrisolarisServer.service` (systemd). `systemctl cat TrisolarisServer.service`.
- Deploy path: `/opt/deploy/TrisolarisServer` (runs `build/libs/*.jar`).
- Active profile: `prod` (see service Environment=SPRING_PROFILES_ACTIVE=prod).
- DB (prod): PostgreSQL `trisolaris` on `localhost:5432` (see `/opt/deploy/TrisolarisServer/src/main/resources/application-prod.properties` on the server).
- DB (dev): PostgreSQL `trisolaris` on `192.168.1.53:5432` (see `application-dev.properties` in the repo).
- DB access (server): `sudo -u postgres psql -d trisolaris`.
- Workflow: Always run build, commit, and push for changes unless explicitly told otherwise.
- API docs policy (mandatory):
- For every API change (`add`, `update`, `delete`, path change, request/response change, role change, validation/error change), update `docs/API_REFERENCE.txt` in the same endpoint-by-endpoint text format already used there.
- Keep each API block in this style: `"<Name> API is this one:"` -> `METHOD /path` -> `What it does` -> `Request body` -> `Allowed roles` -> `Error Codes`.
- Script-generated API docs are forbidden. Documentation updates must be manual edits only.
- Android workflow note: user always runs Shift+F10 in Android Studio to deploy updates.

3208
docs/API_REFERENCE.txt Normal file

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 jakarta.annotation.PreDestroy
import org.springframework.stereotype.Component 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 com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
@@ -13,17 +13,34 @@ class LlamaClient(
private val restTemplate: RestTemplate, private val restTemplate: RestTemplate,
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
@Value("\${ai.llama.baseUrl}") @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 = private val systemPrompt =
"Look only at visible text. " + "Read extremely carefully. Look only at visible text. " +
"Return the exact text you can read verbatim. " + "Return the exact text you can read verbatim. " +
"If the text is unclear, partial, or inferred, return NOT CLEARLY VISIBLE. " + "If the text is unclear, partial, or inferred, return NOT CLEARLY VISIBLE. " +
"Do not guess. Do not explain." "Do not guess. Do not explain."
fun ask(imageUrl: String, question: String): String { fun ask(imageUrl: String, question: String): String {
val payload = mapOf( val payload = mapOf(
"model" to "qwen", "model" to model,
"temperature" to temperature,
"top_p" to topP,
"min_p" to minP,
"repeat_penalty" to repeatPenalty,
"top_k" to topK,
"messages" to listOf( "messages" to listOf(
mapOf( mapOf(
"role" to "system", "role" to "system",
@@ -41,9 +58,42 @@ class LlamaClient(
return post(payload) return post(payload)
} }
fun askWithOcr(imageUrl: String, ocrText: String, question: String): String {
val payload = mapOf(
"model" to model,
"temperature" to temperature,
"top_p" to topP,
"min_p" to minP,
"repeat_penalty" to repeatPenalty,
"top_k" to topK,
"messages" to listOf(
mapOf(
"role" to "system",
"content" to systemPrompt
),
mapOf(
"role" to "user",
"content" to listOf(
mapOf(
"type" to "text",
"text" to "${question}\n\nOCR:\n${ocrText}"
),
mapOf("type" to "image_url", "image_url" to mapOf("url" to imageUrl))
)
)
)
)
return post(payload)
}
fun askText(content: String, question: String): String { fun askText(content: String, question: String): String {
val payload = mapOf( val payload = mapOf(
"model" to "qwen", "model" to model,
"temperature" to temperature,
"top_p" to topP,
"min_p" to minP,
"repeat_penalty" to repeatPenalty,
"top_k" to topK,
"messages" to listOf( "messages" to listOf(
mapOf( mapOf(
"role" to "system", "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.property.AppUserRepo
import com.android.trisolarisserver.repo.PropertyUserRepo import com.android.trisolarisserver.repo.property.PropertyUserRepo
import com.android.trisolarisserver.models.property.Role import com.android.trisolarisserver.models.property.Role
import org.springframework.security.access.AccessDeniedException import org.springframework.security.access.AccessDeniedException
import org.springframework.stereotype.Component import org.springframework.stereotype.Component

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.beans.factory.annotation.Value
import org.springframework.stereotype.Component 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.beans.factory.annotation.Value
import org.springframework.stereotype.Component 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.PDDocument
import org.apache.pdfbox.pdmodel.PDPage 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.Files
import java.nio.file.Path 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.beans.factory.annotation.Value
import org.springframework.stereotype.Component 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.beans.factory.annotation.Value
import org.springframework.stereotype.Component 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 jakarta.servlet.http.HttpServletRequest
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.security.access.AccessDeniedException import org.springframework.security.access.AccessDeniedException
import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ExceptionHandler
@@ -18,7 +19,9 @@ class ApiExceptionHandler {
request: HttpServletRequest request: HttpServletRequest
): ResponseEntity<ApiError> { ): ResponseEntity<ApiError> {
val status = ex.statusCode as HttpStatus val status = ex.statusCode as HttpStatus
return ResponseEntity.status(status).body( return ResponseEntity.status(status)
.contentType(MediaType.APPLICATION_JSON)
.body(
ApiError( ApiError(
timestamp = OffsetDateTime.now().toString(), timestamp = OffsetDateTime.now().toString(),
status = status.value(), status = status.value(),
@@ -34,7 +37,9 @@ class ApiExceptionHandler {
ex: AccessDeniedException, ex: AccessDeniedException,
request: HttpServletRequest request: HttpServletRequest
): ResponseEntity<ApiError> { ): ResponseEntity<ApiError> {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body( return ResponseEntity.status(HttpStatus.FORBIDDEN)
.contentType(MediaType.APPLICATION_JSON)
.body(
ApiError( ApiError(
timestamp = OffsetDateTime.now().toString(), timestamp = OffsetDateTime.now().toString(),
status = HttpStatus.FORBIDDEN.value(), status = HttpStatus.FORBIDDEN.value(),
@@ -50,7 +55,9 @@ class ApiExceptionHandler {
ex: Exception, ex: Exception,
request: HttpServletRequest request: HttpServletRequest
): ResponseEntity<ApiError> { ): ResponseEntity<ApiError> {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.contentType(MediaType.APPLICATION_JSON)
.body(
ApiError( ApiError(
timestamp = OffsetDateTime.now().toString(), timestamp = OffsetDateTime.now().toString(),
status = HttpStatus.INTERNAL_SERVER_ERROR.value(), status = HttpStatus.INTERNAL_SERVER_ERROR.value(),

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.Bean
import org.springframework.context.annotation.Configuration 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.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 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,433 +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.property.Role
import com.android.trisolarisserver.repo.AppUserRepo
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 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["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(
"pinCode" to "POSTAL ADDRESS PIN CODE (6 digit)? Reply only pin or NONE.",
"address" to "POSTAL ADDRESS ONLY (street/area/city/state). Ignore IDs, UUIDs, and codes. Reply only address or NONE."
)
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(
"name" to "NAME? Reply only the name or NONE.",
"dob" to "DOB? Reply only date or NONE.",
"idNumber" to "ID NUMBER? Reply only number or NONE.",
"gender" to "GENDER? Reply only MALE/FEMALE/OTHER or NONE."
)
for ((key, question) in aadharFrontQuestions) {
results[key] = llamaClient.ask(imageUrl, question)
}
}
} else {
val detectionQuestions = linkedMapOf(
"hasDrivingLicence" to "CONTAINS DRIVING LICENCE? Answer YES or NO only.",
"hasTransportDept" to "CONTAINS TRANSPORT DEPARTMENT? Answer YES or NO only.",
"hasElectionCommission" to "CONTAINS ELECTION COMMISSION OF INDIA? Answer YES or NO only.",
"hasIncomeTaxDept" to "CONTAINS INCOME TAX DEPARTMENT? Answer YES or NO only.",
"hasPassport" to "CONTAINS PASSPORT? Answer YES or NO only."
)
for ((key, question) in detectionQuestions) {
results[key] = llamaClient.ask(imageUrl, question)
}
val isDriving = isYes(results["hasDrivingLicence"]) || isYes(results["hasTransportDept"])
if (isDriving) {
val drivingQuestions = linkedMapOf(
"name" to "NAME? Reply only the name or NONE.",
"dob" to "DOB? Reply only date or NONE.",
"idNumber" to "DL NUMBER? Reply only number or NONE.",
"address" to "POSTAL ADDRESS ONLY (street/area/city/state). Ignore IDs, UUIDs, and codes. Reply only address or NONE.",
"pinCode" to "PIN CODE? Reply only pin or NONE.",
"city" to "CITY? Reply only city or NONE.",
"gender" to "GENDER? Reply only MALE/FEMALE/OTHER or NONE.",
"nationality" to "NATIONALITY? Reply only nationality or NONE."
)
for ((key, question) in drivingQuestions) {
results[key] = llamaClient.ask(imageUrl, question)
}
} else if (isYes(results["hasElectionCommission"])) {
val voterQuestions = linkedMapOf(
"name" to "NAME? Reply only the name or NONE.",
"dob" to "DOB? Reply only date or NONE.",
"idNumber" to "VOTER ID NUMBER? Reply only number or NONE.",
"address" to "POSTAL ADDRESS ONLY (street/area/city/state). Ignore IDs, UUIDs, and codes. Reply only address or NONE.",
"pinCode" to "PIN CODE? Reply only pin or NONE.",
"city" to "CITY? Reply only city or NONE.",
"gender" to "GENDER? Reply only MALE/FEMALE/OTHER or NONE.",
"nationality" to "NATIONALITY? Reply only nationality or NONE."
)
for ((key, question) in voterQuestions) {
results[key] = llamaClient.ask(imageUrl, question)
}
} else if (isYes(results["hasIncomeTaxDept"])) {
val panQuestions = linkedMapOf(
"name" to "NAME? Reply only the name or NONE.",
"dob" to "DOB? Reply only date or NONE.",
"idNumber" to "PAN NUMBER? Reply only number or NONE.",
"address" to "POSTAL ADDRESS ONLY (street/area/city/state). Ignore IDs, UUIDs, and codes. Reply only address or NONE.",
"pinCode" to "PIN CODE? Reply only pin or NONE.",
"city" to "CITY? Reply only city or NONE.",
"gender" to "GENDER? Reply only MALE/FEMALE/OTHER or NONE.",
"nationality" to "NATIONALITY? Reply only nationality or NONE."
)
for ((key, question) in panQuestions) {
results[key] = llamaClient.ask(imageUrl, question)
}
} else if (isYes(results["hasPassport"])) {
val passportQuestions = linkedMapOf(
"name" to "NAME? Reply only the name or NONE.",
"dob" to "DOB? Reply only date or NONE.",
"idNumber" to "PASSPORT NUMBER? Reply only number or NONE.",
"address" to "POSTAL ADDRESS ONLY (street/area/city/state). Ignore IDs, UUIDs, and codes. Reply only address or NONE.",
"pinCode" to "PIN CODE? Reply only pin or NONE.",
"city" to "CITY? Reply only city or NONE.",
"gender" to "GENDER? Reply only MALE/FEMALE/OTHER or NONE.",
"nationality" to "NATIONALITY? Reply only nationality or NONE."
)
for ((key, question) in passportQuestions) {
results[key] = llamaClient.ask(imageUrl, question)
}
} else {
val generalQuestions = linkedMapOf(
"name" to "NAME? Reply only the name or NONE.",
"dob" to "DOB? Reply only date or NONE.",
"idNumber" to "ID NUMBER? Reply only number or NONE.",
"address" to "POSTAL ADDRESS ONLY (street/area/city/state). Ignore IDs, UUIDs, and codes. Reply only address or NONE.",
"vehicleNumber" to "VEHICLE NUMBER? Reply only number or NONE.",
"pinCode" to "PIN CODE? Reply only pin or NONE.",
"city" to "CITY? Reply only city or NONE.",
"gender" to "GENDER? Reply only MALE/FEMALE/OTHER or NONE.",
"nationality" to "NATIONALITY? Reply only nationality or NONE."
)
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)
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)
}

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.beans.factory.annotation.Value
import org.springframework.core.io.FileSystemResource 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.property.PropertyUserResponse
import com.android.trisolarisserver.controller.dto.UserResponse import com.android.trisolarisserver.controller.dto.property.UserResponse
import com.android.trisolarisserver.repo.AppUserRepo import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.PropertyUserRepo import com.android.trisolarisserver.repo.property.PropertyUserRepo
import com.android.trisolarisserver.security.MyPrincipal import com.android.trisolarisserver.security.MyPrincipal
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory

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.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.BookingBalanceResponse import com.android.trisolarisserver.controller.dto.payment.BookingBalanceResponse
import com.android.trisolarisserver.db.repo.BookingRepo import com.android.trisolarisserver.repo.booking.BookingRepo
import com.android.trisolarisserver.repo.PaymentRepo import com.android.trisolarisserver.repo.booking.ChargeRepo
import com.android.trisolarisserver.repo.RoomStayRepo import com.android.trisolarisserver.repo.booking.PaymentRepo
import com.android.trisolarisserver.repo.room.RoomStayRepo
import com.android.trisolarisserver.security.MyPrincipal import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
import java.time.LocalDate
import java.util.UUID import java.util.UUID
@RestController @RestController
@@ -22,10 +25,12 @@ class BookingBalances(
private val propertyAccess: PropertyAccess, private val propertyAccess: PropertyAccess,
private val bookingRepo: BookingRepo, private val bookingRepo: BookingRepo,
private val roomStayRepo: RoomStayRepo, private val roomStayRepo: RoomStayRepo,
private val chargeRepo: ChargeRepo,
private val paymentRepo: PaymentRepo private val paymentRepo: PaymentRepo
) { ) {
@GetMapping @GetMapping
@Transactional(readOnly = true)
fun getBalance( fun getBalance(
@PathVariable propertyId: UUID, @PathVariable propertyId: UUID,
@PathVariable bookingId: UUID, @PathVariable bookingId: UUID,
@@ -38,35 +43,20 @@ class BookingBalances(
if (booking.property.id != propertyId) { if (booking.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property") throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
} }
val expected = computeExpectedPay(bookingId, booking.property.timezone) val expected = computeExpectedPay(
roomStayRepo.findByBookingId(bookingId),
booking.property.timezone,
booking.billingMode,
booking.billingCheckinTime,
booking.billingCheckoutTime
)
val charges = chargeRepo.sumAmountByBookingId(bookingId)
val collected = paymentRepo.sumAmountByBookingId(bookingId) val collected = paymentRepo.sumAmountByBookingId(bookingId)
val pending = expected - collected val pending = expected + charges - collected
return BookingBalanceResponse( return BookingBalanceResponse(
expectedPay = expected, expectedPay = expected + charges,
amountCollected = collected, amountCollected = collected,
pending = pending pending = pending
) )
} }
private fun computeExpectedPay(bookingId: UUID, timezone: String?): Long {
val stays = roomStayRepo.findByBookingId(bookingId)
if (stays.isEmpty()) return 0
val now = nowForProperty(timezone)
var total = 0L
stays.forEach { stay ->
val rate = stay.nightlyRate ?: 0L
if (rate == 0L) return@forEach
val start = stay.fromAt.toLocalDate()
val endAt = stay.toAt ?: now
val end = endAt.toLocalDate()
val nights = daysBetweenInclusive(start, end)
total += rate * nights
}
return total
}
private fun daysBetweenInclusive(start: LocalDate, end: LocalDate): Long {
val diff = end.toEpochDay() - start.toEpochDay()
return if (diff <= 0) 1L else diff
}
} }

File diff suppressed because it is too large Load Diff

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

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

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.AppUser
import com.android.trisolarisserver.models.property.Role import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.AppUserRepo import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.security.MyPrincipal import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
@@ -40,3 +45,11 @@ internal fun requireRole(
propertyAccess.requireAnyRole(propertyId, resolved.userId, *roles) propertyAccess.requireAnyRole(propertyId, resolved.userId, *roles)
return resolved return resolved
} }
internal fun requireSuperAdmin(appUserRepo: AppUserRepo, principal: MyPrincipal?): AppUser {
val user = requireUser(appUserRepo, principal)
if (!user.superAdmin) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Super admin only")
}
return user
}

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

@@ -0,0 +1,13 @@
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 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 "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."
val VEHICLE_NUMBER = "vehicleNumber" to "VEHICLE NUMBER? Reply only number 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 import java.util.UUID
data class BookingCheckInRequest( @JsonIgnoreProperties(ignoreUnknown = false)
val roomIds: List<UUID>,
val checkInAt: String? = null,
val transportMode: String? = null,
val nightlyRate: Long? = null,
val rateSource: String? = null,
val ratePlanCode: String? = null,
val currency: String? = null,
val notes: String? = null
)
data class BookingCheckInStayRequest( data class BookingCheckInStayRequest(
val roomId: UUID, val roomId: UUID,
val checkInAt: String? = null, val checkInAt: String? = null,
val checkOutAt: String? = null,
val nightlyRate: Long? = null, val nightlyRate: Long? = null,
val rateSource: String? = null, val rateSource: String? = null,
val ratePlanCode: String? = null, val ratePlanCode: String? = null,
val currency: String? = null val currency: String? = null
) )
@JsonIgnoreProperties(ignoreUnknown = false)
data class BookingBulkCheckInRequest( data class BookingBulkCheckInRequest(
val stays: List<BookingCheckInStayRequest>, val stays: List<BookingCheckInStayRequest>,
val transportMode: String? = null, val transportMode: String? = null,
@@ -33,6 +24,8 @@ data class BookingCreateRequest(
val source: String? = null, val source: String? = null,
val expectedCheckInAt: String, val expectedCheckInAt: String,
val expectedCheckOutAt: String, val expectedCheckOutAt: String,
val billingMode: String? = null,
val billingCheckoutTime: String? = null,
val guestPhoneE164: String? = null, val guestPhoneE164: String? = null,
val fromCity: String? = null, val fromCity: String? = null,
val toCity: String? = null, val toCity: String? = null,
@@ -48,6 +41,9 @@ data class BookingCreateRequest(
data class BookingCreateResponse( data class BookingCreateResponse(
val id: UUID, val id: UUID,
val status: String, val status: String,
val billingMode: String,
val billingCheckinTime: String,
val billingCheckoutTime: String,
val guestId: UUID?, val guestId: UUID?,
val checkInAt: String?, val checkInAt: String?,
val expectedCheckInAt: String?, val expectedCheckInAt: String?,
@@ -60,8 +56,12 @@ data class BookingListItem(
val guestId: UUID?, val guestId: UUID?,
val guestName: String?, val guestName: String?,
val guestPhone: String?, val guestPhone: String?,
val vehicleNumbers: List<String>,
val roomNumbers: List<Int>, val roomNumbers: List<Int>,
val source: String?, val source: String?,
val billingMode: String,
val billingCheckinTime: String,
val billingCheckoutTime: String,
val expectedCheckInAt: String?, val expectedCheckInAt: String?,
val expectedCheckOutAt: String?, val expectedCheckOutAt: String?,
val checkInAt: String?, val checkInAt: String?,
@@ -84,9 +84,14 @@ data class BookingDetailResponse(
val guestPhone: String?, val guestPhone: String?,
val guestNationality: String?, val guestNationality: String?,
val guestAddressText: String?, val guestAddressText: String?,
val guestAge: String?,
val guestSignatureUrl: String?, val guestSignatureUrl: String?,
val vehicleNumbers: List<String>,
val roomNumbers: List<Int>, val roomNumbers: List<Int>,
val source: String?, val source: String?,
val billingMode: String,
val billingCheckinTime: String,
val billingCheckoutTime: String,
val fromCity: String?, val fromCity: String?,
val toCity: String?, val toCity: String?,
val memberRelation: String?, val memberRelation: String?,
@@ -105,6 +110,7 @@ data class BookingDetailResponse(
val registeredByName: String?, val registeredByName: String?,
val registeredByPhone: String?, val registeredByPhone: String?,
val totalNightlyRate: Long, val totalNightlyRate: Long,
val billableNights: Long?,
val expectedPay: Long, val expectedPay: Long,
val amountCollected: Long, val amountCollected: Long,
val pending: Long val pending: Long
@@ -119,11 +125,43 @@ data class BookingExpectedDatesUpdateRequest(
val expectedCheckOutAt: String? = null val expectedCheckOutAt: String? = null
) )
data class BookingBillingPolicyUpdateRequest(
val billingMode: String,
val billingCheckoutTime: String? = null
)
data class BookingCheckOutRequest( data class BookingCheckOutRequest(
val checkOutAt: String? = null, val checkOutAt: String? = null,
val notes: String? = null val notes: String? = null
) )
data class BookingBillableNightsRequest(
val expectedCheckInAt: String? = null,
val expectedCheckOutAt: String? = null
)
data class BookingBillableNightsResponse(
val bookingId: UUID,
val status: String,
val billableNights: Long
)
data class BookingExpectedCheckoutPreviewRequest(
val checkInAt: String,
val billableNights: Long? = null,
val billingMode: String? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null
)
data class BookingExpectedCheckoutPreviewResponse(
val expectedCheckOutAt: String,
val billableNights: Long,
val billingMode: String,
val billingCheckinTime: String,
val billingCheckoutTime: String
)
data class BookingCancelRequest( data class BookingCancelRequest(
val cancelledAt: String? = null, val cancelledAt: String? = null,
val reason: String? = null val reason: String? = null
@@ -134,29 +172,8 @@ data class BookingNoShowRequest(
val reason: String? = null val reason: String? = null
) )
data class RoomChangeRequest( data class RoomStayVoidRequest(
val newRoomId: UUID, val reason: String? = null
val movedAt: String? = null,
val idempotencyKey: String
)
data class RoomChangeResponse(
val oldRoomStayId: UUID,
val newRoomStayId: UUID,
val oldRoomId: UUID,
val newRoomId: UUID,
val movedAt: String
)
data class RoomStayPreAssignRequest(
val roomId: UUID,
val fromAt: String,
val toAt: String,
val nightlyRate: Long? = null,
val rateSource: String? = null,
val ratePlanCode: String? = null,
val currency: String? = null,
val notes: String? = null
) )
data class IssueCardRequest( data class IssueCardRequest(

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 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.time.OffsetDateTime
import java.util.UUID 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 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 import java.util.UUID
data class PropertyCreateRequest( data class PropertyCreateRequest(
val code: String,
val name: String, val name: String,
val addressText: String? = null, val addressText: String? = null,
val timezone: String? = null, val timezone: String? = null,
val currency: String? = null, val currency: String? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null,
val active: Boolean? = null, val active: Boolean? = null,
val otaAliases: Set<String>? = null, val otaAliases: Set<String>? = null,
val emailAddresses: Set<String>? = null, val emailAddresses: Set<String>? = null,
@@ -20,6 +21,8 @@ data class PropertyUpdateRequest(
val addressText: String? = null, val addressText: String? = null,
val timezone: String? = null, val timezone: String? = null,
val currency: String? = null, val currency: String? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null,
val active: Boolean? = null, val active: Boolean? = null,
val otaAliases: Set<String>? = null, val otaAliases: Set<String>? = null,
val emailAddresses: Set<String>? = null, val emailAddresses: Set<String>? = null,
@@ -33,16 +36,34 @@ data class PropertyResponse(
val addressText: String?, val addressText: String?,
val timezone: String, val timezone: String,
val currency: String, val currency: String,
val billingCheckinTime: String,
val billingCheckoutTime: String,
val active: Boolean, val active: Boolean,
val otaAliases: Set<String>, val otaAliases: Set<String>,
val emailAddresses: Set<String>, val emailAddresses: Set<String>,
val allowedTransportModes: Set<String> val allowedTransportModes: Set<String>
) )
data class PropertyCodeResponse(
val code: String
)
data class PropertyBillingPolicyRequest(
val billingCheckinTime: String,
val billingCheckoutTime: String
)
data class PropertyBillingPolicyResponse(
val propertyId: UUID,
val billingCheckinTime: String,
val billingCheckoutTime: String
)
data class GuestResponse( data class GuestResponse(
val id: UUID, val id: UUID,
val name: String?, val name: String?,
val phoneE164: String?, val phoneE164: String?,
val dob: String?,
val nationality: String?, val nationality: String?,
val addressText: String?, val addressText: String?,
val signatureUrl: String?, val signatureUrl: String?,
@@ -85,6 +106,10 @@ data class PropertyUserRoleRequest(
val roles: Set<String> val roles: Set<String>
) )
data class PropertyUserDisableRequest(
val disabled: Boolean
)
data class PropertyUserResponse( data class PropertyUserResponse(
val userId: UUID, val userId: UUID,
val propertyId: UUID, val propertyId: UUID,

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.time.LocalDate
import java.util.UUID 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 import java.util.UUID
@@ -27,16 +27,10 @@ data class RoomAvailabilityResponse(
) )
data class RoomAvailabilityRangeResponse( data class RoomAvailabilityRangeResponse(
val roomTypeName: String,
val freeRoomNumbers: List<Int>,
val freeCount: Int
)
data class RoomAvailabilityWithRateResponse(
val roomId: UUID,
val roomNumber: Int,
val roomTypeCode: String, val roomTypeCode: String,
val roomTypeName: String, val roomTypeName: String,
val freeRoomNumbers: List<Int>,
val freeCount: Int,
val averageRate: Double?, val averageRate: Double?,
val currency: String, val currency: String,
val ratePlanCode: String? = null val ratePlanCode: String? = null

View File

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

View File

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

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

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

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

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

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

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

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.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.PropertyCreateRequest import com.android.trisolarisserver.controller.dto.property.PropertyCodeResponse
import com.android.trisolarisserver.controller.dto.PropertyResponse import com.android.trisolarisserver.controller.dto.property.PropertyBillingPolicyRequest
import com.android.trisolarisserver.controller.dto.PropertyUpdateRequest import com.android.trisolarisserver.controller.dto.property.PropertyBillingPolicyResponse
import com.android.trisolarisserver.controller.dto.PropertyUserResponse import com.android.trisolarisserver.controller.dto.property.PropertyCreateRequest
import com.android.trisolarisserver.controller.dto.PropertyUserRoleRequest import com.android.trisolarisserver.controller.dto.property.PropertyResponse
import com.android.trisolarisserver.repo.AppUserRepo import com.android.trisolarisserver.controller.dto.property.PropertyUserDisableRequest
import com.android.trisolarisserver.repo.PropertyRepo import com.android.trisolarisserver.controller.dto.property.PropertyUpdateRequest
import com.android.trisolarisserver.repo.PropertyUserRepo import com.android.trisolarisserver.controller.dto.property.PropertyUserResponse
import com.android.trisolarisserver.controller.dto.property.PropertyUserRoleRequest
import com.android.trisolarisserver.repo.property.AppUserRepo
import com.android.trisolarisserver.repo.property.PropertyRepo
import com.android.trisolarisserver.repo.property.PropertyUserRepo
import com.android.trisolarisserver.models.property.Property import com.android.trisolarisserver.models.property.Property
import com.android.trisolarisserver.models.property.PropertyUser import com.android.trisolarisserver.models.property.PropertyUser
import com.android.trisolarisserver.models.property.PropertyUserId import com.android.trisolarisserver.models.property.PropertyUserId
@@ -26,6 +33,9 @@ import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.util.UUID import java.util.UUID
@RestController @RestController
@@ -35,6 +45,8 @@ class Properties(
private val propertyUserRepo: PropertyUserRepo, private val propertyUserRepo: PropertyUserRepo,
private val appUserRepo: AppUserRepo private val appUserRepo: AppUserRepo
) { ) {
private val codeRandom = java.security.SecureRandom()
private val codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
@PostMapping("/properties") @PostMapping("/properties")
@ResponseStatus(HttpStatus.CREATED) @ResponseStatus(HttpStatus.CREATED)
@@ -42,17 +54,17 @@ class Properties(
@AuthenticationPrincipal principal: MyPrincipal?, @AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: PropertyCreateRequest @RequestBody request: PropertyCreateRequest
): PropertyResponse { ): PropertyResponse {
val user = requireUser(principal) val user = requireUser(appUserRepo, principal)
if (propertyRepo.existsByCode(request.code)) { val code = generatePropertyCode()
throw ResponseStatusException(HttpStatus.CONFLICT, "Property code already exists")
}
val property = Property( val property = Property(
code = request.code, code = code,
name = request.name, name = request.name,
addressText = request.addressText, addressText = request.addressText,
timezone = request.timezone ?: "Asia/Kolkata", timezone = request.timezone ?: "Asia/Kolkata",
currency = request.currency ?: "INR", currency = request.currency ?: "INR",
billingCheckinTime = validateBillingTime(request.billingCheckinTime, "billingCheckinTime", "12:00"),
billingCheckoutTime = validateBillingTime(request.billingCheckoutTime, "billingCheckoutTime", "11:00"),
active = request.active ?: true, active = request.active ?: true,
otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf(), otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf(),
emailAddresses = request.emailAddresses?.toMutableSet() ?: mutableSetOf(), emailAddresses = request.emailAddresses?.toMutableSet() ?: mutableSetOf(),
@@ -80,7 +92,7 @@ class Properties(
fun listProperties( fun listProperties(
@AuthenticationPrincipal principal: MyPrincipal? @AuthenticationPrincipal principal: MyPrincipal?
): List<PropertyResponse> { ): List<PropertyResponse> {
val user = requireUser(principal) val user = requireUser(appUserRepo, principal)
return if (user.superAdmin) { return if (user.superAdmin) {
propertyRepo.findAll().map { it.toResponse() } propertyRepo.findAll().map { it.toResponse() }
} else { } else {
@@ -89,6 +101,66 @@ class Properties(
} }
} }
@GetMapping("/properties/{propertyId}/code")
fun getPropertyCode(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): PropertyCodeResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
return PropertyCodeResponse(code = property.code)
}
@GetMapping("/properties/{propertyId}/billing-policy")
fun getBillingPolicy(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): PropertyBillingPolicyResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
return PropertyBillingPolicyResponse(
propertyId = property.id!!,
billingCheckinTime = property.billingCheckinTime,
billingCheckoutTime = property.billingCheckoutTime
)
}
@PutMapping("/properties/{propertyId}/billing-policy")
fun updateBillingPolicy(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: PropertyBillingPolicyRequest
): PropertyBillingPolicyResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN)
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
property.billingCheckinTime = validateBillingTime(
request.billingCheckinTime,
"billingCheckinTime",
property.billingCheckinTime
)
property.billingCheckoutTime = validateBillingTime(
request.billingCheckoutTime,
"billingCheckoutTime",
property.billingCheckoutTime
)
val saved = propertyRepo.save(property)
return PropertyBillingPolicyResponse(
propertyId = saved.id!!,
billingCheckinTime = saved.billingCheckinTime,
billingCheckoutTime = saved.billingCheckoutTime
)
}
@GetMapping("/properties/{propertyId}/users") @GetMapping("/properties/{propertyId}/users")
fun listPropertyUsers( fun listPropertyUsers(
@PathVariable propertyId: UUID, @PathVariable propertyId: UUID,
@@ -97,8 +169,14 @@ class Properties(
requirePrincipal(principal) requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId) propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER) propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
val users = propertyUserRepo.findByIdPropertyId(propertyId) val actorUser = appUserRepo.findById(principal.userId).orElse(null)
return users.map { val actorRoles = propertyUserRepo.findRolesByPropertyAndUser(propertyId, principal.userId)
val actorRank = rankForUser(actorUser?.superAdmin == true, actorRoles)
val users = propertyUserRepo.findByPropertyIdWithUser(propertyId)
return users
.filter { it.id.userId != principal.userId }
.filter { actorRank >= rankForUser(it.user.superAdmin, it.roles) }
.map {
PropertyUserResponse( PropertyUserResponse(
userId = it.id.userId!!, userId = it.id.userId!!,
propertyId = it.id.propertyId!!, propertyId = it.id.propertyId!!,
@@ -158,6 +236,55 @@ class Properties(
) )
} }
@PutMapping("/properties/{propertyId}/users/{userId}/disabled")
fun updatePropertyUserDisabled(
@PathVariable propertyId: UUID,
@PathVariable userId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: PropertyUserDisableRequest
): PropertyUserResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val actorUser = appUserRepo.findById(principal.userId).orElse(null)
val actorRoles = propertyUserRepo.findRolesByPropertyAndUser(propertyId, principal.userId)
val targetId = PropertyUserId(propertyId = propertyId, userId = userId)
val target = propertyUserRepo.findById(targetId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "User not found in property")
}
val targetRoles = target.roles
if (actorUser?.superAdmin != true) {
val canAdminManage = actorRoles.contains(Role.ADMIN)
val canManagerManage = actorRoles.contains(Role.MANAGER)
val allowedForManager = setOf(
Role.STAFF,
Role.AGENT,
Role.HOUSEKEEPING,
Role.FINANCE,
Role.GUIDE,
Role.SUPERVISOR
)
val allowed = when {
canAdminManage -> !targetRoles.contains(Role.ADMIN)
canManagerManage -> targetRoles.all { allowedForManager.contains(it) }
else -> false
}
if (!allowed) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Role not allowed")
}
}
target.disabled = request.disabled
val saved = propertyUserRepo.save(target)
return PropertyUserResponse(
userId = saved.id.userId!!,
propertyId = saved.id.propertyId!!,
roles = saved.roles.map { it.name }.toSet()
)
}
@DeleteMapping("/properties/{propertyId}/users/{userId}") @DeleteMapping("/properties/{propertyId}/users/{userId}")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
fun deletePropertyUser( fun deletePropertyUser(
@@ -197,6 +324,16 @@ class Properties(
property.addressText = request.addressText ?: property.addressText property.addressText = request.addressText ?: property.addressText
property.timezone = request.timezone ?: property.timezone property.timezone = request.timezone ?: property.timezone
property.currency = request.currency ?: property.currency property.currency = request.currency ?: property.currency
property.billingCheckinTime = validateBillingTime(
request.billingCheckinTime,
"billingCheckinTime",
property.billingCheckinTime
)
property.billingCheckoutTime = validateBillingTime(
request.billingCheckoutTime,
"billingCheckoutTime",
property.billingCheckoutTime
)
property.active = request.active ?: property.active property.active = request.active ?: property.active
if (request.otaAliases != null) { if (request.otaAliases != null) {
property.otaAliases = request.otaAliases.toMutableSet() property.otaAliases = request.otaAliases.toMutableSet()
@@ -211,21 +348,6 @@ class Properties(
return propertyRepo.save(property).toResponse() return propertyRepo.save(property).toResponse()
} }
private fun requirePrincipal(principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
}
private fun requireUser(principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
return appUserRepo.findById(principal.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
}
}
private fun parseTransportModes(modes: Set<String>): MutableSet<TransportMode> { private fun parseTransportModes(modes: Set<String>): MutableSet<TransportMode> {
return try { return try {
modes.map { TransportMode.valueOf(it) }.toMutableSet() modes.map { TransportMode.valueOf(it) }.toMutableSet()
@@ -233,6 +355,47 @@ class Properties(
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown transport mode") throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown transport mode")
} }
} }
private fun validateBillingTime(value: String?, fieldName: String, fallback: String): String {
val candidate = value?.trim()?.takeIf { it.isNotEmpty() } ?: fallback
return try {
LocalTime.parse(candidate, BILLING_TIME_FORMATTER).format(BILLING_TIME_FORMATTER)
} catch (_: DateTimeParseException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$fieldName must be HH:mm")
}
}
private fun generatePropertyCode(): String {
repeat(10) {
val code = buildString(7) {
repeat(7) {
append(codeAlphabet[codeRandom.nextInt(codeAlphabet.length)])
}
}
if (!propertyRepo.existsByCode(code)) {
return code
}
}
throw ResponseStatusException(HttpStatus.CONFLICT, "Unable to generate property code")
}
private fun rankForUser(isSuperAdmin: Boolean, roles: Set<Role>): Int {
if (isSuperAdmin) return 500
return roles.maxOfOrNull { roleRank(it) } ?: 0
}
private fun roleRank(role: Role): Int {
return when (role) {
Role.ADMIN -> 400
Role.MANAGER -> 300
Role.STAFF, Role.HOUSEKEEPING, Role.FINANCE, Role.SUPERVISOR, Role.GUIDE -> 200
Role.AGENT -> 100
}
}
companion object {
private val BILLING_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
}
} }
private fun Property.toResponse(): PropertyResponse { private fun Property.toResponse(): PropertyResponse {
@@ -244,6 +407,8 @@ private fun Property.toResponse(): PropertyResponse {
addressText = addressText, addressText = addressText,
timezone = timezone, timezone = timezone,
currency = currency, currency = currency,
billingCheckinTime = billingCheckinTime,
billingCheckoutTime = billingCheckoutTime,
active = active, active = active,
otaAliases = otaAliases.toSet(), otaAliases = otaAliases.toSet(),
emailAddresses = emailAddresses.toSet(), emailAddresses = emailAddresses.toSet(),

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.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.RateCalendarResponse import com.android.trisolarisserver.controller.dto.rate.RateCalendarResponse
import com.android.trisolarisserver.controller.dto.RateCalendarAverageResponse import com.android.trisolarisserver.controller.dto.rate.RateCalendarAverageResponse
import com.android.trisolarisserver.controller.dto.RateCalendarRangeUpsertRequest import com.android.trisolarisserver.controller.dto.rate.RateCalendarRangeUpsertRequest
import com.android.trisolarisserver.controller.dto.RatePlanCreateRequest import com.android.trisolarisserver.controller.dto.rate.RatePlanCreateRequest
import com.android.trisolarisserver.controller.dto.RatePlanResponse import com.android.trisolarisserver.controller.dto.rate.RatePlanResponse
import com.android.trisolarisserver.controller.dto.RatePlanUpdateRequest import com.android.trisolarisserver.controller.dto.rate.RatePlanUpdateRequest
import com.android.trisolarisserver.repo.PropertyRepo import com.android.trisolarisserver.repo.property.PropertyRepo
import com.android.trisolarisserver.repo.RateCalendarRepo import com.android.trisolarisserver.repo.rate.RateCalendarRepo
import com.android.trisolarisserver.repo.RatePlanRepo import com.android.trisolarisserver.repo.rate.RatePlanRepo
import com.android.trisolarisserver.repo.RoomTypeRepo import com.android.trisolarisserver.repo.room.RoomTypeRepo
import com.android.trisolarisserver.models.property.Role import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.models.room.RateCalendar import com.android.trisolarisserver.models.room.RateCalendar
import com.android.trisolarisserver.models.room.RatePlan import com.android.trisolarisserver.models.room.RatePlan
@@ -132,8 +135,8 @@ class RatePlans(
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER) requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId) val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found") ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found")
val fromDate = parseDate(request.from) val fromDate = parseDate(request.from, "Invalid date")
val toDate = parseDate(request.to) val toDate = parseDate(request.to, "Invalid date")
if (toDate.isBefore(fromDate)) { if (toDate.isBefore(fromDate)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "to must be on/after from") throw ResponseStatusException(HttpStatus.BAD_REQUEST, "to must be on/after from")
} }
@@ -174,8 +177,8 @@ class RatePlans(
requireMember(propertyAccess, propertyId, principal) requireMember(propertyAccess, propertyId, principal)
val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId) val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found") ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found")
val fromDate = parseDate(from) val fromDate = parseDate(from, "Invalid date")
val toDate = parseDate(to) val toDate = parseDate(to, "Invalid date")
if (toDate.isBefore(fromDate)) { if (toDate.isBefore(fromDate)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "to must be on/after from") throw ResponseStatusException(HttpStatus.BAD_REQUEST, "to must be on/after from")
} }
@@ -209,20 +212,12 @@ class RatePlans(
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER) requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId) val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found") ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found")
val date = parseDate(rateDate) val date = parseDate(rateDate, "Invalid date")
val existing = rateCalendarRepo.findByRatePlanIdAndRateDate(plan.id!!, date) val existing = rateCalendarRepo.findByRatePlanIdAndRateDate(plan.id!!, date)
?: return ?: return
rateCalendarRepo.delete(existing) rateCalendarRepo.delete(existing)
} }
private fun parseDate(value: String): LocalDate {
return try {
LocalDate.parse(value.trim())
} catch (_: Exception) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date")
}
}
private fun datesBetween(from: LocalDate, to: LocalDate): Sequence<LocalDate> { private fun datesBetween(from: LocalDate, to: LocalDate): Sequence<LocalDate> {
return generateSequence(from) { current -> return generateSequence(from) { current ->
val next = current.plusDays(1) val next = current.plusDays(1)

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

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