From 44e11520c5584f973c64db249334b25d0882600c Mon Sep 17 00:00:00 2001 From: Iliyan Angelov Date: Thu, 20 Nov 2025 02:18:52 +0200 Subject: [PATCH] updates --- ...omotion_fields_to_bookings.cpython-312.pyc | Bin 0 -> 1941 bytes ..._add_paypal_payment_method.cpython-312.pyc | Bin 1501 -> 1501 bytes ...dd_is_proforma_to_invoices.cpython-312.pyc | Bin 0 -> 1071 bytes ...0742c1_add_promotion_fields_to_bookings.py | 34 + ...1a2b3c4d5e6_add_is_proforma_to_invoices.py | 27 + Backend/requirements.txt | 1 + .../__pycache__/booking.cpython-312.pyc | Bin 2546 -> 2756 bytes .../__pycache__/invoice.cpython-312.pyc | Bin 4860 -> 4914 bytes Backend/src/models/booking.py | 3 + Backend/src/models/invoice.py | 1 + .../booking_routes.cpython-312.pyc | Bin 41140 -> 71587 bytes .../contact_routes.cpython-312.pyc | Bin 7717 -> 8021 bytes .../payment_routes.cpython-312.pyc | Bin 32657 -> 47991 bytes .../promotion_routes.cpython-312.pyc | Bin 15239 -> 16322 bytes .../system_settings_routes.cpython-312.pyc | Bin 46640 -> 56646 bytes Backend/src/routes/booking_routes.py | 714 ++++++++++++++-- Backend/src/routes/contact_routes.py | 12 +- Backend/src/routes/payment_routes.py | 342 +++++++- Backend/src/routes/promotion_routes.py | 18 +- Backend/src/routes/system_settings_routes.py | 277 +++++- .../invoice_service.cpython-312.pyc | Bin 18997 -> 20008 bytes .../paypal_service.cpython-312.pyc | Bin 17160 -> 24052 bytes .../stripe_service.cpython-312.pyc | Bin 16630 -> 25620 bytes Backend/src/services/invoice_service.py | 52 +- Backend/src/services/paypal_service.py | 153 +++- Backend/src/services/stripe_service.py | 212 ++++- .../email_templates.cpython-312.pyc | Bin 29773 -> 34917 bytes Backend/src/utils/email_templates.py | 105 ++- Frontend/package-lock.json | 46 + Frontend/package.json | 4 +- Frontend/src/App.tsx | 2 +- Frontend/src/components/common/Recaptcha.tsx | 91 ++ .../payments/PayPalPaymentWrapper.tsx | 123 +-- Frontend/src/components/rooms/RatingStars.tsx | 6 +- .../src/components/rooms/ReviewSection.tsx | 139 +-- Frontend/src/pages/ContactPage.tsx | 35 + .../src/pages/admin/BookingManagementPage.tsx | 253 +++++- .../src/pages/admin/BusinessDashboardPage.tsx | 20 +- Frontend/src/pages/admin/CheckInPage.tsx | 169 +++- .../src/pages/admin/PaymentManagementPage.tsx | 16 + .../pages/admin/ReceptionDashboardPage.tsx | 455 ++++++++-- Frontend/src/pages/admin/SettingsPage.tsx | 222 ++++- Frontend/src/pages/auth/LoginPage.tsx | 36 + Frontend/src/pages/auth/RegisterPage.tsx | 36 + .../src/pages/customer/BookingDetailPage.tsx | 130 ++- Frontend/src/pages/customer/BookingPage.tsx | 794 +++++++++++++----- .../src/pages/customer/BookingSuccessPage.tsx | 41 +- .../src/pages/customer/DepositPaymentPage.tsx | 493 +++++++---- .../src/pages/customer/MyBookingsPage.tsx | 27 + .../src/pages/customer/PayPalCancelPage.tsx | 92 +- .../src/pages/customer/PayPalReturnPage.tsx | 80 +- .../src/pages/customer/RoomDetailPage.tsx | 222 ++--- Frontend/src/services/api/bookingService.ts | 10 + Frontend/src/services/api/paymentService.ts | 25 + .../src/services/api/systemSettingsService.ts | 99 +++ 55 files changed, 4741 insertions(+), 876 deletions(-) create mode 100644 Backend/alembic/versions/__pycache__/bd309b0742c1_add_promotion_fields_to_bookings.cpython-312.pyc create mode 100644 Backend/alembic/versions/__pycache__/f1a2b3c4d5e6_add_is_proforma_to_invoices.cpython-312.pyc create mode 100644 Backend/alembic/versions/bd309b0742c1_add_promotion_fields_to_bookings.py create mode 100644 Backend/alembic/versions/f1a2b3c4d5e6_add_is_proforma_to_invoices.py create mode 100644 Frontend/src/components/common/Recaptcha.tsx diff --git a/Backend/alembic/versions/__pycache__/bd309b0742c1_add_promotion_fields_to_bookings.cpython-312.pyc b/Backend/alembic/versions/__pycache__/bd309b0742c1_add_promotion_fields_to_bookings.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8d28a5a73402679979215ffdac843cf179c2517a GIT binary patch literal 1941 zcmb_d&2QX96!&<&UdP$pG$m5h0Lv2ffP+$dHy<=h#UWk3M7xy&q-YDWyq?*O8?U_? z+a-ZZA|Y{YrAiS3i8D(603s?i$4Wh5DO*$x6&JYVRus7O#LVt`*DaBv3P$pKZ{9q= zd2jstjqhhNGDpYHpXDqpaNO@~5)Hm*99^KsHV3&X2La@5(B!K;V0OYzR0SwhlQ3Bo zVWOI%j|3$sekD|gs7_HWZzPnVFTOM&)LYnXy1wN)dfh@c^mO0VYp%O$ISo&im(UH% zql@|X%cXn`&S(oY?Ty)@S?I1HuavJBjACuZoP~2}UcQKt;iG(+ic-F)73Zc4h3TS} z*NUaWd`Vk4H@h%De|}Du4_O}yAiUeEkaRpVNyjsqy?#529ZP~fN528qyia9!T#t?= za!?I{pNyv0#mE|H$j8>Dfj_cf0t!35yfC>d?y#Ndh! z;V%NhFVgy+B0nQ@hy0JIaT57s;OmiwzSJKO^ufpPCXOZau@}bRLU@Y&$Hyk0Bd<7! zrO;1R@F=Ynr>LN?lYItm-7zStMwp+G4YM0d5qe03*ll&;h0jnw8SXf`*u)booE;~A ziLDJm_j9KUhDkbSKJQ&B`e3*{xqbH5*>HU3;PmYN!~@|H`=XVt%FR;virN;m*i>r^ zh1gVmAZVSP6rP9}`2lt+$}makI&@84C#fEgdsTaDwqctqsJVg1SY;CK{cT8w)u<1q zHEcNMicVRMY>&tgwJ0q--E}&6#;lKL+I%8iXu2@4(OdX+y2|b`Z-$D;2>`&KyaEWr|X6E6kF)A{noAQw;f;#x?bP^Og9sL78KdLwY literal 0 HcmV?d00001 diff --git a/Backend/alembic/versions/__pycache__/d9aff6c5f0d4_add_paypal_payment_method.cpython-312.pyc b/Backend/alembic/versions/__pycache__/d9aff6c5f0d4_add_paypal_payment_method.cpython-312.pyc index c8a64c254e39bc5a0748a2a86418f76b7de4b439..a27ee628989700a71eb1deaa74c15b6e9a0c7ec0 100644 GIT binary patch delta 19 Zcmcc1eV3c-G%qg~0}${`-^g{D6#zBJ1poj5 delta 19 Zcmcc1eV3c-G%qg~0}%Y1wvp>HD*!uh1@Qm? diff --git a/Backend/alembic/versions/__pycache__/f1a2b3c4d5e6_add_is_proforma_to_invoices.cpython-312.pyc b/Backend/alembic/versions/__pycache__/f1a2b3c4d5e6_add_is_proforma_to_invoices.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a7a4de87df5fce5342d83693e03714a06c987808 GIT binary patch literal 1071 zcmah|y>HV%6u+|_$8npg1w;`;$%GW7)NcAA5mgLrRl(2BOC5t7!*#()+r1ckkWrXMfJ;b-<;+Ip^Jz z0QfGFRCpfCaFx4#fPe)MM3N6%k|jYA%f4(WNU<^~W2s2CvRpN!A@!+Z<+#ssulM9) zuHSVKvc1r5Q&K0i<=7Fiy`W1x7l*oj7k9nTBSGQLMx{_+aLTnscL^=yl~DzUl>)ly z)axtma@|Br`Z~oiQ_ot`Xv6CwK#fqU{MXnxut|M)1(sz~N3r+Qw}XVZX%p?RY`MSaaMR93aE- zajWLJ2JaevR)Ms0}TYEQN)?Rrpyw~{; z^ke^B|NWy+_zP-$oogN{9-ZM^&yXbmi^(#1@PD$9bOu_vO0)bN6~q*!5n2<7l6C?Y zofcKmlwRP5)z}Z2j&PfE5!xh(k)&dvbMu~P zt1W^$KE6e-@v8Wc!$oe6WeDLfNrlRvDFA1`f!SjXOkN#mN7HkI{L%C!Zt^n&eW*%s M8~)ZMI3K_0FZ`AU$N&HU literal 0 HcmV?d00001 diff --git a/Backend/alembic/versions/bd309b0742c1_add_promotion_fields_to_bookings.py b/Backend/alembic/versions/bd309b0742c1_add_promotion_fields_to_bookings.py new file mode 100644 index 00000000..3c6a31e4 --- /dev/null +++ b/Backend/alembic/versions/bd309b0742c1_add_promotion_fields_to_bookings.py @@ -0,0 +1,34 @@ +"""add_promotion_fields_to_bookings + +Revision ID: bd309b0742c1 +Revises: f1a2b3c4d5e6 +Create Date: 2025-11-20 02:16:09.496685 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'bd309b0742c1' +down_revision = 'f1a2b3c4d5e6' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add promotion-related columns to bookings table + op.add_column('bookings', sa.Column('original_price', sa.Numeric(10, 2), nullable=True)) + op.add_column('bookings', sa.Column('discount_amount', sa.Numeric(10, 2), nullable=True, server_default='0')) + op.add_column('bookings', sa.Column('promotion_code', sa.String(50), nullable=True)) + # Add index on promotion_code for faster lookups + op.create_index(op.f('ix_bookings_promotion_code'), 'bookings', ['promotion_code'], unique=False) + + +def downgrade() -> None: + # Remove promotion-related columns + op.drop_index(op.f('ix_bookings_promotion_code'), table_name='bookings') + op.drop_column('bookings', 'promotion_code') + op.drop_column('bookings', 'discount_amount') + op.drop_column('bookings', 'original_price') + diff --git a/Backend/alembic/versions/f1a2b3c4d5e6_add_is_proforma_to_invoices.py b/Backend/alembic/versions/f1a2b3c4d5e6_add_is_proforma_to_invoices.py new file mode 100644 index 00000000..b265142e --- /dev/null +++ b/Backend/alembic/versions/f1a2b3c4d5e6_add_is_proforma_to_invoices.py @@ -0,0 +1,27 @@ +"""add_is_proforma_to_invoices + +Revision ID: f1a2b3c4d5e6 +Revises: d9aff6c5f0d4 +Create Date: 2025-11-20 00:20:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f1a2b3c4d5e6' +down_revision = 'd9aff6c5f0d4' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add is_proforma column to invoices table + op.add_column('invoices', sa.Column('is_proforma', sa.Boolean(), nullable=False, server_default='0')) + + +def downgrade() -> None: + # Remove is_proforma column + op.drop_column('invoices', 'is_proforma') + diff --git a/Backend/requirements.txt b/Backend/requirements.txt index 48b60df4..7e719c73 100644 --- a/Backend/requirements.txt +++ b/Backend/requirements.txt @@ -20,6 +20,7 @@ stripe>=13.2.0 paypal-checkout-serversdk>=1.0.3 pyotp==2.9.0 qrcode[pil]==7.4.2 +httpx==0.25.2 # Enterprise features (optional but recommended) # redis==5.0.1 # Uncomment if using Redis caching diff --git a/Backend/src/models/__pycache__/booking.cpython-312.pyc b/Backend/src/models/__pycache__/booking.cpython-312.pyc index 821c4040f26ebdee914fa0dadd1a4ad36ff19bbb..e856c4bb64826df311f59380e33a203c4813066e 100644 GIT binary patch delta 557 zcmew)d_mH^47N~W-; zup*f$g;i7#drd{9XSy$NTERHmZ$roxQL6^On_@CE!xsdt z5S$&iA?1MM6*0F4|C>NnZVQZNduOjCP9Cy delta 381 zcmX>i`bn7YG%qg~0}vdyk%d!I;3EZ|oeuyO)<$;# diff --git a/Backend/src/models/__pycache__/invoice.cpython-312.pyc b/Backend/src/models/__pycache__/invoice.cpython-312.pyc index b443ac2b6d337bebe6563b7af2b367d989881b2a..150faad0fec54d233593c19a36c04f8a7090c208 100644 GIT binary patch delta 475 zcmeyPx=D@qG%qg~0}xcX$z^`t$UB{h@!002OrlIesd6c-DXeSwRx^X785pAECvRhs z7gbucR9ruuag79AwHZ{@07cXsEGn5Il_I@H25y2yrL3mhEq2emvi!{C z)LR^xo731G7#S@$FXv!p6e{8d2G}j`%;NZhqWrY{qTIyIt2hf785eDq=2m6oyD20w zLwtql4%I6{4h`O$9e7SLGbT^w7TCrZJNb}+DWlKk9|Efx8OtUw5weoAWK3Y}NcqA5 WqQ6uyN-(NTQ2ELLq>7}0mH+^fc5gHQ delta 433 zcmdm__D7ZXG%qg~0}vdyk>JwwAxNbV*8VR^+6R4;jil`}AR5C>>MS6`4+yt{qSxvcH?4EgL z`I*V7w>UC4*RVSIY#s diff --git a/Backend/src/models/booking.py b/Backend/src/models/booking.py index 5c725ec6..bfe7bc60 100644 --- a/Backend/src/models/booking.py +++ b/Backend/src/models/booking.py @@ -24,6 +24,9 @@ class Booking(Base): check_out_date = Column(DateTime, nullable=False) num_guests = Column(Integer, nullable=False, default=1) total_price = Column(Numeric(10, 2), nullable=False) + original_price = Column(Numeric(10, 2), nullable=True) # Price before discount + discount_amount = Column(Numeric(10, 2), nullable=True, default=0) # Discount amount applied + promotion_code = Column(String(50), nullable=True) # Promotion code used status = Column(Enum(BookingStatus), nullable=False, default=BookingStatus.pending) deposit_paid = Column(Boolean, nullable=False, default=False) requires_deposit = Column(Boolean, nullable=False, default=False) diff --git a/Backend/src/models/invoice.py b/Backend/src/models/invoice.py index 33ce7ac3..2311f2a8 100644 --- a/Backend/src/models/invoice.py +++ b/Backend/src/models/invoice.py @@ -37,6 +37,7 @@ class Invoice(Base): # Status status = Column(Enum(InvoiceStatus), nullable=False, default=InvoiceStatus.draft) + is_proforma = Column(Boolean, nullable=False, default=False) # True for proforma invoices # Company/Organization information (for admin to manage) company_name = Column(String(200), nullable=True) diff --git a/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc index d517e8116132489824235152547bf91c04306c2b..d585b0615df976c646be4d810e8fc4daf6a177ba 100644 GIT binary patch literal 71587 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 delta 15358 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 uJFP-uh)s^vxT$=St$>lT=g|S;i-DQLJySb%C- zvLq%au!yLou%@u(@aFQ>vNAH%ux80241}?OidiTBV^tJnPvKa@xta+g-o&WMHQ9j8 zijOxrKer$;uQEP0H!(A3aweM&WBTN2Z0hV#@yUnSwliu@u40#xWh+t!YQ80kq9eXI zwWK67FMaYXc0DdlpnNeKkWiR>kzFRn2FQ{E33)RzFf=fHU}KP#pYJi#V@cd)8I$WW z))!^0?{J7+=a9O{AvGiaB8Tp0W+qv-=WN`KsmixfbF vB8X4|62CZXa`RJ4b5iY!VkVc!CJ3uBvdv)szyPEcIDZCFAA%-7m$d@`r5lCo delta 408 zcmW+wO-lk%6rJbHd*fI$5-FO3X+br8xG4x!1k0j8D=LE8jKU0IIT_E7)dbNtTHT-zM9Cv()r>fkHa?lAjb-l+{%@`aqEW21L(*$!vJov}xQh<5o z2Q}IePsWS-4}~#=bSQ>W?pgM#duak%p~ImhTLv|B3Y72$&apk&J_xH~Cd9qRY@hOR8r}!QtADEAP_=AAcO>Z0FppF)iknD7D%G>=&Vu;)FrUH zd&X*S?`b&p9bk+vVyEXcY_nrL-7}4MeAa3|#@L>5SE{s=MKxa2v!3hq-Q79DFvlM3 z`}qIJjLfW33h1`i_s*P7P()N@#2=50jQsyE{`ljE@$s=5xW4ebbMznoQlt5=_=8>v zV&I|AsL@>1Fd9bd(Tr;cwBxz~-MD^0KW-Q>j2j1xPFb*75j(coH^v62=n-5=hwSNgPOIOiYZ&Hl8$)1aT(D?6Hq04D_Gj28|Rju#CSk>81);_;G!5)!s~O2^9v%Cs7t#;s+N82d@>&E&UrLLUs2 zGuxRIcP`^_TbNXL#R(IW_NsB9(p^6G9PKXIXVTx+3crAskugU8Vlr-KQo7`R7L#=| zJ0d2B$zq&0bKllWRCI2Kf{M-hUQ#LUr3d*%>P%eE&%4HFh%N3N@0cE$?0EHLP*H5Z* zg?qi!8z!bwL3smEKBXWj2AL}C1^7#4s+k)4d}uphbkx$gC#54jDL*Y8gDqUAsEe7Y zQ`BYS8m(MU%O&*I8VfpRgMzY&saH^LScCG$XQUk2e+>%Co7SMbDT1=x2U@1lZ6c%6 zz%(i7G_FBsa|E5W+uj_}_ALs^O>0oz`izt#+uov}ym<}E+ddKHRt4qeGsd#EpW|38 ziwRp=`?~f`PW#+!IHuh_<(^=?;rPz|`}=mB9(GUpMkgo2Ca-VEH|-4@_fEUnGvSzh z?iZ%rUSHVM=^kR-Y?(eB)9?0rF=_1B&N&}D-69bw8Pqwx-^Y$lx%=Jhi=)GCjDSk^4SB?fSxb#roHuNyA*h6DiglkDJsmdq zhE96JF=A<99dkTvV%KT+aK zr-{ZgX-M`@YrVj4_;P5>n3INNpD{8fI{(iZ;d!~_do;v)eFZcODGF&=_SeXlsvbX9 zEa#-52`v-DnEl3^mbb+*F=z6X(YtHVDW~^kpNUnk;g>aE(fLg`<0vok3UGlh!8rXR z{YCvB8jUbo&%l^#(7bLc(D*7Ty+RG!Fc-sEfn!XYGyBbB>*yWX_tnubea>?hzh$hR z#>zeu?~lQH&~#eO5vavn%-NVTTEU;u&S*~Sk7#DJ|3N>aElZgF(^wZ=oo>&Rn{}O- zo`4x{a>C`CbPW$p47)w9A(!}8x@Jb)6E5-Pb)A4&#Os=5U87zxrJJcHr1p++4bT@#Zqy?DfAFt1<%EX=suaiC*RVcp z>>yLUMQEJI&A>dHKqtA0>G9(*CC8q`G=mdE<8Bsb92N)3Y|3_FDs;7xeu1p6D%TBt zSm&Dz$9SiYkHNPgY#8y4dsv)&W(#&g4?!a6No-nV>q*O+TxD580r+OIRp5k8!;|CV zqrPwq%!|-PBjMEXNyhE*R(naa2kEzjg_m9vHo}JszF?*kqaOU==})=OgpDwn!^h0{ z;*bY!;yl9QAP(y$z2u|ho_H~Az)ug0Z!KGm_bex-JszRKS@_#X>KC@i-^>`#zyP#a0L%lD7T=L&Ezxu3ZC01ij z{QTL^oxPI7XVh{TwM#LzA#=h{9r?VYl5OY}N* z4+L*Tqv_He(1CYIH=%ook(0Xl*!ywzH=4iDd@o~bD7To;t><#~LteWJUP;%zQ<_pbyauJtY6w0i=KEv1S;A(aR zs&@v`J3@}!M=^%vgkNe5@d*#mSxMAb(^nE8^v{oCHCD$jGa^3`8_=fd0Yap%Z_PW~z0moL}+`^e#e!UWd8Ghq!gNN{Pt7E52`+j;`^Nt+N zcT==GvJBsK7%-fngB#z?(qcHrvNK2j-Qud9>H5D;HbVSgryC*WuXFSeeypt_uNZJ7 z#v#w>koV^(Z^AF#N%zljko}y&-#WGzys*yW4#(2bG0KGFMYczLzYJ4DC*5JAXB6L1 ztCEcy#(9EXhz7g@e-HnQnQ|!>;8WO*e&d)Z?b4*^%UlSZwZ{NOh`!`hW6H9`WPZZa z(EM|z3CFXRHD~f`*-EIN=_#c`TcR&t9F6g7Y1v9{1ZW$^C^kZqg3oAAHNfo90%fZ3 zIX;QYp)^%%CZ!rnQIHmc>fB6J-ArQ$07P99w-f`l!^tq}l6f7)6H(!Mq`FJIkxWQ# zp&Bnlb5b9AQO9U!VX*r3voJD%OE8V$L>qM}>Z8Ixjg>?tho7{qw4^bK8*S7wfmQYtHMQ-wQ2>^T(-ip%AM!w&TX|BNcVA4&OQ&1^HA` z(0T*kr}M2>q}@QnvhUkS!<18HQ-P{V%CSu}rR+~&dsNa!l-eYxry*Z64Mmiyl5)*b zRnkITwoOU{ex#u`!VXLQqtJ&)un%ovVXhWJ*&%RVy+mlzX;N6CcPFr1~X9f<+sw= zLJM=$>-!BF#^R6l$M|)AJ-GT=$RN$Zz}_>lR}4(t6&+)}qU+ayJZGg8L7p?9)OB9} z`Z+3T!To)T`#P=WWsPsYBIe*+JQEM-K*d~wKVj?$y(9a+0UDC@HYwK|PTtglYl?=Ts zK8K%C>OD8CVcjUhVsT;9(D>vu09px@?H(5pEi3))LrBZcxThw)qrO3qNQXhTL#E5l zx)G3KB!(ThGl`18rG*oG?9haFh{%+Ka9^T|;9S@^0Rn>;oNDM1&1Q^lQO;+t9)36lJ{OVSoieU^MzBeM27h0Ny)@_hLyzA5;`TKGsE|fL9!y zAhIr$gP*mq0qQX%2!|OvP+)SMaQlWwK&q1ZuF16@WL&fR^zh*5#EHqDKLUR7hAtdO z;Wv;4!zRYy1tvH(K&`r8ag;u+0USJ9Gzis!j@6*h4^h~3?_cVv zT$$3RH4bP$igScgQZII1=)7_$m{P>2RB|bmHxKbuEnHR0ViQ-jCz#Un+K&5*H1j(i zm^9Xm6`elb5lYXwYQ1cI^AKOOnJe1-jk!C|2V0)wik=ImA3bjb+0~lP$K`TyxeM!- z;tE4485g@RbYGbXrj+n0)m%#T&6%&ByK!!@@_RM!*Kjp^gDLywcYwT_lzK7lLfjYQ z=Z(UT_zUs(tm#+MuI66O4O+{1>qgGH@!gsC{9p42t?du>x};c0YStvDT}rx8ddbZt z6@ZABoPKfZg{^Ov@cA3K{0)45BbVP8%-vxs-ra?aY&`lk1`{`uA`MGG-+ zC0t8b&a7GV-rK(W&LMu!Ft=xT+08C*_bx!mo4EWPvx3q2FrISM2d1n*n zY+80U2M!;7kP)AqFu!}nsj;QKaqbJ}?q}v*t+`yoXI65Vl~BjGCa+EM#qC^i`varC z!hYU*=?G^lfpL+Nb+P3_3!hxFlw5M(S#)*s@+9x9CZC0Tt2U!%d6+}HgkEKA6SeHspoeuWN;}J*LQL$ z>sJyq@yT!Od2P?7%D=OgtZaeDxw2iWNps!L&cC0NAIf%xz%3}fUVm-Nt#w?%hT9G< ze=Cs5wLdVL9SIMNakkhMO`Ik6SC39=H7U+tXteQ;uq|ap5B{$ooz=p-0CC2IkU9Qd zT*@yr26(NWggDf`n|y2UJE^zK0Y~Fv`@D78+;uM@>$*O$v3I$zKd}Emufpx*>+r@0b(Y>1{Rf-% z7_(K2;T8-BboRaF`anu+(%vFHS7d}AT)7@H%sOgBxS{4H8F!eASoBDMVqby3hgV`0 zaDwjl10|>}Yfv_OPVd)y<7E(l0YU4gUS=3zQAA>kp#I_o3l{L*VvAup16vk#K^_$m zkmOL71WO7LC8_rYPR`__Y|vOTjgfsHen83PlhUg(!4eHAJFO*RiFu96HOb@FDfb5tf5L;sEY($Q2HQ{ zn$&;?5Uy3@$B=c4y6AX=CypQ&@^w)c#Hx&MQie+W)5>5hAiyKghpcJT1p>Tk{2Jq4 zg@PKK0fM8>AH&2EQB4ayH^9%2QTA__TLrKI0o#E6MFjYG z(rd=CT`F~qp|gb~z$Y+?e&eW?v56vjEX@TXx*&o}a0A3-&6yQnvLqjV?8zkqpvo@} zW1cggHAe}<_SuqXVb}$NjBCi{opKM4o){gbYpFl~l2)U6oU~nBQUuWy5>A6q!$O%v zfd2UxAw}4-bCNwi$}sK;7Eys77I6Uf1UfifvnSEniq0rHE_BAxc>$cVQbEAMz7r%w zBJSdhAP7${V(2tFM2MWlP#ijhHX+-OKVL!zbMf4Tb3tnfZ>{63b?-L3*Zj5SpmiHa zTPd+Wi?zL0{G~KLsQ^T+g;_3XT`;!py-i=+^o^r`>R-2PWy`#@b49O1@yUqdlM%%yYqX@KH8vB;=iRp3+IMy? zCu|OwH~$*sDr56+yto$hvbNW%eYGd8CtLHQEDZlB+um!@|EM~(*P#EvV1)1o7Cpqz z8f#t}W#$N!6d}CC0~LIOAO6bluSj@lRu^8_TJ`ubiMfZK5U)=t=;&2H@2Z+%P?r>1 zn(V{6SsMMNo`QZz3EPd|4st=-Od6JbUp5UZ*h|%vYHS;@-KuGQ%2Hjl1ljlH({Mzo zDk;}2RV6LdMMVgK*kW3m>_^nFRF0=1pCqD0)KDemnl)5O3w5caCCEO+B0?H<0ZXhJ zucluX!FKD%>Y_-&PoSkDl8`b~;-6LqVY>}LQw0E6^Bpp&)=GtfnwUh`ZdkEF1`>4b z;{66-3=`I53^&oT2xDkeFouafL~+UGmv$>eF^0yo#wfE76=ZEeH7lXm*c@7mOS5won}had z-oBl)ZwGem8Mw4iVk&~D3k`3zTx;PAnz(|d+n#SuetmMOV9&kOf?N9AyOvWs1GY|J z*+4O>{6cvsGf%`?Yc_uM?7u(zCHJL{t38){mQyPh*NfotAlEgxJT$SqbrKQW4V-fW zAh=7;rogoSmm1BB+T92)qj#^Veen>#;|RCo$ntY1mYYsq>fp0$x$Ig#d*f2}#=y7_ z(obl&V|w%mjNHuSH%oz!TOz^Ju|@=B-oLbMzg%P!m)*3S-5eM=7II`?eE!1oq2@N= zEti^mE|myv<}){PnH!fgHwE?|1|T{;cHRm=v^DvSmM^p*U_W_bl20w=QcIzJZ`E9@ z;q$j~`P%@9E&<5;QU+%&Sg`^S9CA1ptbvp&xPh#S9$6Lh$7fZ#nzXNW8M|UN-?wNX z{QX#aSA+ih*{NN1`tR2nG2Ebsc*S}SEXPGKE2v^hn3aH8!K`T1kXA-gc$(AuKxz;! zPz(mCE&4*mZ~7UaA6HPuR!?hSj5IA#mR6$&+rXqWpd6%%CrZ;LEl~~0^+~D5BNS?F6yEm4!}@i013waBNt<4EH`6m9beW0v!lWSF>xXi9HXY#4tZ4SBqG6b zNidl!OOnxCShzPRSRHAyR(!fmv~PS8YcnP>G_)=7@l$_N2{bpG;>)F}$6Pcd`?QyU zp-NnXmW|T7ne;ZG{CI(y*VD?aR@7T!iG+h2stE#9L5!j08y8WhQKBU z-B?A`ZTLxBOYT>#z8^ustj2OZxm7qO&+Ax=KSqu1Ij?1Z16wWZOtL3^JY&v6YGG8V zg@yLG+Is=Y^(t%)lk&{(L##g*wpzrhu_Cb5LNy+?T1afi7-BS0m$=np3yoI8S{VnE zO5ZwAvQwQI{GX)dY0unptKSMOx31B0)p&R>(m??xgUN)=oZ0X%hjB8wjEl)*@|gmr zkSSt{uOtxF7?s)39}j9Sz!Bgzm*_*xh+Hr}w$rfetB)Erq*gJjo7GTJI(~dzlsatQ z=%!)W_esbwttG4xsHRlwvxyYn9!g6+7p(`}?V%}UA7cCDUo=E&pc1cI1DLfGeGxOZ zk5Z9+sSa`e#G55=TZB7vHs1l7W{rM7Nbk!&Q>y;t$o3lB%`(OInnbd_#z42%FcnNC zQ{^`UYhQgu->-qKHdau*I!tK;Gv6?m#ORqsPt}~=Z&y>F@*Sh8X`AMf{mElPG*_a%H<7EZ#} z@gerq;E)%zT}GK60_T>+C}$ zUk~fsI(I(S_Y4%?NMJ8wY5E-p_J#Ew+xx=$?!F^%zXzOM`woTm{T=OL{r=AFVSVcX z2(`6>bFc@TuAY7DpOCyg1MN2~f{IcNL8#^&3QP?~% zd}h#lX8ic1=Wz;^1KWh%m}GYmTMKC)S43?jqEe)C-=<64P(+piX0s_VQP<5Ss;=R) zJVUVZ8+t({EoK}v+CXiMoxsYHMT5Gq9%L^8+Hb(vXI(zPc}`I3N{0~WPl1va=(e#q z^24TS-|)obOxObZo2EQ&P{5N1Ls&n=Ff4v>*?M%gqO%bl#6Z|abWrM+H#4y)$Fuk; zWRYJ}Xo!)K{8@}6&)^jdNe_a32Y+Uvvke{mR=Cxp@p#B>R_O~JGl5c5F(b^Kly zM;@W19ZJg!C1>2rD!uLuW^KBcQMhn4m{AA6`K($lt2R{K#8+?Ss<+-gyIkFKB^|ZY zmNUvhDh=74ygi?@=P$q_PQ{}{b8_r_%t|U*o5|whvM%*3$5q@bs^g2AxuWLzj%929 zy@Cpq{(}V@@Tcp(Ira_HYo_^`*Aqh!w7h0{Jq`o0uf?8kST<)tPgs-rxI8W{@6G20 zGQqg!#kB8be>;0cR~+A`{jseL5*3%eb@l4AEEqqZsSJb|EjNfsH+i@uH+(~Z7 z$zah4Uo^oL!TqU#E&p!gu0Y>$zHelyZzPaY!{^j5<(I^U&c{Mc3X9*`bZyi1o?yXdzF<37uzhhW z-*$j&I}kYH;o3aGf^ojU%N2No1=9hW>u&vyK=%OO{h6ii&jhk6gq>o66Q|CbVI3(u zcVYV0{>8#OzEGCydde;LVtZik0Sr5CO)nmJ5MwB`0}W6&D_GCxZ{+ef-a5UQ9?b7P zpZL?9ZJ~m4zMzpSXoO8^SN)g$!p5|FS+0e&x187DN!Q+M+jHk6-@|Y{%<>8Ea+`0V zLCC=uv@8|0EYHqC`_6${9<=?O_5k(^deGXJv+vcJIYzku8}A924?u0LSo>;6Mqjb!d(MuHy#<;NQnbB!h7TMD3}?i^ zjSpOy=z}~h#uQlk3iKaTR`ogcfea(u4LFSu6DZJQxY)iI!kdcsI`y2>2w|>34|!*; zWEH4J)WxLRsYLGW<@G`RB zv1oirw4O4wjL3@LFeqXIAk)VvG`%G0p5_C6AR7&aX_dP=$mKx_=` z`+&;;TIT^nR0Nk%eWMlVgeq#Iw#|%5c;*pCaT%3yL2wzBc)(>OG)7J9O~hqze*(G2 zwuu3S$0Rb+)*HZOes?WTdghi>Tt=mZ&~mHVn*z9u9dMasCIxXN_?O0{ODK(q(TKQ= z${Q1j%c!^c_dkEDVM6EitQW@rcEtk|gKD@Cq&}+Nnoq~B`Zoj6R0-LX`_~v`D^4?c@83KSn!1JyV?>~j_VOH1U$y^cP_Hq?zP!fo`)N3**&F3)FNwt=< z%ACc--vNau_uq(|kA_J9eO&K=kZP}McqN2_fih0XUpQW-D(d83-ymGemz!Yv3rr5YkkYin?ttiLV^rIN-nw;>Q zDH8zkH=r5ssbwczafdWU~u-2@a{!) zE}?T7oj*qB%jjG|=PT&^@911bX91mS==?i${sf&j(fLzw0F>q5CMKDUO;t$B9~zrLGW z-@TOE{YbCL$zRBOtMppw)!qksNchiQ?6Q}ew`$qHgn)p|7tk>x4lSV~VEjzjTP_3i z?0cB!1#rqXKSibM>zHx~9fG@?(3F*%0Swy>*4!8X+EG>&aBCc=EY4fR*cdT(B_Qkg zW(gybbR|Oe>|dcXj1KmI0L{bmR-*Dr__G|HW^@Q3Prz^j;b&tU8F45a$`|cdxMV`s5yCN|Apn9L2^Ilhg^B%-kN_7Fh}lYL-@*csE$YWF623dBd{Nyn*ik7 zP9u>2TWGx9Ps_($1q-KxX|?lKQ0(Fz6`Z4DKK7%yv=v=K{6X!#obv0P*K(O zF#*XBRJ8;OwgI}Dn+fP@MkZjW;Mg?Qv^QE_YXKm7Ij%BPR2x{=$`x%Vxc&XORJ?U* z>vCNAQxW}Vh4C~0W{&SY!S$X9ww_qgb!y|gK85pD;r#oV*;h+1m%iB$%&g!u>$ptZ zuhR6jrbRoqzAc#9&S!RWnccz6-REP_q+H7vTNY;CI(O|HU$Qw^viW{l#aGjBq~EFz zmbLO_om^Sx9Vg%Q9M|<+VDJ>zbt+iq;mcUAj189g0x89J!EWCE6a0S9(tb}MZ#|#a zxRlqp?0xB%fPZQ`a1{VOSRL_cU&7UqUAjHELaAMc^JqR|}C4WT!zo5l5lUAmWa z=euE~mzKYE`r7GR3Bi&UzGNp?vUBk)-*JTNI1+e%itCsPmb}21%y1<$!ICq9l)}4> zU4fosutKra!vu0`U~g7dA<`?M_dKh>u;wlGJ*a>q1?jj`)?QBF$4}A!SI3G z^>-YwI8d4jL|}QOq=7GP;)bJ_tN6er{e6;y>v8(^(0KlOa~BfBQ6D3q0dwdHaPpH;(U z)$m#MTvmMumU3p^JNLD7e8T~*;Xq*H!GP;fD5nJ8*sN4|V;!mR#)6ZeNzdieD!8i0at&+|i#zBuzzEcAVL@lD5dVg`e+>E>#>gJrvx zvUcH`)Va&&gf*#eRPjx{TvPAz-edgU5pM5DpmCJT9lh7MlW*+f8vDQq1sF?Mc5Q@7 z>}uELt}DBME-nPYqU)-KpgA2(Wsh+sh?p{f85cE+1I_!FQx61e2hgm+LG9y}SuOP6 z<41L5!3Zawdp1WPy=}2|aeW}QgST~Zwocru19yJ?2qwlz8wk&|GW13Gz`U$Ix*iM~ ztb1;~=KYP^w+_^4zOz9)P-FN`y#d3G+pFNt_e%B{pG(v%S8AWL8kVbeG{KK>NetWx zS7ZKgjTRDz>(ZXH=s&dRA^tkHDhr!zwCpdWB6X< zHD~n6GPHVHFh)#K87Y@(4Z0Ra(xwGt!Dc4T6GMyzBLfz7$;JgGs%R|O5tR&nGFHFw z%Np1WVPX=#YyjO+PXWis#{1;HurWyyePLqkr`jP+DU_3@Qckj1 zLdsVp1J@v9D(TS_j<4t=DC^WrG|Kv+w1uh`5s=R%m)eSv-xz_M=h3vXPbEt*To?MJL79@8Hm(olNP$8f4aq(}0Y&}|@*NtI%`&PX z$m9u%^S~G?2t{2A+9;kxU1QRUjeLVXb7@>XX3JJI(VVLGw2EjwBW5@~Q>^Saf4u6~qlD)Dg!NIId!^%K zqUuwotPfMpRQThW5}Yc)uOxmVQ*}izXwm}C2VYj5_kzBY4-mhpsYw?1e6lXj5PMQWoxRlxb!(g_Wlig!y_5pAoqe_C zajjU_@lh|BNgN&_WJI)#j*2Fic(4uV5*I#BsdVi)4OU1%^VUTZ7Cug@l)9DZC_};q zFgb!GswO9@hFEqKP%&z?#9K+=Z*rysr6G1{Y2oAbWiIc??;z-@Rk)mn9r8o|z!LzO(BoY%IL!EU zdgDTRIxwL2C{Is@{Tp?YDok~pEsI6a7Eh>ZY)sq7?Hp)#BX=1lSMAvi08 zNRKf0k>Bh+xKDJ)!%3?qE5Q)X*D=L8ba1Ir)&>t7&1 zL+EhZo2X$en z0Gx25*iu@Na2kDXv=0V_mpyC}tB4h2|0g<~;CNAjrD(Y13$S>$*hc!YM+`3ZF=YHl zkY2pF&LXP0zXa&`KbYI@I?Aut1s&D%v3C_4C~y!7qDmC_8sWr%B(Z zdt}rnC%}GVIJF{HlbUyN@WLSIl{@ML)%ceLocvZ&Fl~J>X#<)wb)w14g{JG%!Q}dR z^Fx!ylBRa71*qc&Qri~07n|?A6nJhZaQsAI;AG&WH{kOJymNt+v&-glP?bvu7hd?| z=Y$G{(l#q<19n>M=ew5Taza-78@pfIeQDFe&g&Z%wghSq2QrQrE*Had9 z0M@H%LvSy>UD+r&zvGkZ=--b^Qy7kYqWL1&s|_0P1;Mz2>m7XMwqWJ9)yo3+Y-zkL zpR?sJjB&PF@~e=u72b^FOE+`y-?o{=6?3-Yn^k;y3kUygEe`?zPxz%-lbH6}$d_gW zEo?rmnoFyO<%?j#y7!8{R`gEEibWHj{lKcR<=!i=xs}M*?&NBB2Fp7FwvwMFr=EW? z;A-H~8iL82Zf{shZV9DiUhKKhv!c^x6^Du|ukT-YK2Wza&~YpNDrtSV0T%ip~8|--r>NJslW@^#HV@_Ym*x(8Il*`fl{bbi2^orQd^|t49xGAPay~JrrTp*mL=$ z4P4R&*!d80dzZ1t=&ucw_tR2hN zo$_k}+Y6Mhi7oYw+1F+-)hs8LuR#OGYapX%+1g7-cE5rE+3ug#d_S+`dLoy%DO6N( zz5UwJP(d9u0c;h;m{D8aj0OPg5r`(7BV#Z&Kgp=;#&^B9*S+)Nt&xDEW!b!KwKlwH z8up1qTEA1UIP~q}#q@xq8<2W54Lh)!654|Wz4`{2fIzRl91J8C^^|LVREFUnmD~50 z=zrAQYV9-Y17<7y0JEq!&Q3$J?>90!6j~!dJr&540z9B{Z(?!FrHTORS3;mBfY+{o0Yv;tM_q8|4Nz$Y z1sPBLc)}c<>8sv8zhs(FtzDp`t^x%zvCrHRRGLo*rFmCW_24Hc_NvB%N^?F;7S5ModKBm<+fP=cFYWoo4@FxHeC(bBv-eJ^M3Z|GT z@xu}VJPnhYi4S-hV0@L_Rp~$RE}HS(Mr$qmYU3SqFr@%mmBGmzRKZR6I_zii0o*EF z0AC5`bLd|0r0@!C6h9o5reQ7-3=hP3vgd4mn<}_0LCUnRQQ1%pRA!3&iIMOXoXHq7 zmv}bu^$UU@p$Ml_)YMMq6bU${ca%mp;3ScD8V_w7`h@jHt(B;YsgU7aeSi^4uboLCrUt?{$wZ_#6xM{JGS&9g|E?7Z2~2~>DdS?T zVw?4!V%=%kxT_^Yhk?^yS z)HZasql0glusIzh2#s)J=zqe{G&;BlAsETENAFda2b0`^_$hw|4K^a2%fh}Fc`ivj zmjZ&Eo=STwQc!2LD4oN@_kLF|N$A6juyz`lz(#QpwfwKQ6zu z|L(R9+~M84RNQ=L|J{vSE}gtOad`rcI$3hoEp7m@GaHT>i9y+u9y78sr-kA47#hdP`!;QNuHpS81BO#=-T9jLi(AX!$G2@e zE!`V5-_Ou?*BQQ_X~3|vRRec^;Kb}d$kk#v-_l*9|3QTw!_`_0*N||X7Bc)`gJstS z{SUTO?W)oLu-ph~epq9Km>+JyG(T*}?=95-O-5_>o_hV?)?4AnkF>4M-dz0$xkmW$ zL7^VXkp=A;_wf<1wvGoY!=cGu->}4IL6bnv7OfP+ltm?ONr4EA#ly0)PCnz59DJf& zR>sd%)Fq}7Id4JQ#*@&m=x)YPUSlmaA}XZmYACSL4zB2H@beH?0aTKeA3HL4gQusRAF}4}WpMOQ+E408g4V7o%9P zja3-slO$theZaInoL3Hfpd@nxjSAQZuT?vbmG)J{0&C*4mJz|1*=Dml;Usd8o37!A zW5(rv5mqz2ba^#HvAim#5SLj;e+k2fe2{0q+)tPy7bPLDu|zIJ{jQgn4(-_9+1tBI zT#i*=iWSeJV{gMVA}nOjtWEfIS9wQI!kKk=5H?{ybN2{qNw5i+Ys5VS=fRcf z$uVhg+#3_N$ax7XI*QcC(Rl%!vIO=YF>J&hLd-wx5Z5LmYfRQ-!iM9M%$abEP>5IH z&d9Py!U%algJQP|9MCIBxD_4biptC^X#9z*8CYC6A@V7)fYfMMGdizgCbA4+h7e_I z1g<7Qv(BUkg?oTyr|GaGGv^WHjNjg9(-$Y#KP?3yyF5asqEJo{tH^p4n-siItR}sPPg~EWt%qEp)LdYPfEP+fUI-CjP`m~G)09lvrs_(r zCb{HSD|Wd3&uB>`70=94G5=0J9?zscIskq4vbGJ(uC^Mt=V{)zXt(DW-j8jyz>jYw zYq#ebzLjFea5g6TR*n|Kxx~%0v^D6zRi4_W(SKWGh48nn#cg%^Z`T_G1?S1P;n4MU2{gr zV^C-m0dN?RM-o=$jKrRY4${TTx>eC&D}c3XlePps!jy?`3AxrttENASHbd3uD)mRf zawh0?nkBtXNlz6aJ*DspY2t}ynqAS3I3@w&6G0Enwn|qsnbuhLS2>j$ z(CUwgfCN!Gin{1WK-ATgTLd6qM2oa)_Fb9eQ-I~eiDa>AP%M0Qqz{o?EXkpKzF;FE zqNLa{mzxdcX_I5iz>7wD`|UB6T%W$s54}?4RbzodesWYG+LOw$uC)O+45aMfIqQ0qCB*O&Oq%iEmY= zqLxqA?u674+MV*Kk7Ke#YS|x$8j};H-6^rPYDZ~{iv+u9gVc_GMmw&Q*#$jI^|{G~ z@F-}O>ZFvmHB#o2lm&{EigTLO?3}{Wv&aV>%gYh)4(_M*mVHG^)pwpgOW5D9nx6pT zHh&URbOo$(-z=6iRMFO`+N><=dn#uVJyWWjNtiOQG-_um$g+T4)X}sHI+_t0nv!4u z?6Ppal~Utz?_BaH8nZ{_{-GhJM$uErv{xd5VdWP?Ep-TCIiEd3R3Tq5HvO~h?D^LeX8gnCVh&=W?$=(zSiA?ZM(a6^z0w(YA33Zk&%Knr>gh> z`iU$&Bmtx*dU_~H6J`%ZMUHC(-hP{tJ{|h*?dnIK_p`KA{mFsG2D{E_O zl@ie>)|6yFf&zQkzsIuwD>^?#=O57dKhXIZI_2n8fD?`dWv6L&VsM)E%%&pSiCS!w z{S@xO!Qpt4wMRXTaBnuf6V4d-NGg@kOriu^hLtZ-PP9di%&zuu zN@R>MR-+a9fTL44T`Q^Uv7nZtVL5ah==7s=3?1Z9pH)MT{RJk*?|n2*{|?5HF@{Tp z!shr~2!*YpUYjsC5b2eu?8$I`D^y-|-HqI(Zit z@n-=#zeML(=#a{FVW!bAATlQf3hWYrriP`tE3;FCuF1vAl4+y!} z;sr4mB<1u|kxECYu8YSm99!5ObgUC}^JXuey>ND6FRYmztAFz)l`cvP<;-xu+m$VWv{)4@C;`F{;O4?`!FRxlvBv(RC77iH&3Ij zfSvzhwCYlwyrYzJlrA}r-rWAxt{YvqJc0J-f|W<-V?&NyVVvC>xV1a5?O>qpP|$IB z?RYRvoaE$f<(#ej={kC$yvExld{ZCS)E8*n8_3-ka+bpX2k|kWx@SYxy)Lqr4;$;laU|c$egsM``LL5g_n1QTty2LoU1;RnY&=VjH-aYogUqW zcl&QAy>svutP5;eHg8>Hap5V-crD-2FSdT$bo+F`(FwcXadF}BYDy7$bbXE5SNCk# z)2R8|O=b}S{IPOWhFS997{t)MwewaT(XV26gW$2)Ghc)P!_S}s=G#I+L^0!tL-3NC8Oi_ zFY2Oc$d2nj`#turE2<*!^T}#)|ML5n{WHY~C|=!uuqkVyvG632U5L8q(;{jz6;U_g zXM{~z^>;-=m!9~pNU2m6<|0p~deW!@jF(;SH;$nuO4KEv-mZ4q4?*}+eaJ_W3zorj zjB#xRSS?Wn#e)XPLYM^>!B8+F7s#`MD%TTmEUgAqxtbI?DgZ~;Jqh`OB`+J45aCish(Sf}q0?UxsC0LOnqMY!4DNwzN=c`o)n8BUfgEsU>`BHJ4ibiFb&kJ=+eE6xbnRO(vG18o8oIVlzro z7EJxc?iS{|BA-z?ntcyb(f)+UA1F z)$1Jc=7LB9bgg6c9s`tDSq;{gND!^~lL#uf#>)~xg~$>_@F4QVR=k0V5ue~lymOfC z9duAbg58FW+Ij_%T!^S5FGLV|W!1g{c~HngODU4wi4LL$>^IR_MCW~U-bDxB>5sRs zKp2(LB2NMI+;>pq`8GJB$nzQ8SCE{`Cl_$Z1qRknC6i=;^Wgz9Q0!NO@ zu7U);a#um@Z@a4i&x`wXSHUMf9`0Z4u7Vk1SAm`ED$rVz!mz7A5B}qozW*b7BbWbWe@4@Y2H`PILg*7ni6JEn&^*SilJIPG}$x~986)ch_OqtRH;JH zUQQezWk6FL470eaho*faEDC{aGG>SypJdz~Z#4n3QZOb#jG-S@2c|^;URY^H^+G9) z2%8Cr65>c<>$Gna$c>{}cb3fIVTTY!4t*W;x#2`rIJUqmoQ0Z$bpH_@CpziSw=8~e z!X|cd+6NSHP8UqSe;uRsV3&pcJjVT7bk1X(VQSLr3mcfxVITV~l4xq$N9>Ukvnyn0 z7oHOVWuSu?6pOR+4b4MHts#KbV{P@L%ICgU1GgUaS3y<^Zobz-bQr zMAOV^nt!ax`mrYGLyhGhG=)Fb6#qoi@?(wjCz{@%ruQeB^*`1W{X{eIV+^eS$YQ@# zaed(W?qy5kXH5@G1=?-eTZdLO=r5KCexU#0LlUvF8&rPHpU?bU=6u^LxgWWT7MMSY zyIW8SCsiI^F4%h4<~Toesr5?Xc`E@@z>r&f=Eh|Ry3jm|Igq0W}44cLhS1dvpz7=UJaYEP%$k#6f zS`YOaUDAq9h(E0ps~E5r1hs_^Oq$HR$O^=?Xq!W}xzAX0YVQ0B0UTuJ=cnk#2$vQSU-_o zawZvXMtA_B_!QwGnG=73zr~CZk0ubm)_9r0df0#h21q?HAl3teNDjsfi3RC_)*~mV zb*i;@9e;6IeCuA`B2>7!avpkhJuH4ufnx%o+bL DISZC> delta 12392 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 diff --git a/Backend/src/routes/__pycache__/promotion_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/promotion_routes.cpython-312.pyc index 6b1257363d651ba7588d5ec75b41c2f5e387d950..7ba8ef2564291975838836b1aea5dfaef1cb063f 100644 GIT binary patch delta 1603 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 delta 1813 zcmai!e{54#6vyveuYLVR*B@)U%}KZNV;kFm!G;52V^M~p24X;Av21L>z}Lzg zX*XaKCM?_VBt*lH5EK3&Tlv+5pe973@Q;8R6>yjcD#{SB9W!F0#Cy6824XyEKlgjj zJ?GsY@1A$&L;lE3PWjAe)Dvd4J>L4Q$9F1D?wD{i_n4q*76WDRLaF!-szO~fq-mxB z5eB1cxqu9-TT0+?irx0)H=k?5h7yHutyg(?%`e} z*(98X^PoLR)bywnb`AFk2`b_AQ5}(b>9FoI!Ido;)cj7{G%CXMw%PnbbpTr1ocZ1E4e>L>L7n;o>K_pUh#T;Fm(4u^?laA)vsV$r+H8I@#Gb_r;>8B{ur^|Jf+58zPtRW@ zP}gCBqow0Sg$*-X>*9||XTn5hRo-{D~Va=s0g9k8)eFbGjyL~6)|gu~8b%ZBI#c(&6e>VtAbY?usL z9-D~^8hW|m{f_v?DUZ0WR@jY?2H8x;N22gyn#cIG|Mtxh#`ts*eNbVQVcNr|Dg&=& z%2;J^lx!j4f-#0jP7j<4brIGA~(?{@+SW> zX`(T{i3Y4Nw`;cZAg>m)WOW*1KH@rJ0QPrHEl3sEuh_3b;PQs6CVfnmjVVBIb+2$} zF=__l1;k8-fIABao1K_coV!~UA0 z_^F+XA1XId!E#fu+ z4!?+W0QT*!rpfrt-7S><4y*Sr%r>wx@7LAVuUWZ#eNA{(Kdn`rxaeC5{4S{kzS~=@ zb+Wj$xP70|NbQi_yO?f+jlI+8UvRW{rR5KdyUXyB`X|a8kkc0^%U~%R<=UDJ^{=gK z%wte9AM%7Y)IAtp-&m*qjk)&_N$BjGP6>S4S1^mRBsF!U0uHfTK~!9|x|4PL`I^RN zmB;P^95o>{sSJbwQ({jPizp?84xvY+Ue5)Lma&_TP!J}BnL>HDA7Zggc1-7EUcrK1 zD^or6p$vhb^unS;CE6S&L-DRd&uO^8EI9T}D^=o!7qm31U$cH)D6D>u z^Ib*UM&K<_@nF>)SbfnemomwIBox!c;o?%hL`x^^pD{#OlQ0ub+UDNu?s(=a>bPok zz3YekBws|!E=i_AZ13=db0#>Z5^KPJd3@dy$_A}@2_D52Yg$5!A|Vi+c~C?-l9BR; Rq51MBa*|uc{Awm!!$0{U$P@qo diff --git a/Backend/src/routes/booking_routes.py b/Backend/src/routes/booking_routes.py index 7f677b51..ad952ba4 100644 --- a/Backend/src/routes/booking_routes.py +++ b/Backend/src/routes/booking_routes.py @@ -26,6 +26,134 @@ from ..utils.email_templates import ( router = APIRouter(prefix="/bookings", tags=["bookings"]) +def _generate_invoice_email_html(invoice: dict, is_proforma: bool = False) -> str: + """Generate HTML email content for invoice""" + invoice_type = "Proforma Invoice" if is_proforma else "Invoice" + items_html = ''.join([f''' + + {item.get('description', 'N/A')} + {item.get('quantity', 0)} + {item.get('unit_price', 0):.2f} + {item.get('line_total', 0):.2f} + + ''' for item in invoice.get('items', [])]) + + return f""" + + + + + + + +
+
+

{invoice_type}

+
+
+
+

{invoice_type} #{invoice.get('invoice_number', 'N/A')}

+

Issue Date: {invoice.get('issue_date', 'N/A')}

+

Due Date: {invoice.get('due_date', 'N/A')}

+

Status: {invoice.get('status', 'N/A')}

+
+ +
+

Items

+ + + + + + + + + + + {items_html} + +
DescriptionQuantityUnit PriceTotal
+
+ +
+

Subtotal: {invoice.get('subtotal', 0):.2f}

+ {f'

Discount: -{invoice.get("discount_amount", 0):.2f}

' if invoice.get('discount_amount', 0) > 0 else ''} +

Tax: {invoice.get('tax_amount', 0):.2f}

+

Total Amount: {invoice.get('total_amount', 0):.2f}

+

Amount Paid: {invoice.get('amount_paid', 0):.2f}

+

Balance Due: {invoice.get('balance_due', 0):.2f}

+
+
+ +
+ + + """ + + def generate_booking_number() -> str: """Generate unique booking number""" prefix = "BK" @@ -34,6 +162,29 @@ def generate_booking_number() -> str: return f"{prefix}-{ts}-{rand}" +def calculate_booking_payment_balance(booking: Booking) -> dict: + """Calculate total paid amount and remaining balance for a booking""" + total_paid = 0.0 + if booking.payments: + # Sum all completed payments + total_paid = sum( + float(payment.amount) if payment.amount else 0.0 + for payment in booking.payments + if payment.payment_status == PaymentStatus.completed + ) + + total_price = float(booking.total_price) if booking.total_price else 0.0 + remaining_balance = total_price - total_paid + + return { + "total_paid": total_paid, + "total_price": total_price, + "remaining_balance": remaining_balance, + "is_fully_paid": remaining_balance <= 0.01, # Allow small floating point differences + "payment_percentage": (total_paid / total_price * 100) if total_price > 0 else 0 + } + + @router.get("/") async def get_all_bookings( search: Optional[str] = Query(None), @@ -47,7 +198,11 @@ async def get_all_bookings( ): """Get all bookings (Admin/Staff only)""" try: - query = db.query(Booking) + query = db.query(Booking).options( + selectinload(Booking.payments), + joinedload(Booking.user), + joinedload(Booking.room).joinedload(Room.room_type) + ) # Filter by search (booking_number) if search: @@ -79,6 +234,23 @@ async def get_all_bookings( # Include related data result = [] for booking in bookings: + # Determine payment_method and payment_status from payments + payment_method_from_payments = None + payment_status_from_payments = "unpaid" + if booking.payments: + latest_payment = max(booking.payments, key=lambda p: p.created_at if p.created_at else datetime.min) + if isinstance(latest_payment.payment_method, PaymentMethod): + payment_method_from_payments = latest_payment.payment_method.value + elif hasattr(latest_payment.payment_method, 'value'): + payment_method_from_payments = latest_payment.payment_method.value + else: + payment_method_from_payments = str(latest_payment.payment_method) + + if latest_payment.payment_status == PaymentStatus.completed: + payment_status_from_payments = "paid" + elif latest_payment.payment_status == PaymentStatus.refunded: + payment_status_from_payments = "refunded" + booking_dict = { "id": booking.id, "booking_number": booking.booking_number, @@ -87,21 +259,33 @@ async def get_all_bookings( "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None, "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None, "num_guests": booking.num_guests, + "guest_count": booking.num_guests, # Frontend expects guest_count "total_price": float(booking.total_price) if booking.total_price else 0.0, + "original_price": float(booking.original_price) if booking.original_price else None, + "discount_amount": float(booking.discount_amount) if booking.discount_amount else None, + "promotion_code": booking.promotion_code, "status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, + "payment_method": payment_method_from_payments if payment_method_from_payments else "cash", + "payment_status": payment_status_from_payments, "deposit_paid": booking.deposit_paid, "requires_deposit": booking.requires_deposit, "special_requests": booking.special_requests, + "notes": booking.special_requests, # Frontend expects notes "created_at": booking.created_at.isoformat() if booking.created_at else None, + "createdAt": booking.created_at.isoformat() if booking.created_at else None, + "updated_at": booking.updated_at.isoformat() if booking.updated_at else None, + "updatedAt": booking.updated_at.isoformat() if booking.updated_at else None, } # Add user info if booking.user: booking_dict["user"] = { "id": booking.user.id, + "name": booking.user.full_name, "full_name": booking.user.full_name, "email": booking.user.email, "phone": booking.user.phone, + "phone_number": booking.user.phone, } # Add room info @@ -111,6 +295,37 @@ async def get_all_bookings( "room_number": booking.room.room_number, "floor": booking.room.floor, } + # Safely access room_type - it should be loaded via joinedload + try: + if hasattr(booking.room, 'room_type') and booking.room.room_type: + booking_dict["room"]["room_type"] = { + "id": booking.room.room_type.id, + "name": booking.room.room_type.name, + "base_price": float(booking.room.room_type.base_price) if booking.room.room_type.base_price else 0.0, + "capacity": booking.room.room_type.capacity, + } + except Exception as room_type_error: + import logging + logger = logging.getLogger(__name__) + logger.warning(f"Could not load room_type for booking {booking.id}: {room_type_error}") + + # Add payments + if booking.payments: + booking_dict["payments"] = [ + { + "id": p.id, + "amount": float(p.amount) if p.amount else 0.0, + "payment_method": p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else (p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method)), + "payment_type": p.payment_type.value if isinstance(p.payment_type, PaymentType) else (p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type)), + "payment_status": p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status, + "transaction_id": p.transaction_id, + "payment_date": p.payment_date.isoformat() if p.payment_date else None, + "created_at": p.created_at.isoformat() if p.created_at else None, + } + for p in booking.payments + ] + else: + booking_dict["payments"] = [] result.append(booking_dict) @@ -127,6 +342,11 @@ async def get_all_bookings( }, } except Exception as e: + import logging + import traceback + logger = logging.getLogger(__name__) + logger.error(f"Error in get_all_bookings: {str(e)}") + logger.error(traceback.format_exc()) raise HTTPException(status_code=500, detail=str(e)) @@ -138,13 +358,33 @@ async def get_my_bookings( ): """Get current user's bookings""" try: - bookings = db.query(Booking).filter( + bookings = db.query(Booking).options( + selectinload(Booking.payments), + joinedload(Booking.room).joinedload(Room.room_type) + ).filter( Booking.user_id == current_user.id ).order_by(Booking.created_at.desc()).all() base_url = get_base_url(request) result = [] for booking in bookings: + # Determine payment_method and payment_status from payments + payment_method_from_payments = None + payment_status_from_payments = "unpaid" + if booking.payments: + latest_payment = max(booking.payments, key=lambda p: p.created_at if p.created_at else datetime.min) + if isinstance(latest_payment.payment_method, PaymentMethod): + payment_method_from_payments = latest_payment.payment_method.value + elif hasattr(latest_payment.payment_method, 'value'): + payment_method_from_payments = latest_payment.payment_method.value + else: + payment_method_from_payments = str(latest_payment.payment_method) + + if latest_payment.payment_status == PaymentStatus.completed: + payment_status_from_payments = "paid" + elif latest_payment.payment_status == PaymentStatus.refunded: + payment_status_from_payments = "refunded" + booking_dict = { "id": booking.id, "booking_number": booking.booking_number, @@ -152,12 +392,22 @@ async def get_my_bookings( "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None, "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None, "num_guests": booking.num_guests, - "total_price": float(booking.total_price) if booking.total_price else 0.0, - "status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, - "deposit_paid": booking.deposit_paid, - "requires_deposit": booking.requires_deposit, - "special_requests": booking.special_requests, + "guest_count": booking.num_guests, + "total_price": float(booking.total_price) if booking.total_price else 0.0, + "original_price": float(booking.original_price) if booking.original_price else None, + "discount_amount": float(booking.discount_amount) if booking.discount_amount else None, + "promotion_code": booking.promotion_code, + "status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, + "payment_method": payment_method_from_payments if payment_method_from_payments else "cash", + "payment_status": payment_status_from_payments, + "deposit_paid": booking.deposit_paid, + "requires_deposit": booking.requires_deposit, + "special_requests": booking.special_requests, + "notes": booking.special_requests, "created_at": booking.created_at.isoformat() if booking.created_at else None, + "createdAt": booking.created_at.isoformat() if booking.created_at else None, + "updated_at": booking.updated_at.isoformat() if booking.updated_at else None, + "updatedAt": booking.updated_at.isoformat() if booking.updated_at else None, } # Add room info @@ -184,6 +434,24 @@ async def get_my_bookings( } } + # Add payments + if booking.payments: + booking_dict["payments"] = [ + { + "id": p.id, + "amount": float(p.amount) if p.amount else 0.0, + "payment_method": p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else (p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method)), + "payment_type": p.payment_type.value if isinstance(p.payment_type, PaymentType) else (p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type)), + "payment_status": p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status, + "transaction_id": p.transaction_id, + "payment_date": p.payment_date.isoformat() if p.payment_date else None, + "created_at": p.created_at.isoformat() if p.created_at else None, + } + for p in booking.payments + ] + else: + booking_dict["payments"] = [] + result.append(booking_dict) return { @@ -219,6 +487,10 @@ async def create_booking( guest_count = booking_data.get("guest_count", 1) notes = booking_data.get("notes") payment_method = booking_data.get("payment_method", "cash") + promotion_code = booking_data.get("promotion_code") + + # Invoice information (optional) + invoice_info = booking_data.get("invoice_info", {}) # Detailed validation with specific error messages missing_fields = [] @@ -284,6 +556,34 @@ async def create_booking( # Will be confirmed after successful payment initial_status = BookingStatus.pending + # Calculate original price (before discount) and discount amount + # Calculate room price + room_price = float(room.price) if room.price and room.price > 0 else float(room.room_type.base_price) if room.room_type else 0.0 + number_of_nights = (check_out - check_in).days + room_total = room_price * number_of_nights + + # Calculate services total (will be recalculated when adding services, but estimate here) + services = booking_data.get("services", []) + services_total = 0.0 + if services: + from ..models.service import Service + for service_item in services: + service_id = service_item.get("service_id") + quantity = service_item.get("quantity", 1) + if service_id: + service = db.query(Service).filter(Service.id == service_id).first() + if service and service.is_active: + services_total += float(service.price) * quantity + + original_price = room_total + services_total + discount_amount = max(0.0, original_price - float(total_price)) if promotion_code else 0.0 + + # Add promotion code to notes if provided + final_notes = notes or "" + if promotion_code: + promotion_note = f"Promotion Code: {promotion_code}" + final_notes = f"{promotion_note}\n{final_notes}".strip() if final_notes else promotion_note + # Create booking booking = Booking( booking_number=booking_number, @@ -293,7 +593,10 @@ async def create_booking( check_out_date=check_out, num_guests=guest_count, total_price=total_price, - special_requests=notes, + original_price=original_price if promotion_code else None, + discount_amount=discount_amount if promotion_code and discount_amount > 0 else None, + promotion_code=promotion_code, + special_requests=final_notes, status=initial_status, requires_deposit=requires_deposit, deposit_paid=False, @@ -330,8 +633,21 @@ async def create_booking( logger.info(f"Payment created: ID={payment.id}, method={payment.payment_method.value if hasattr(payment.payment_method, 'value') else payment.payment_method}") # Create deposit payment if required (for cash method) - # Note: For cash payments, deposit is paid on arrival, so we don't create a pending payment record - # The payment will be created when the customer pays at check-in + # For cash payments, create a pending deposit payment record that can be paid via PayPal or Stripe + if requires_deposit and deposit_amount > 0: + from ..models.payment import Payment, PaymentMethod, PaymentStatus, PaymentType + deposit_payment = Payment( + booking_id=booking.id, + amount=deposit_amount, + payment_method=PaymentMethod.stripe, # Default, will be updated when user chooses payment method + payment_type=PaymentType.deposit, + deposit_percentage=deposit_percentage, + payment_status=PaymentStatus.pending, + payment_date=None, + ) + db.add(deposit_payment) + db.flush() + logger.info(f"Deposit payment created: ID={deposit_payment.id}, amount={deposit_amount}, percentage={deposit_percentage}%") # Add services to booking if provided services = booking_data.get("services", []) @@ -368,9 +684,10 @@ async def create_booking( db.commit() db.refresh(booking) - # Automatically create invoice for the booking + # Automatically create invoice(s) for the booking try: from ..services.invoice_service import InvoiceService + from ..utils.mailer import send_email from sqlalchemy.orm import joinedload, selectinload # Reload booking with service_usages for invoice creation @@ -378,15 +695,113 @@ async def create_booking( selectinload(Booking.service_usages).selectinload(ServiceUsage.service) ).filter(Booking.id == booking.id).first() - # Create invoice automatically - invoice = InvoiceService.create_invoice_from_booking( - booking_id=booking.id, - db=db, - created_by_id=current_user.id, - tax_rate=0.0, # Default no tax, can be configured - discount_amount=0.0, - due_days=30, - ) + # Get company settings for invoice + from ..models.system_settings import SystemSettings + company_settings = {} + for key in ["company_name", "company_address", "company_phone", "company_email", "company_tax_id", "company_logo_url"]: + setting = db.query(SystemSettings).filter(SystemSettings.key == key).first() + if setting and setting.value: + company_settings[key] = setting.value + + # Get tax rate from settings (default to 0 if not set) + tax_rate_setting = db.query(SystemSettings).filter(SystemSettings.key == "tax_rate").first() + tax_rate = float(tax_rate_setting.value) if tax_rate_setting and tax_rate_setting.value else 0.0 + + # Merge invoice info from form with company settings (form takes precedence) + # Only include non-empty values from invoice_info + invoice_kwargs = {**company_settings} + if invoice_info: + if invoice_info.get("company_name"): + invoice_kwargs["company_name"] = invoice_info.get("company_name") + if invoice_info.get("company_address"): + invoice_kwargs["company_address"] = invoice_info.get("company_address") + if invoice_info.get("company_tax_id"): + invoice_kwargs["company_tax_id"] = invoice_info.get("company_tax_id") + if invoice_info.get("customer_tax_id"): + invoice_kwargs["customer_tax_id"] = invoice_info.get("customer_tax_id") + if invoice_info.get("notes"): + invoice_kwargs["notes"] = invoice_info.get("notes") + if invoice_info.get("terms_and_conditions"): + invoice_kwargs["terms_and_conditions"] = invoice_info.get("terms_and_conditions") + if invoice_info.get("payment_instructions"): + invoice_kwargs["payment_instructions"] = invoice_info.get("payment_instructions") + + # Get discount from booking + booking_discount = float(booking.discount_amount) if booking.discount_amount else 0.0 + + # Create invoices based on payment method + if payment_method == "cash": + # For cash bookings: create invoice for 20% deposit + proforma for 80% remaining + deposit_amount = float(total_price) * 0.2 + remaining_amount = float(total_price) * 0.8 + + # Create invoice for deposit (20%) + deposit_invoice = InvoiceService.create_invoice_from_booking( + booking_id=booking.id, + db=db, + created_by_id=current_user.id, + tax_rate=tax_rate, + discount_amount=booking_discount, + due_days=30, + is_proforma=False, + invoice_amount=deposit_amount, + **invoice_kwargs + ) + + # Create proforma invoice for remaining amount (80%) + proforma_invoice = InvoiceService.create_invoice_from_booking( + booking_id=booking.id, + db=db, + created_by_id=current_user.id, + tax_rate=tax_rate, + discount_amount=booking_discount, + due_days=30, + is_proforma=True, + invoice_amount=remaining_amount, + **invoice_kwargs + ) + + # Send deposit invoice via email + try: + invoice_html = _generate_invoice_email_html(deposit_invoice, is_proforma=False) + await send_email( + to=current_user.email, + subject=f"Invoice {deposit_invoice['invoice_number']} - Deposit Payment", + html=invoice_html + ) + logger.info(f"Deposit invoice sent to {current_user.email}") + except Exception as email_error: + logger.error(f"Failed to send deposit invoice email: {str(email_error)}") + + # Send proforma invoice via email + try: + proforma_html = _generate_invoice_email_html(proforma_invoice, is_proforma=True) + await send_email( + to=current_user.email, + subject=f"Proforma Invoice {proforma_invoice['invoice_number']} - Remaining Balance", + html=proforma_html + ) + logger.info(f"Proforma invoice sent to {current_user.email}") + except Exception as email_error: + logger.error(f"Failed to send proforma invoice email: {str(email_error)}") + else: + # For full payment (Stripe/PayPal): create full invoice + # Invoice will be created and sent after payment is confirmed + # We create it now as draft, and it will be updated when payment is confirmed + full_invoice = InvoiceService.create_invoice_from_booking( + booking_id=booking.id, + db=db, + created_by_id=current_user.id, + tax_rate=tax_rate, + discount_amount=booking_discount, + due_days=30, + is_proforma=False, + **invoice_kwargs + ) + + # Don't send invoice email yet - will be sent after payment is confirmed + # The invoice will be updated and sent when payment is completed + logger.info(f"Invoice {full_invoice['invoice_number']} created for booking {booking.id} (will be sent after payment confirmation)") except Exception as e: # Log error but don't fail booking creation if invoice creation fails import logging @@ -511,32 +926,7 @@ async def create_booking( "capacity": booking.room.room_type.capacity, } - # Send booking confirmation email (non-blocking) - try: - client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") - room = db.query(Room).filter(Room.id == room_id).first() - room_type_name = room.room_type.name if room and room.room_type else "Room" - - email_html = booking_confirmation_email_template( - booking_number=booking.booking_number, - guest_name=current_user.full_name, - room_number=room.room_number if room else "N/A", - room_type=room_type_name, - check_in=check_in.strftime("%B %d, %Y"), - check_out=check_out.strftime("%B %d, %Y"), - num_guests=guest_count, - total_price=float(total_price), - requires_deposit=requires_deposit, - deposit_amount=deposit_amount if requires_deposit else None, - client_url=client_url - ) - await send_email( - to=current_user.email, - subject=f"Booking Confirmation - {booking.booking_number}", - html=email_html - ) - except Exception as e: - print(f"Failed to send booking confirmation email: {e}") + # Don't send email here - emails will be sent when booking is confirmed or cancelled return { "success": True, @@ -678,7 +1068,11 @@ async def get_booking_by_id( "id": p.id, "amount": float(p.amount) if p.amount else 0.0, "payment_method": p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else (p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method)), + "payment_type": p.payment_type.value if isinstance(p.payment_type, PaymentType) else (p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type)), "payment_status": p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status, + "transaction_id": p.transaction_id, + "payment_date": p.payment_date.isoformat() if p.payment_date else None, + "created_at": p.created_at.isoformat() if p.created_at else None, } for p in booking.payments ] @@ -757,7 +1151,12 @@ async def cancel_booking( # Send cancellation email (non-blocking) try: - client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") + from ..models.system_settings import SystemSettings + + # Get client URL from settings + client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first() + client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")) + email_html = booking_status_changed_email_template( booking_number=booking.booking_number, guest_name=booking.user.full_name if booking.user else "Guest", @@ -770,7 +1169,9 @@ async def cancel_booking( html=email_html ) except Exception as e: - print(f"Failed to send cancellation email: {e}") + import logging + logger = logging.getLogger(__name__) + logger.error(f"Failed to send cancellation email: {e}") return { "success": True, @@ -792,7 +1193,10 @@ async def update_booking( ): """Update booking status (Admin only)""" try: - booking = db.query(Booking).filter(Booking.id == id).first() + # Load booking with payments to check balance + booking = db.query(Booking).options( + selectinload(Booking.payments) + ).filter(Booking.id == id).first() if not booking: raise HTTPException(status_code=404, detail="Booking not found") @@ -807,29 +1211,105 @@ async def update_booking( db.commit() db.refresh(booking) - # Send status change email if status changed (non-blocking) - if status_value and old_status != booking.status: - try: - client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") - email_html = booking_status_changed_email_template( - booking_number=booking.booking_number, - guest_name=booking.user.full_name if booking.user else "Guest", - status=booking.status.value, - client_url=client_url - ) - await send_email( - to=booking.user.email if booking.user else None, - subject=f"Booking Status Updated - {booking.booking_number}", - html=email_html - ) - except Exception as e: - print(f"Failed to send status change email: {e}") + # Check payment balance if status changed to checked_in + payment_warning = None + if status_value and old_status != booking.status and booking.status == BookingStatus.checked_in: + payment_balance = calculate_booking_payment_balance(booking) + if payment_balance["remaining_balance"] > 0.01: # More than 1 cent remaining + payment_warning = { + "message": f"Guest has not fully paid. Remaining balance: {payment_balance['remaining_balance']:.2f}", + "total_paid": payment_balance["total_paid"], + "total_price": payment_balance["total_price"], + "remaining_balance": payment_balance["remaining_balance"], + "payment_percentage": payment_balance["payment_percentage"] + } - return { + # Send status change email only if status changed to confirmed or cancelled (non-blocking) + if status_value and old_status != booking.status: + if booking.status in [BookingStatus.confirmed, BookingStatus.cancelled]: + try: + from ..models.system_settings import SystemSettings + from ..services.room_service import get_base_url + from fastapi import Request + + # Get client URL from settings + client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first() + client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")) + + if booking.status == BookingStatus.confirmed: + # Send booking confirmation email with full details + from sqlalchemy.orm import selectinload + booking_with_room = db.query(Booking).options( + selectinload(Booking.room).selectinload(Room.room_type) + ).filter(Booking.id == booking.id).first() + + room = booking_with_room.room if booking_with_room else None + room_type_name = room.room_type.name if room and room.room_type else "Room" + + # Get platform currency for email + currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first() + currency = currency_setting.value if currency_setting and currency_setting.value else "USD" + + # Get currency symbol + currency_symbols = { + "USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥", + "KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$", + "VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$" + } + currency_symbol = currency_symbols.get(currency, currency) + + email_html = booking_confirmation_email_template( + booking_number=booking.booking_number, + guest_name=booking.user.full_name if booking.user else "Guest", + room_number=room.room_number if room else "N/A", + room_type=room_type_name, + check_in=booking.check_in_date.strftime("%B %d, %Y") if booking.check_in_date else "N/A", + check_out=booking.check_out_date.strftime("%B %d, %Y") if booking.check_out_date else "N/A", + num_guests=booking.num_guests, + total_price=float(booking.total_price), + requires_deposit=booking.requires_deposit, + deposit_amount=float(booking.total_price) * 0.2 if booking.requires_deposit else None, + original_price=float(booking.original_price) if booking.original_price else None, + discount_amount=float(booking.discount_amount) if booking.discount_amount else None, + promotion_code=booking.promotion_code, + client_url=client_url, + currency_symbol=currency_symbol + ) + await send_email( + to=booking.user.email if booking.user else None, + subject=f"Booking Confirmed - {booking.booking_number}", + html=email_html + ) + elif booking.status == BookingStatus.cancelled: + # Send cancellation email + email_html = booking_status_changed_email_template( + booking_number=booking.booking_number, + guest_name=booking.user.full_name if booking.user else "Guest", + status="cancelled", + client_url=client_url + ) + await send_email( + to=booking.user.email if booking.user else None, + subject=f"Booking Cancelled - {booking.booking_number}", + html=email_html + ) + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.error(f"Failed to send status change email: {e}") + + response_data = { "status": "success", "message": "Booking updated successfully", "data": {"booking": booking} } + + # Add payment warning if there's remaining balance during check-in + if payment_warning: + response_data["warning"] = payment_warning + response_data["message"] = "Booking updated successfully. ⚠️ Payment reminder: Guest has remaining balance." + + return response_data except HTTPException: raise except Exception as e: @@ -844,30 +1324,126 @@ async def check_booking_by_number( ): """Check booking by booking number""" try: - booking = db.query(Booking).filter(Booking.booking_number == booking_number).first() + booking = db.query(Booking).options( + selectinload(Booking.payments), + joinedload(Booking.user), + joinedload(Booking.room).joinedload(Room.room_type) + ).filter(Booking.booking_number == booking_number).first() if not booking: raise HTTPException(status_code=404, detail="Booking not found") + # Determine payment_method and payment_status from payments + payment_method_from_payments = None + payment_status_from_payments = "unpaid" + if booking.payments: + latest_payment = max(booking.payments, key=lambda p: p.created_at if p.created_at else datetime.min) + if isinstance(latest_payment.payment_method, PaymentMethod): + payment_method_from_payments = latest_payment.payment_method.value + elif hasattr(latest_payment.payment_method, 'value'): + payment_method_from_payments = latest_payment.payment_method.value + else: + payment_method_from_payments = str(latest_payment.payment_method) + + if latest_payment.payment_status == PaymentStatus.completed: + payment_status_from_payments = "paid" + elif latest_payment.payment_status == PaymentStatus.refunded: + payment_status_from_payments = "refunded" + booking_dict = { "id": booking.id, "booking_number": booking.booking_number, + "user_id": booking.user_id, "room_id": booking.room_id, "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None, "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None, + "num_guests": booking.num_guests, + "guest_count": booking.num_guests, + "total_price": float(booking.total_price) if booking.total_price else 0.0, + "original_price": float(booking.original_price) if booking.original_price else None, + "discount_amount": float(booking.discount_amount) if booking.discount_amount else None, + "promotion_code": booking.promotion_code, "status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, + "payment_method": payment_method_from_payments if payment_method_from_payments else "cash", + "payment_status": payment_status_from_payments, + "deposit_paid": booking.deposit_paid, + "requires_deposit": booking.requires_deposit, + "special_requests": booking.special_requests, + "notes": booking.special_requests, + "created_at": booking.created_at.isoformat() if booking.created_at else None, + "createdAt": booking.created_at.isoformat() if booking.created_at else None, + "updated_at": booking.updated_at.isoformat() if booking.updated_at else None, + "updatedAt": booking.updated_at.isoformat() if booking.updated_at else None, } + # Add user info + if booking.user: + booking_dict["user"] = { + "id": booking.user.id, + "name": booking.user.full_name, + "full_name": booking.user.full_name, + "email": booking.user.email, + "phone": booking.user.phone, + "phone_number": booking.user.phone, + } + + # Add room info if booking.room: booking_dict["room"] = { "id": booking.room.id, "room_number": booking.room.room_number, + "floor": booking.room.floor, } + if booking.room.room_type: + booking_dict["room"]["room_type"] = { + "id": booking.room.room_type.id, + "name": booking.room.room_type.name, + "base_price": float(booking.room.room_type.base_price) if booking.room.room_type.base_price else 0.0, + "capacity": booking.room.room_type.capacity, + } - return { + # Add payments + if booking.payments: + booking_dict["payments"] = [ + { + "id": p.id, + "amount": float(p.amount) if p.amount else 0.0, + "payment_method": p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else (p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method)), + "payment_type": p.payment_type.value if isinstance(p.payment_type, PaymentType) else (p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type)), + "payment_status": p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status, + "transaction_id": p.transaction_id, + "payment_date": p.payment_date.isoformat() if p.payment_date else None, + "created_at": p.created_at.isoformat() if p.created_at else None, + } + for p in booking.payments + ] + else: + booking_dict["payments"] = [] + + # Calculate and add payment balance information + payment_balance = calculate_booking_payment_balance(booking) + booking_dict["payment_balance"] = { + "total_paid": payment_balance["total_paid"], + "total_price": payment_balance["total_price"], + "remaining_balance": payment_balance["remaining_balance"], + "is_fully_paid": payment_balance["is_fully_paid"], + "payment_percentage": payment_balance["payment_percentage"] + } + + # Add warning if there's remaining balance (useful for check-in) + response_data = { "status": "success", "data": {"booking": booking_dict} } + + if payment_balance["remaining_balance"] > 0.01: + response_data["warning"] = { + "message": f"Guest has not fully paid. Remaining balance: {payment_balance['remaining_balance']:.2f}", + "remaining_balance": payment_balance["remaining_balance"], + "payment_percentage": payment_balance["payment_percentage"] + } + + return response_data except HTTPException: raise except Exception as e: diff --git a/Backend/src/routes/contact_routes.py b/Backend/src/routes/contact_routes.py index 0c8b7db5..a6e87bb6 100644 --- a/Backend/src/routes/contact_routes.py +++ b/Backend/src/routes/contact_routes.py @@ -25,7 +25,15 @@ class ContactForm(BaseModel): def get_admin_email(db: Session) -> str: """Get admin email from system settings or find admin user""" - # First, try to get from system settings + # First, try to get from company_email (company settings) + company_email_setting = db.query(SystemSettings).filter( + SystemSettings.key == "company_email" + ).first() + + if company_email_setting and company_email_setting.value: + return company_email_setting.value + + # Second, try to get from admin_email (legacy setting) admin_email_setting = db.query(SystemSettings).filter( SystemSettings.key == "admin_email" ).first() @@ -52,7 +60,7 @@ def get_admin_email(db: Session) -> str: # Last resort: raise error raise HTTPException( status_code=500, - detail="Admin email not configured. Please set admin_email in system settings or ensure an admin user exists." + detail="Admin email not configured. Please set company_email in system settings or ensure an admin user exists." ) diff --git a/Backend/src/routes/payment_routes.py b/Backend/src/routes/payment_routes.py index 9b18ee13..699b91d4 100644 --- a/Backend/src/routes/payment_routes.py +++ b/Backend/src/routes/payment_routes.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, Header -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload, selectinload from typing import Optional from datetime import datetime import os @@ -11,13 +11,51 @@ from ..models.user import User from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus from ..models.booking import Booking, BookingStatus from ..utils.mailer import send_email -from ..utils.email_templates import payment_confirmation_email_template +from ..utils.email_templates import payment_confirmation_email_template, booking_status_changed_email_template from ..services.stripe_service import StripeService from ..services.paypal_service import PayPalService router = APIRouter(prefix="/payments", tags=["payments"]) +async def cancel_booking_on_payment_failure(booking: Booking, db: Session, reason: str = "Payment failed or canceled"): + """ + Helper function to cancel a booking when payment fails or is canceled. + This bypasses the normal cancellation restrictions and sends cancellation email. + """ + if booking.status == BookingStatus.cancelled: + return # Already cancelled + + booking.status = BookingStatus.cancelled + db.commit() + db.refresh(booking) + + # Send cancellation email (non-blocking) + try: + from ..models.system_settings import SystemSettings + + # Get client URL from settings + client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first() + client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")) + + if booking.user: + email_html = booking_status_changed_email_template( + booking_number=booking.booking_number, + guest_name=booking.user.full_name if booking.user else "Guest", + status="cancelled", + client_url=client_url + ) + await send_email( + to=booking.user.email, + subject=f"Booking Cancelled - {booking.booking_number}", + html=email_html + ) + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.error(f"Failed to send cancellation email: {e}") + + @router.get("/") async def get_payments( booking_id: Optional[int] = Query(None), @@ -29,11 +67,11 @@ async def get_payments( ): """Get all payments""" try: - query = db.query(Payment) - - # Filter by booking_id + # Build base query if booking_id: - query = query.filter(Payment.booking_id == booking_id) + query = db.query(Payment).filter(Payment.booking_id == booking_id) + else: + query = db.query(Payment) # Filter by status if status_filter: @@ -46,7 +84,14 @@ async def get_payments( if current_user.role_id != 1: # Not admin query = query.join(Booking).filter(Booking.user_id == current_user.id) + # Get total count before applying eager loading total = query.count() + + # Load payments with booking and user relationships using selectinload to avoid join conflicts + query = query.options( + selectinload(Payment.booking).selectinload(Booking.user) + ) + offset = (page - 1) * limit payments = query.order_by(Payment.created_at.desc()).offset(offset).limit(limit).all() @@ -72,6 +117,14 @@ async def get_payments( "id": payment.booking.id, "booking_number": payment.booking.booking_number, } + # Include user information if available + if payment.booking.user: + payment_dict["booking"]["user"] = { + "id": payment.booking.user.id, + "name": payment.booking.user.full_name, + "full_name": payment.booking.user.full_name, + "email": payment.booking.user.email, + } result.append(payment_dict) @@ -87,8 +140,13 @@ async def get_payments( }, }, } + except HTTPException: + raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + import logging + logger = logging.getLogger(__name__) + logger.error(f"Error fetching payments: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error fetching payments: {str(e)}") @router.get("/booking/{booking_id}") @@ -108,8 +166,10 @@ async def get_payments_by_booking_id( if current_user.role_id != 1 and booking.user_id != current_user.id: raise HTTPException(status_code=403, detail="Forbidden") - # Get all payments for this booking - payments = db.query(Payment).filter(Payment.booking_id == booking_id).order_by(Payment.created_at.desc()).all() + # Get all payments for this booking with user relationship + payments = db.query(Payment).options( + joinedload(Payment.booking).joinedload(Booking.user) + ).filter(Payment.booking_id == booking_id).order_by(Payment.created_at.desc()).all() result = [] for payment in payments: @@ -133,6 +193,14 @@ async def get_payments_by_booking_id( "id": payment.booking.id, "booking_number": payment.booking.booking_number, } + # Include user information if available + if payment.booking.user: + payment_dict["booking"]["user"] = { + "id": payment.booking.user.id, + "name": payment.booking.user.full_name, + "full_name": payment.booking.user.full_name, + "email": payment.booking.user.email, + } result.append(payment_dict) @@ -241,14 +309,34 @@ async def create_payment( # Send payment confirmation email if payment was marked as paid (non-blocking) if payment.payment_status == PaymentStatus.completed and booking.user: try: - client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") + from ..models.system_settings import SystemSettings + + # Get client URL from settings + client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first() + client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")) + + # Get platform currency for email + currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first() + currency = currency_setting.value if currency_setting and currency_setting.value else "USD" + + # Get currency symbol + currency_symbols = { + "USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥", + "KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$", + "VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$" + } + currency_symbol = currency_symbols.get(currency, currency) + email_html = payment_confirmation_email_template( booking_number=booking.booking_number, guest_name=booking.user.full_name, amount=float(payment.amount), payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method), transaction_id=payment.transaction_id, - client_url=client_url + payment_type=payment.payment_type.value if payment.payment_type else None, + total_price=float(booking.total_price), + client_url=client_url, + currency_symbol=currency_symbol ) await send_email( to=booking.user.email, @@ -256,7 +344,9 @@ async def create_payment( html=email_html ) except Exception as e: - print(f"Failed to send payment confirmation email: {e}") + import logging + logger = logging.getLogger(__name__) + logger.error(f"Failed to send payment confirmation email: {e}") return { "status": "success", @@ -284,16 +374,28 @@ async def update_payment_status( raise HTTPException(status_code=404, detail="Payment not found") status_value = status_data.get("status") + old_status = payment.payment_status + if status_value: try: - payment.payment_status = PaymentStatus(status_value) + new_status = PaymentStatus(status_value) + payment.payment_status = new_status + + # Auto-cancel booking if payment is marked as failed or refunded + if new_status in [PaymentStatus.failed, PaymentStatus.refunded]: + booking = db.query(Booking).filter(Booking.id == payment.booking_id).first() + if booking and booking.status != BookingStatus.cancelled: + await cancel_booking_on_payment_failure( + booking, + db, + reason=f"Payment {new_status.value}" + ) except ValueError: raise HTTPException(status_code=400, detail="Invalid payment status") if status_data.get("transaction_id"): payment.transaction_id = status_data["transaction_id"] - old_status = payment.payment_status if status_data.get("mark_as_paid"): payment.payment_status = PaymentStatus.completed payment.payment_date = datetime.utcnow() @@ -304,17 +406,50 @@ async def update_payment_status( # Send payment confirmation email if payment was just completed (non-blocking) if payment.payment_status == PaymentStatus.completed and old_status != PaymentStatus.completed: try: + from ..models.system_settings import SystemSettings + + # Get client URL from settings + client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first() + client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")) + + # Get platform currency for email + currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first() + currency = currency_setting.value if currency_setting and currency_setting.value else "USD" + + # Get currency symbol + currency_symbols = { + "USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥", + "KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$", + "VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$" + } + currency_symbol = currency_symbols.get(currency, currency) # Refresh booking relationship payment = db.query(Payment).filter(Payment.id == id).first() if payment.booking and payment.booking.user: - client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") + # Get client URL from settings + client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first() + client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")) + + # Get platform currency for email + currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first() + currency = currency_setting.value if currency_setting and currency_setting.value else "USD" + + # Get currency symbol + currency_symbols = { + "USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥", + "KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$", + "VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$" + } + currency_symbol = currency_symbols.get(currency, currency) + email_html = payment_confirmation_email_template( booking_number=payment.booking.booking_number, guest_name=payment.booking.user.full_name, amount=float(payment.amount), payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method), transaction_id=payment.transaction_id, - client_url=client_url + client_url=client_url, + currency_symbol=currency_symbol ) await send_email( to=payment.booking.user.email, @@ -325,10 +460,22 @@ async def update_payment_status( # If this is a deposit payment, update booking deposit_paid status if payment.payment_type == PaymentType.deposit and payment.booking: payment.booking.deposit_paid = True - # Optionally auto-confirm booking if deposit is paid - if payment.booking.status == BookingStatus.pending: + # Restore cancelled bookings or confirm pending bookings when deposit is paid + if payment.booking.status in [BookingStatus.pending, BookingStatus.cancelled]: payment.booking.status = BookingStatus.confirmed db.commit() + # If this is a full payment, also restore cancelled bookings + elif payment.payment_type == PaymentType.full and payment.booking: + # Calculate total paid from all completed payments + total_paid = sum( + float(p.amount) for p in payment.booking.payments + if p.payment_status == PaymentStatus.completed + ) + # Confirm booking if fully paid, and restore cancelled bookings + if total_paid >= float(payment.booking.total_price): + if payment.booking.status in [BookingStatus.pending, BookingStatus.cancelled]: + payment.booking.status = BookingStatus.confirmed + db.commit() except Exception as e: print(f"Failed to send payment confirmation email: {e}") @@ -395,6 +542,29 @@ async def create_stripe_payment_intent( if current_user.role_id != 1 and booking.user_id != current_user.id: raise HTTPException(status_code=403, detail="Forbidden") + # For deposit payments, verify the amount matches the deposit payment record + # This ensures users are only charged the deposit (20%) and not the full amount + if booking.requires_deposit and not booking.deposit_paid: + deposit_payment = db.query(Payment).filter( + Payment.booking_id == booking_id, + Payment.payment_type == PaymentType.deposit, + Payment.payment_status == PaymentStatus.pending + ).order_by(Payment.created_at.desc()).first() + + if deposit_payment: + expected_deposit_amount = float(deposit_payment.amount) + # Allow small floating point differences (0.01) + if abs(amount - expected_deposit_amount) > 0.01: + logger.warning( + f"Amount mismatch for deposit payment: " + f"Requested ${amount:,.2f}, Expected deposit ${expected_deposit_amount:,.2f}, " + f"Booking total ${float(booking.total_price):,.2f}" + ) + raise HTTPException( + status_code=400, + detail=f"For pay-on-arrival bookings, only the deposit amount (${expected_deposit_amount:,.2f}) should be charged, not the full booking amount (${float(booking.total_price):,.2f})." + ) + # Create payment intent intent = StripeService.create_payment_intent( amount=amount, @@ -472,7 +642,7 @@ async def confirm_stripe_payment( ) # Confirm payment (this commits the transaction internally) - payment = StripeService.confirm_payment( + payment = await StripeService.confirm_payment( payment_intent_id=payment_intent_id, db=db, booking_id=booking_id @@ -495,14 +665,34 @@ async def confirm_stripe_payment( # This won't affect the transaction since it's already committed if booking and booking.user: try: - client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") + from ..models.system_settings import SystemSettings + + # Get client URL from settings + client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first() + client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")) + + # Get platform currency for email + currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first() + currency = currency_setting.value if currency_setting and currency_setting.value else "USD" + + # Get currency symbol + currency_symbols = { + "USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥", + "KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$", + "VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$" + } + currency_symbol = currency_symbols.get(currency, currency) + email_html = payment_confirmation_email_template( booking_number=booking.booking_number, guest_name=booking.user.full_name, amount=payment["amount"], payment_method="stripe", transaction_id=payment["transaction_id"], - client_url=client_url + payment_type=payment.get("payment_type"), + total_price=float(booking.total_price), + client_url=client_url, + currency_symbol=currency_symbol ) await send_email( to=booking.user.email, @@ -574,7 +764,7 @@ async def stripe_webhook( detail="Missing stripe-signature header" ) - result = StripeService.handle_webhook( + result = await StripeService.handle_webhook( payload=payload, signature=signature, db=db @@ -640,6 +830,31 @@ async def create_paypal_order( if current_user.role_id != 1 and booking.user_id != current_user.id: raise HTTPException(status_code=403, detail="Forbidden") + # For deposit payments, verify the amount matches the deposit payment record + # This ensures users are only charged the deposit (20%) and not the full amount + if booking.requires_deposit and not booking.deposit_paid: + deposit_payment = db.query(Payment).filter( + Payment.booking_id == booking_id, + Payment.payment_type == PaymentType.deposit, + Payment.payment_status == PaymentStatus.pending + ).order_by(Payment.created_at.desc()).first() + + if deposit_payment: + expected_deposit_amount = float(deposit_payment.amount) + # Allow small floating point differences (0.01) + if abs(amount - expected_deposit_amount) > 0.01: + import logging + logger = logging.getLogger(__name__) + logger.warning( + f"Amount mismatch for deposit payment: " + f"Requested ${amount:,.2f}, Expected deposit ${expected_deposit_amount:,.2f}, " + f"Booking total ${float(booking.total_price):,.2f}" + ) + raise HTTPException( + status_code=400, + detail=f"For pay-on-arrival bookings, only the deposit amount (${expected_deposit_amount:,.2f}) should be charged, not the full booking amount (${float(booking.total_price):,.2f})." + ) + # Get return URLs from request or use defaults client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") return_url = order_data.get("return_url", f"{client_url}/payment/paypal/return") @@ -689,6 +904,63 @@ async def create_paypal_order( raise HTTPException(status_code=500, detail=str(e)) +@router.post("/paypal/cancel") +async def cancel_paypal_payment( + payment_data: dict, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Mark PayPal payment as failed and cancel booking when user cancels on PayPal""" + try: + booking_id = payment_data.get("booking_id") + + if not booking_id: + raise HTTPException( + status_code=400, + detail="booking_id is required" + ) + + # Find pending PayPal payment for this booking + payment = db.query(Payment).filter( + Payment.booking_id == booking_id, + Payment.payment_method == PaymentMethod.paypal, + Payment.payment_status == PaymentStatus.pending + ).order_by(Payment.created_at.desc()).first() + + # Also check for deposit payments + if not payment: + payment = db.query(Payment).filter( + Payment.booking_id == booking_id, + Payment.payment_type == PaymentType.deposit, + Payment.payment_status == PaymentStatus.pending + ).order_by(Payment.created_at.desc()).first() + + if payment: + payment.payment_status = PaymentStatus.failed + db.commit() + db.refresh(payment) + + # Auto-cancel booking + booking = db.query(Booking).filter(Booking.id == booking_id).first() + if booking and booking.status != BookingStatus.cancelled: + await cancel_booking_on_payment_failure( + booking, + db, + reason="PayPal payment canceled by user" + ) + + return { + "status": "success", + "message": "Payment canceled and booking cancelled" + } + except HTTPException: + db.rollback() + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + + @router.post("/paypal/capture") async def capture_paypal_payment( payment_data: dict, @@ -707,7 +979,7 @@ async def capture_paypal_payment( ) # Confirm payment (this commits the transaction internally) - payment = PayPalService.confirm_payment( + payment = await PayPalService.confirm_payment( order_id=order_id, db=db, booking_id=booking_id @@ -727,14 +999,34 @@ async def capture_paypal_payment( # Send payment confirmation email (non-blocking) if booking and booking.user: try: - client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") + from ..models.system_settings import SystemSettings + + # Get client URL from settings + client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first() + client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")) + + # Get platform currency for email + currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first() + currency = currency_setting.value if currency_setting and currency_setting.value else "USD" + + # Get currency symbol + currency_symbols = { + "USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥", + "KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$", + "VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$" + } + currency_symbol = currency_symbols.get(currency, currency) + email_html = payment_confirmation_email_template( booking_number=booking.booking_number, guest_name=booking.user.full_name, amount=payment["amount"], payment_method="paypal", transaction_id=payment["transaction_id"], - client_url=client_url + payment_type=payment.get("payment_type"), + total_price=float(booking.total_price), + client_url=client_url, + currency_symbol=currency_symbol ) await send_email( to=booking.user.email, diff --git a/Backend/src/routes/promotion_routes.py b/Backend/src/routes/promotion_routes.py index 135d55bf..6a525dbf 100644 --- a/Backend/src/routes/promotion_routes.py +++ b/Backend/src/routes/promotion_routes.py @@ -128,7 +128,8 @@ async def validate_promotion( """Validate and apply promotion""" try: code = validation_data.get("code") - booking_amount = float(validation_data.get("booking_amount", 0)) + # Accept both booking_value (from frontend) and booking_amount (for backward compatibility) + booking_amount = float(validation_data.get("booking_value") or validation_data.get("booking_amount", 0)) promotion = db.query(Promotion).filter(Promotion.code == code).first() if not promotion: @@ -161,17 +162,30 @@ async def validate_promotion( final_amount = booking_amount - discount_amount return { + "success": True, "status": "success", "data": { "promotion": { "id": promotion.id, "code": promotion.code, "name": promotion.name, + "description": promotion.description, + "discount_type": promotion.discount_type.value if hasattr(promotion.discount_type, 'value') else str(promotion.discount_type), + "discount_value": float(promotion.discount_value) if promotion.discount_value else 0, + "min_booking_amount": float(promotion.min_booking_amount) if promotion.min_booking_amount else None, + "max_discount_amount": float(promotion.max_discount_amount) if promotion.max_discount_amount else None, + "start_date": promotion.start_date.isoformat() if promotion.start_date else None, + "end_date": promotion.end_date.isoformat() if promotion.end_date else None, + "usage_limit": promotion.usage_limit, + "used_count": promotion.used_count, + "status": "active" if promotion.is_active else "inactive", }, + "discount": discount_amount, "original_amount": booking_amount, "discount_amount": discount_amount, "final_amount": final_amount, - } + }, + "message": "Promotion validated successfully" } except HTTPException: raise diff --git a/Backend/src/routes/system_settings_routes.py b/Backend/src/routes/system_settings_routes.py index ec599fd4..2ee87661 100644 --- a/Backend/src/routes/system_settings_routes.py +++ b/Backend/src/routes/system_settings_routes.py @@ -895,6 +895,7 @@ class UpdateCompanySettingsRequest(BaseModel): company_phone: Optional[str] = None company_email: Optional[str] = None company_address: Optional[str] = None + tax_rate: Optional[float] = None @router.get("/company") @@ -911,6 +912,7 @@ async def get_company_settings( "company_phone", "company_email", "company_address", + "tax_rate", ] settings_dict = {} @@ -944,6 +946,7 @@ async def get_company_settings( "company_phone": settings_dict.get("company_phone", ""), "company_email": settings_dict.get("company_email", ""), "company_address": settings_dict.get("company_address", ""), + "tax_rate": float(settings_dict.get("tax_rate", 0)) if settings_dict.get("tax_rate") else 0.0, "updated_at": updated_at, "updated_by": updated_by, } @@ -972,6 +975,8 @@ async def update_company_settings( db_settings["company_email"] = request_data.company_email if request_data.company_address is not None: db_settings["company_address"] = request_data.company_address + if request_data.tax_rate is not None: + db_settings["tax_rate"] = str(request_data.tax_rate) for key, value in db_settings.items(): # Find or create setting @@ -997,7 +1002,7 @@ async def update_company_settings( # Get updated settings updated_settings = {} - for key in ["company_name", "company_tagline", "company_logo_url", "company_favicon_url", "company_phone", "company_email", "company_address"]: + for key in ["company_name", "company_tagline", "company_logo_url", "company_favicon_url", "company_phone", "company_email", "company_address", "tax_rate"]: setting = db.query(SystemSettings).filter( SystemSettings.key == key ).first() @@ -1032,6 +1037,7 @@ async def update_company_settings( "company_phone": updated_settings.get("company_phone", ""), "company_email": updated_settings.get("company_email", ""), "company_address": updated_settings.get("company_address", ""), + "tax_rate": float(updated_settings.get("tax_rate", 0)) if updated_settings.get("tax_rate") else 0.0, "updated_at": updated_at, "updated_by": updated_by, } @@ -1243,3 +1249,272 @@ async def upload_company_favicon( logger.error(f"Error uploading favicon: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/recaptcha") +async def get_recaptcha_settings( + db: Session = Depends(get_db) +): + """Get reCAPTCHA settings (Public endpoint for frontend)""" + try: + site_key_setting = db.query(SystemSettings).filter( + SystemSettings.key == "recaptcha_site_key" + ).first() + + enabled_setting = db.query(SystemSettings).filter( + SystemSettings.key == "recaptcha_enabled" + ).first() + + result = { + "recaptcha_site_key": "", + "recaptcha_enabled": False, + } + + if site_key_setting: + result["recaptcha_site_key"] = site_key_setting.value or "" + + if enabled_setting: + result["recaptcha_enabled"] = enabled_setting.value.lower() == "true" if enabled_setting.value else False + + return { + "status": "success", + "data": result + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/recaptcha/admin") +async def get_recaptcha_settings_admin( + current_user: User = Depends(authorize_roles("admin")), + db: Session = Depends(get_db) +): + """Get reCAPTCHA settings (Admin only - includes secret key)""" + try: + site_key_setting = db.query(SystemSettings).filter( + SystemSettings.key == "recaptcha_site_key" + ).first() + + secret_key_setting = db.query(SystemSettings).filter( + SystemSettings.key == "recaptcha_secret_key" + ).first() + + enabled_setting = db.query(SystemSettings).filter( + SystemSettings.key == "recaptcha_enabled" + ).first() + + # Mask secret for security (only show last 4 characters) + def mask_key(key_value: str) -> str: + if not key_value or len(key_value) < 4: + return "" + return "*" * (len(key_value) - 4) + key_value[-4:] + + result = { + "recaptcha_site_key": "", + "recaptcha_secret_key": "", + "recaptcha_secret_key_masked": "", + "recaptcha_enabled": False, + "has_site_key": False, + "has_secret_key": False, + } + + if site_key_setting: + result["recaptcha_site_key"] = site_key_setting.value or "" + result["has_site_key"] = bool(site_key_setting.value) + result["updated_at"] = site_key_setting.updated_at.isoformat() if site_key_setting.updated_at else None + result["updated_by"] = site_key_setting.updated_by.full_name if site_key_setting.updated_by else None + + if secret_key_setting: + result["recaptcha_secret_key"] = secret_key_setting.value or "" + result["recaptcha_secret_key_masked"] = mask_key(secret_key_setting.value or "") + result["has_secret_key"] = bool(secret_key_setting.value) + + if enabled_setting: + result["recaptcha_enabled"] = enabled_setting.value.lower() == "true" if enabled_setting.value else False + + return { + "status": "success", + "data": result + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/recaptcha") +async def update_recaptcha_settings( + recaptcha_data: dict, + current_user: User = Depends(authorize_roles("admin")), + db: Session = Depends(get_db) +): + """Update reCAPTCHA settings (Admin only)""" + try: + site_key = recaptcha_data.get("recaptcha_site_key", "").strip() + secret_key = recaptcha_data.get("recaptcha_secret_key", "").strip() + enabled = recaptcha_data.get("recaptcha_enabled", False) + + # Update or create site key setting + if site_key: + setting = db.query(SystemSettings).filter( + SystemSettings.key == "recaptcha_site_key" + ).first() + + if setting: + setting.value = site_key + setting.updated_by_id = current_user.id + else: + setting = SystemSettings( + key="recaptcha_site_key", + value=site_key, + description="Google reCAPTCHA site key for frontend", + updated_by_id=current_user.id + ) + db.add(setting) + + # Update or create secret key setting + if secret_key: + setting = db.query(SystemSettings).filter( + SystemSettings.key == "recaptcha_secret_key" + ).first() + + if setting: + setting.value = secret_key + setting.updated_by_id = current_user.id + else: + setting = SystemSettings( + key="recaptcha_secret_key", + value=secret_key, + description="Google reCAPTCHA secret key for backend verification", + updated_by_id=current_user.id + ) + db.add(setting) + + # Update or create enabled setting + setting = db.query(SystemSettings).filter( + SystemSettings.key == "recaptcha_enabled" + ).first() + + if setting: + setting.value = str(enabled).lower() + setting.updated_by_id = current_user.id + else: + setting = SystemSettings( + key="recaptcha_enabled", + value=str(enabled).lower(), + description="Enable or disable reCAPTCHA verification", + updated_by_id=current_user.id + ) + db.add(setting) + + db.commit() + + # Return masked values + def mask_key(key_value: str) -> str: + if not key_value or len(key_value) < 4: + return "" + return "*" * (len(key_value) - 4) + key_value[-4:] + + return { + "status": "success", + "message": "reCAPTCHA settings updated successfully", + "data": { + "recaptcha_site_key": site_key if site_key else "", + "recaptcha_secret_key": secret_key if secret_key else "", + "recaptcha_secret_key_masked": mask_key(secret_key) if secret_key else "", + "recaptcha_enabled": enabled, + "has_site_key": bool(site_key), + "has_secret_key": bool(secret_key), + } + } + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/recaptcha/verify") +async def verify_recaptcha( + verification_data: dict, + db: Session = Depends(get_db) +): + """Verify reCAPTCHA token (Public endpoint)""" + try: + token = verification_data.get("token", "").strip() + + if not token: + raise HTTPException( + status_code=400, + detail="reCAPTCHA token is required" + ) + + # Get reCAPTCHA settings + enabled_setting = db.query(SystemSettings).filter( + SystemSettings.key == "recaptcha_enabled" + ).first() + + secret_key_setting = db.query(SystemSettings).filter( + SystemSettings.key == "recaptcha_secret_key" + ).first() + + # Check if reCAPTCHA is enabled + is_enabled = False + if enabled_setting: + is_enabled = enabled_setting.value.lower() == "true" if enabled_setting.value else False + + if not is_enabled: + # If disabled, always return success + return { + "status": "success", + "data": { + "verified": True, + "message": "reCAPTCHA is disabled" + } + } + + if not secret_key_setting or not secret_key_setting.value: + raise HTTPException( + status_code=500, + detail="reCAPTCHA secret key is not configured" + ) + + # Verify with Google reCAPTCHA API + import httpx + + async with httpx.AsyncClient() as client: + response = await client.post( + "https://www.google.com/recaptcha/api/siteverify", + data={ + "secret": secret_key_setting.value, + "response": token + }, + timeout=10.0 + ) + + result = response.json() + + if result.get("success"): + return { + "status": "success", + "data": { + "verified": True, + "score": result.get("score"), # For v3 + "action": result.get("action") # For v3 + } + } + else: + error_codes = result.get("error-codes", []) + return { + "status": "error", + "data": { + "verified": False, + "error_codes": error_codes + } + } + except httpx.TimeoutException: + raise HTTPException( + status_code=408, + detail="reCAPTCHA verification timeout" + ) + except Exception as e: + logger.error(f"Error verifying reCAPTCHA: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + diff --git a/Backend/src/services/__pycache__/invoice_service.cpython-312.pyc b/Backend/src/services/__pycache__/invoice_service.cpython-312.pyc index f61ce899d0ddd9c5397a7739ecac83ff84fe736b..cc230db6afdbdb3007e22970023c433a7632122d 100644 GIT binary patch delta 6441 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 fea#U?4|a>RV_dHENo?9DTMaS*{%?J!U?z1SKc$ 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 Uf3ELv&SM&Sh`vveg1G1Z04NcvwEzGB delta 3617 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 I`(bAO1y1oqa{vGU diff --git a/Backend/src/services/__pycache__/stripe_service.cpython-312.pyc b/Backend/src/services/__pycache__/stripe_service.cpython-312.pyc index 7e4239393b6643b1bf0fee9e10b0454b0b5365d2..015ef5b64882c0f097b72318e99f20240ff9fa0f 100644 GIT binary patch delta 12097 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 delta 4824 zcmbUlZERE5^<6*P&)*-hoe$e_oDk?tAP~NrkPre1l+Slxzs&~CSIFv{8j1H0PKIVwl4!|WwadJ zF3rdIv_7U!8)62+iF(nHGNy%?z;I*Als3oAX-mwKw#KY!Tg=AjLdu?Y#2jg7%*p1a zlq>CyxdA7lIaQYS#5@eQq`YZw%nSG)tK<=F5-<6xz>vdw(SAlBD;FQ8I)Ga76{-`c zl{#({C%VpXqWcXUy2f#_D!PCiAQ5_o>?e`2+vGz+9CVal7!Y7pbw_)4Ki1tDk9Ka@ z-Psf0+S&WMPIl92K5M9fb;sC%ewA*!4^Y{Gpd3L3f=UEvr|d_-q?OM|>)z9`?Xl?Q z^&Q(fQKTFMjw~7!4$C3>nK5jrhe?pSh3}B?*ahJt+4jGx@Xe@ryt8BT&YfFl41Y+& zDRYhC8=zq!XjnwAnfHU1MV34xS64%%NC4`6M^upq zhNQ40%Y$-r_;oJgmN6X6OF2N>+#5Sq;(Qf?)d(JJR;|U&ow!%!vx*$C%&y9dk+~6p zoBqzdg@mZHY#nJC+gTPcj4TDZD)jXyy%5R0msfGcABI^9L- zTT%KNWb%|+jnrlYEeO^kSO-8g$r5-gJte7oh5ke~p(wJ9B2?jsq_7$DayxFA>uOQm zdR*uL5HZP*AiY<8byiF3*A9_FkouW8RK|W z*~o#2INn({GH)vwW;T+`G;H>pH|6xn74&U`ClCY-eSc_VUCL7W%&s8aQDe83nsY4b zu{%o)Y^KlF)WA<~#whwhO)qIhqv?sTjVH-<^i;@C4~7k7{S4JXKdJNBN<`GtgVp7x zvqbjiNRn~c^>2`(L4?a0=_gUIu4qmr3sAG@>U;&1CRkBTaOBtaub`B%4vBLw;46 zC2dCaPJR4=GnsQm(YDf9756C$UJf?IGWz%MB3I6;?9@o0ubbX}#P2=&1e|w6$$~N2 zhzpJq(hP4I^2)ydW)C?3P_QaW!XSFiaCw^{iaj0Khq5xqJt zX9E{s@pXg2(@+L)%Hv7HE ziDBi4HlLLX9`Sj1NuX6p{L$EPs?3 zS$6KB>sBlwtLfm1OT{9wm%3Lr8S%8`2D*9WUeZHd{aqy{6qQ6`hjAHJCB088#9ZK{aW!;c!R^&vVgmcw$cu-C! zl(_VCAFGn8UO6!&sit^5lSoVPxT=?>Avsc3QagKSb-Uk-QCEc-?M3%vbZdK67{j4j zX3W5@U;qiagd}5;l}G4%>qC`gfNG_7_6v6**s>cXG6PGSW64X}ez0b|bQCGSWUCX+JMqc1-z#lfKBf zFEYA+a&hbU;@0cF)<4(QPqPc%v}Uqt{dm**dw6T_a(rF+*)0VZz1X$XSt8_;qhO@} z-g24${GNBrx~+|Dy`Sv_+fMJOj)u8Egga_>aojj(j#l&I<_>4HlAowFz+|GDhxLiD zd2bm%5phSG`H5y5;2#+~guQnDk9Gr0=;9soO{}MY-w}OsN09!*8gAuteAcQ6po9&P zXesM7F_)ynBzqZXT*YtF4#ma0Hb-*&8z>h>UY~=<0QM=Ok(J*)Fc;X|gfMS}bZ=%D zG$Bb3o%VX%mH|#n*U)U3iqvYWW=P(gGs~ZXF<#VwBUyn}Eh^-63W{PQXV&;fZJITN z%l!I*Jx*rNFaBS9Jag^&c21Y9!A8Opo-u&a^e5-B2IB#}*p~?c>Q};asxD|NHJkpX z+Xu-AN{3*{S>*Z3LK>bIbmc6wEgI-*!YZ^8-b60VA^5UDitbB1tG-6f5GY;2yrJYw zb&6A4%Ngjco+`m`(g3B*L~j{_p|7y(A7&q6>{@AnD(Tn6!QOgMRjtiMd(I5C%26so ziwhK-k{+d(6Zt&Iec}1yceV;g%D(xfMf-_vPGrQ?*sFVgO2{+xiG88SHc+J+A(=~k zN-?JvYZQ>-X)xPw_t4k&HINkjXkVRO6|%#9eNr|H9INS@z5a?S84hFS`SJ*2EMvFO zKlO%42fg1L9!VpW<@Og4V`&}TmNN)gZf6Oc<#v|9St@6*Kvn~dhZ6%SNi3%Cr(jdW zDt{06ejfq82~@{%=}`aR;L&(i>VrpzYJf)v6b8&%t5nBKri(+ZKq_EqM8xKVDZ5IN!2=a(Z8R!NoZ|XIDLc;998ly1kXQKJlm?Hs7YN zY$&I;153y>U3(xDst8Z{YhE^9SUgo(eO|m^ofh;iOM%myEuY`pN$B*GTSvYGLn>?U zaD-{Cx)xmj&cb(s@9+MoJ9@2W&qVj$_n*Gz-#6*)9ryNrS>TcFeirNCXl>_8?zg7e zO&0FmmF7(X|884FmydtXR}T1l4HcVo{8gO+@T(T{rq%pakAKrL{^~LV;1Rvb4`;<4 zJ9fyXgCGjttf%RfgDW40x>bCj$(Xfd>>y+V0{oT8n-O3=FP=G`zG^shLXooaMWjB8 zAc-J^fL##;YiL8_gJ44Ddq3OvJZ+dKsW!wN>uSA9NuZhJu}&P|-f@m$~8<lrc}2TB~pF;Qu;(A6!&5rv14HczWC;@$0_^Kv53u$;(gOv&^w%x+T_=O z58Jft831q-2$|ODi2kmTBaU0#f^lxaO>V(0Zs!EI^A@+ str: +def generate_invoice_number(db: Session, is_proforma: bool = False) -> str: """Generate a unique invoice number""" - # Format: INV-YYYYMMDD-XXXX + # Format: INV-YYYYMMDD-XXXX or PRO-YYYYMMDD-XXXX for proforma + prefix = "PRO" if is_proforma else "INV" today = datetime.utcnow().strftime("%Y%m%d") # Get the last invoice number for today last_invoice = db.query(Invoice).filter( - Invoice.invoice_number.like(f"INV-{today}-%") + Invoice.invoice_number.like(f"{prefix}-{today}-%") ).order_by(Invoice.invoice_number.desc()).first() if last_invoice: @@ -31,7 +32,7 @@ def generate_invoice_number(db: Session) -> str: else: sequence = 1 - return f"INV-{today}-{sequence:04d}" + return f"{prefix}-{today}-{sequence:04d}" class InvoiceService: @@ -45,6 +46,8 @@ class InvoiceService: tax_rate: float = 0.0, discount_amount: float = 0.0, due_days: int = 30, + is_proforma: bool = False, + invoice_amount: Optional[float] = None, # For partial invoices (e.g., deposit) **kwargs ) -> Dict[str, Any]: """ @@ -77,11 +80,17 @@ class InvoiceService: raise ValueError("User not found") # Generate invoice number - invoice_number = generate_invoice_number(db) + invoice_number = generate_invoice_number(db, is_proforma=is_proforma) + + # If invoice_amount is specified, we need to adjust item calculations + # This will be handled in the item creation section below # Calculate amounts - subtotal will be recalculated after adding items - # Initial subtotal is booking total (room + services) - subtotal = float(booking.total_price) + # Initial subtotal is booking total (room + services) or invoice_amount if specified + if invoice_amount is not None: + subtotal = float(invoice_amount) + else: + subtotal = float(booking.total_price) # Calculate tax and total amounts tax_amount = (subtotal - discount_amount) * (tax_rate / 100) @@ -121,6 +130,7 @@ class InvoiceService: amount_paid=amount_paid, balance_due=balance_due, status=status, + is_proforma=is_proforma, company_name=kwargs.get("company_name"), company_address=kwargs.get("company_address"), company_phone=kwargs.get("company_phone"), @@ -146,17 +156,28 @@ class InvoiceService: services_total = sum( float(su.total_price) for su in booking.service_usages ) - room_price = float(booking.total_price) - services_total + booking_total = float(booking.total_price) + room_price = booking_total - services_total # Calculate number of nights nights = (booking.check_out_date - booking.check_in_date).days if nights <= 0: nights = 1 + # If invoice_amount is specified (for partial invoices), calculate proportion + if invoice_amount is not None and invoice_amount < booking_total: + # Calculate proportion for partial invoice + proportion = float(invoice_amount) / booking_total + room_price = room_price * proportion + services_total = services_total * proportion + item_description_suffix = f" (Partial: {proportion * 100:.0f}%)" + else: + item_description_suffix = "" + # Room item room_item = InvoiceItem( invoice_id=invoice.id, - description=f"Room: {booking.room.room_number} - {booking.room.room_type.name if booking.room.room_type else 'N/A'} ({nights} night{'s' if nights > 1 else ''})", + description=f"Room: {booking.room.room_number} - {booking.room.room_type.name if booking.room.room_type else 'N/A'} ({nights} night{'s' if nights > 1 else ''}){item_description_suffix}", quantity=nights, unit_price=room_price / nights if nights > 0 else room_price, tax_rate=tax_rate, @@ -168,14 +189,20 @@ class InvoiceService: # Add service items if any for service_usage in booking.service_usages: + service_item_price = float(service_usage.total_price) + if invoice_amount is not None and invoice_amount < booking_total: + # Apply proportion to service items + proportion = float(invoice_amount) / booking_total + service_item_price = service_item_price * proportion + service_item = InvoiceItem( invoice_id=invoice.id, - description=f"Service: {service_usage.service.name}", + description=f"Service: {service_usage.service.name}{item_description_suffix}", quantity=float(service_usage.quantity), - unit_price=float(service_usage.unit_price), + unit_price=service_item_price / float(service_usage.quantity) if service_usage.quantity > 0 else service_item_price, tax_rate=tax_rate, discount_amount=0.0, - line_total=float(service_usage.total_price), + line_total=service_item_price, service_id=service_usage.service_id, ) db.add(service_item) @@ -391,6 +418,7 @@ class InvoiceService: "notes": invoice.notes, "terms_and_conditions": invoice.terms_and_conditions, "payment_instructions": invoice.payment_instructions, + "is_proforma": invoice.is_proforma if hasattr(invoice, 'is_proforma') else False, "items": [ { "id": item.id, diff --git a/Backend/src/services/paypal_service.py b/Backend/src/services/paypal_service.py index ba3696bc..d613741b 100644 --- a/Backend/src/services/paypal_service.py +++ b/Backend/src/services/paypal_service.py @@ -1,6 +1,7 @@ """ PayPal payment service for processing PayPal payments """ +import logging from paypalcheckoutsdk.core import PayPalHttpClient, SandboxEnvironment, LiveEnvironment from paypalcheckoutsdk.orders import OrdersCreateRequest, OrdersGetRequest, OrdersCaptureRequest from paypalcheckoutsdk.payments import CapturesRefundRequest @@ -13,6 +14,8 @@ from sqlalchemy.orm import Session from datetime import datetime import json +logger = logging.getLogger(__name__) + def get_paypal_client_id(db: Session) -> Optional[str]: """Get PayPal client ID from database or environment variable""" @@ -282,7 +285,7 @@ class PayPalService: raise ValueError(f"PayPal error: {error_msg}") @staticmethod - def confirm_payment( + async def confirm_payment( order_id: str, db: Session, booking_id: Optional[int] = None @@ -337,6 +340,15 @@ class PayPalService: Payment.payment_status == PaymentStatus.pending ).order_by(Payment.created_at.desc()).first() + # If still not found, try to find pending deposit payment (for cash bookings with deposit) + # This allows updating the payment_method from the default to paypal + if not payment: + payment = db.query(Payment).filter( + Payment.booking_id == booking_id, + Payment.payment_type == PaymentType.deposit, + Payment.payment_status == PaymentStatus.pending + ).order_by(Payment.created_at.desc()).first() + amount = capture_data["amount"] capture_id = capture_data.get("capture_id") @@ -347,6 +359,7 @@ class PayPalService: payment.payment_date = datetime.utcnow() # If pending, keep as pending payment.amount = amount + payment.payment_method = PaymentMethod.paypal # Update payment method to PayPal if capture_id: payment.transaction_id = f"{order_id}|{capture_id}" else: @@ -380,18 +393,142 @@ class PayPalService: if payment.payment_status == PaymentStatus.completed: db.refresh(booking) + # Calculate total paid from all completed payments (now includes current payment) + # This needs to be calculated before the if/elif blocks + total_paid = sum( + float(p.amount) for p in booking.payments + if p.payment_status == PaymentStatus.completed + ) + + # Update invoice status based on payment + from ..models.invoice import Invoice, InvoiceStatus + + # Find invoices for this booking and update their status + invoices = db.query(Invoice).filter(Invoice.booking_id == booking_id).all() + for invoice in invoices: + # Update invoice amount_paid and balance_due + invoice.amount_paid = total_paid + invoice.balance_due = float(invoice.total_amount) - total_paid + + # Update invoice status + if invoice.balance_due <= 0: + invoice.status = InvoiceStatus.paid + invoice.paid_date = datetime.utcnow() + elif invoice.amount_paid > 0: + invoice.status = InvoiceStatus.sent + + booking_was_confirmed = False + should_send_email = False if payment.payment_type == PaymentType.deposit: booking.deposit_paid = True - if booking.status == BookingStatus.pending: + # Restore cancelled bookings or confirm pending bookings + if booking.status in [BookingStatus.pending, BookingStatus.cancelled]: booking.status = BookingStatus.confirmed + booking_was_confirmed = True + should_send_email = True + elif booking.status == BookingStatus.confirmed: + # Booking already confirmed, but deposit was just paid + should_send_email = True elif payment.payment_type == PaymentType.full: - total_paid = sum( - float(p.amount) for p in booking.payments - if p.payment_status == PaymentStatus.completed - ) - + # Confirm booking and restore cancelled bookings when payment succeeds if total_paid >= float(booking.total_price) or float(payment.amount) >= float(booking.total_price): - booking.status = BookingStatus.confirmed + if booking.status in [BookingStatus.pending, BookingStatus.cancelled]: + booking.status = BookingStatus.confirmed + booking_was_confirmed = True + should_send_email = True + elif booking.status == BookingStatus.confirmed: + # Booking already confirmed, but full payment was just completed + should_send_email = True + + # Send booking confirmation email if booking was just confirmed or payment completed + if should_send_email: + try: + from ..utils.mailer import send_email + from ..utils.email_templates import booking_confirmation_email_template + from ..models.system_settings import SystemSettings + from ..models.room import Room + from sqlalchemy.orm import selectinload + import os + from ..config.settings import settings + + # Get client URL from settings + client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first() + client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")) + + # Get platform currency for email + currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first() + currency = currency_setting.value if currency_setting and currency_setting.value else "USD" + + # Get currency symbol + currency_symbols = { + "USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥", + "KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$", + "VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$" + } + currency_symbol = currency_symbols.get(currency, currency) + + # Load booking with room details for email + booking_with_room = db.query(Booking).options( + selectinload(Booking.room).selectinload(Room.room_type) + ).filter(Booking.id == booking_id).first() + + room = booking_with_room.room if booking_with_room else None + room_type_name = room.room_type.name if room and room.room_type else "Room" + + # Calculate amount paid and remaining due + amount_paid = total_paid + payment_type_str = payment.payment_type.value if payment.payment_type else None + + email_html = booking_confirmation_email_template( + booking_number=booking.booking_number, + guest_name=booking.user.full_name if booking.user else "Guest", + room_number=room.room_number if room else "N/A", + room_type=room_type_name, + check_in=booking.check_in_date.strftime("%B %d, %Y") if booking.check_in_date else "N/A", + check_out=booking.check_out_date.strftime("%B %d, %Y") if booking.check_out_date else "N/A", + num_guests=booking.num_guests, + total_price=float(booking.total_price), + requires_deposit=False, # Payment completed, no deposit message needed + deposit_amount=None, + amount_paid=amount_paid, + payment_type=payment_type_str, + client_url=client_url, + currency_symbol=currency_symbol + ) + if booking.user: + await send_email( + to=booking.user.email, + subject=f"Booking Confirmed - {booking.booking_number}", + html=email_html + ) + logger.info(f"Booking confirmation email sent to {booking.user.email}") + except Exception as email_error: + logger.error(f"Failed to send booking confirmation email: {str(email_error)}") + + # Send invoice email if payment is completed and invoice is now paid + from ..utils.mailer import send_email + from ..services.invoice_service import InvoiceService + from ..models.invoice import InvoiceStatus + + # Load user for email + from ..models.user import User + user = db.query(User).filter(User.id == booking.user_id).first() + + for invoice in invoices: + if invoice.status == InvoiceStatus.paid and invoice.balance_due <= 0: + try: + invoice_dict = InvoiceService.invoice_to_dict(invoice) + invoice_html = _generate_invoice_email_html(invoice_dict, is_proforma=invoice.is_proforma) + invoice_type = "Proforma Invoice" if invoice.is_proforma else "Invoice" + if user: + await send_email( + to=user.email, + subject=f"{invoice_type} {invoice.invoice_number} - Payment Confirmed", + html=invoice_html + ) + logger.info(f"{invoice_type} {invoice.invoice_number} sent to {user.email}") + except Exception as email_error: + logger.error(f"Failed to send invoice email: {str(email_error)}") db.commit() db.refresh(booking) diff --git a/Backend/src/services/stripe_service.py b/Backend/src/services/stripe_service.py index b27e562e..b63fa304 100644 --- a/Backend/src/services/stripe_service.py +++ b/Backend/src/services/stripe_service.py @@ -1,6 +1,7 @@ """ Stripe payment service for processing card payments """ +import logging import stripe from typing import Optional, Dict, Any from ..config.settings import settings @@ -10,6 +11,8 @@ from ..models.system_settings import SystemSettings from sqlalchemy.orm import Session from datetime import datetime +logger = logging.getLogger(__name__) + def get_stripe_secret_key(db: Session) -> Optional[str]: """Get Stripe secret key from database or environment variable""" @@ -183,7 +186,7 @@ class StripeService: raise ValueError(f"Stripe error: {str(e)}") @staticmethod - def confirm_payment( + async def confirm_payment( payment_intent_id: str, db: Session, booking_id: Optional[int] = None @@ -230,6 +233,15 @@ class StripeService: Payment.payment_method == PaymentMethod.stripe ).first() + # If not found, try to find pending deposit payment (for cash bookings with deposit) + # This allows updating the payment_method from the default to stripe + if not payment: + payment = db.query(Payment).filter( + Payment.booking_id == booking_id, + Payment.payment_type == PaymentType.deposit, + Payment.payment_status == PaymentStatus.pending + ).order_by(Payment.created_at.desc()).first() + amount = intent_data["amount"] if payment: @@ -240,6 +252,7 @@ class StripeService: payment.payment_date = datetime.utcnow() # If processing, keep as pending (will be updated by webhook) payment.amount = amount + payment.payment_method = PaymentMethod.stripe # Update payment method to Stripe else: # Create new payment record payment_type = PaymentType.full @@ -271,25 +284,148 @@ class StripeService: # Refresh booking to get updated payments relationship db.refresh(booking) + # Calculate total paid from all completed payments (now includes current payment) + # This needs to be calculated before the if/elif blocks + total_paid = sum( + float(p.amount) for p in booking.payments + if p.payment_status == PaymentStatus.completed + ) + + # Update invoice status based on payment + from ..models.invoice import Invoice, InvoiceStatus + from ..services.invoice_service import InvoiceService + + # Find invoices for this booking and update their status + invoices = db.query(Invoice).filter(Invoice.booking_id == booking_id).all() + for invoice in invoices: + # Update invoice amount_paid and balance_due + invoice.amount_paid = total_paid + invoice.balance_due = float(invoice.total_amount) - total_paid + + # Update invoice status + if invoice.balance_due <= 0: + invoice.status = InvoiceStatus.paid + invoice.paid_date = datetime.utcnow() + elif invoice.amount_paid > 0: + invoice.status = InvoiceStatus.sent + + booking_was_confirmed = False + should_send_email = False if payment.payment_type == PaymentType.deposit: # Mark deposit as paid and confirm booking booking.deposit_paid = True - if booking.status == BookingStatus.pending: + # Restore cancelled bookings or confirm pending bookings + if booking.status in [BookingStatus.pending, BookingStatus.cancelled]: booking.status = BookingStatus.confirmed + booking_was_confirmed = True + should_send_email = True + elif booking.status == BookingStatus.confirmed: + # Booking already confirmed, but deposit was just paid + should_send_email = True elif payment.payment_type == PaymentType.full: - # Calculate total paid from all completed payments (now includes current payment) - total_paid = sum( - float(p.amount) for p in booking.payments - if p.payment_status == PaymentStatus.completed - ) - # Confirm booking if: # 1. Total paid (all payments) covers the booking price, OR # 2. This single payment covers the entire booking amount + # Also restore cancelled bookings when payment succeeds if total_paid >= float(booking.total_price) or float(payment.amount) >= float(booking.total_price): - booking.status = BookingStatus.confirmed + if booking.status in [BookingStatus.pending, BookingStatus.cancelled]: + booking.status = BookingStatus.confirmed + booking_was_confirmed = True + should_send_email = True + elif booking.status == BookingStatus.confirmed: + # Booking already confirmed, but full payment was just completed + should_send_email = True - # Commit booking status update + # Send booking confirmation email if booking was just confirmed or payment completed + if should_send_email: + try: + from ..utils.mailer import send_email + from ..utils.email_templates import booking_confirmation_email_template + from ..models.system_settings import SystemSettings + from ..models.room import Room + from sqlalchemy.orm import selectinload + import os + from ..config.settings import settings + + # Get client URL from settings + client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first() + client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")) + + # Get platform currency for email + currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first() + currency = currency_setting.value if currency_setting and currency_setting.value else "USD" + + # Get currency symbol + currency_symbols = { + "USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥", + "KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$", + "VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$" + } + currency_symbol = currency_symbols.get(currency, currency) + + # Load booking with room details for email + booking_with_room = db.query(Booking).options( + selectinload(Booking.room).selectinload(Room.room_type) + ).filter(Booking.id == booking_id).first() + + room = booking_with_room.room if booking_with_room else None + room_type_name = room.room_type.name if room and room.room_type else "Room" + + # Calculate amount paid and remaining due + amount_paid = total_paid + payment_type_str = payment.payment_type.value if payment.payment_type else None + + email_html = booking_confirmation_email_template( + booking_number=booking.booking_number, + guest_name=booking.user.full_name if booking.user else "Guest", + room_number=room.room_number if room else "N/A", + room_type=room_type_name, + check_in=booking.check_in_date.strftime("%B %d, %Y") if booking.check_in_date else "N/A", + check_out=booking.check_out_date.strftime("%B %d, %Y") if booking.check_out_date else "N/A", + num_guests=booking.num_guests, + total_price=float(booking.total_price), + requires_deposit=False, # Payment completed, no deposit message needed + deposit_amount=None, + amount_paid=amount_paid, + payment_type=payment_type_str, + client_url=client_url, + currency_symbol=currency_symbol + ) + if booking.user: + await send_email( + to=booking.user.email, + subject=f"Booking Confirmed - {booking.booking_number}", + html=email_html + ) + logger.info(f"Booking confirmation email sent to {booking.user.email}") + except Exception as email_error: + logger.error(f"Failed to send booking confirmation email: {str(email_error)}") + + # Send invoice email if payment is completed and invoice is now paid + from ..utils.mailer import send_email + from ..services.invoice_service import InvoiceService + + # Load user for email + from ..models.user import User + user = db.query(User).filter(User.id == booking.user_id).first() + + for invoice in invoices: + if invoice.status == InvoiceStatus.paid and invoice.balance_due <= 0: + try: + invoice_dict = InvoiceService.invoice_to_dict(invoice) + invoice_html = _generate_invoice_email_html(invoice_dict, is_proforma=invoice.is_proforma) + invoice_type = "Proforma Invoice" if invoice.is_proforma else "Invoice" + if user: + await send_email( + to=user.email, + subject=f"{invoice_type} {invoice.invoice_number} - Payment Confirmed", + html=invoice_html + ) + logger.info(f"{invoice_type} {invoice.invoice_number} sent to {user.email}") + except Exception as email_error: + logger.error(f"Failed to send invoice email: {str(email_error)}") + + # Commit booking and invoice status updates db.commit() db.refresh(booking) @@ -335,7 +471,7 @@ class StripeService: raise ValueError(f"Error confirming payment: {error_msg}") @staticmethod - def handle_webhook( + async def handle_webhook( payload: bytes, signature: str, db: Session @@ -375,14 +511,16 @@ class StripeService: booking_id = metadata.get("booking_id") if booking_id: - try: - StripeService.confirm_payment( - payment_intent_id=payment_intent_id, - db=db, - booking_id=int(booking_id) - ) - except Exception as e: - print(f"Error processing webhook for booking {booking_id}: {str(e)}") + try: + await StripeService.confirm_payment( + payment_intent_id=payment_intent_id, + db=db, + booking_id=int(booking_id) + ) + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.error(f"Error processing webhook for booking {booking_id}: {str(e)}") elif event["type"] == "payment_intent.payment_failed": payment_intent = event["data"]["object"] @@ -400,6 +538,42 @@ class StripeService: if payment: payment.payment_status = PaymentStatus.failed db.commit() + + # Auto-cancel booking when payment fails + booking = db.query(Booking).filter(Booking.id == int(booking_id)).first() + if booking and booking.status != BookingStatus.cancelled: + booking.status = BookingStatus.cancelled + db.commit() + db.refresh(booking) + + # Send cancellation email (non-blocking) + try: + if booking.user: + from ..utils.mailer import send_email + from ..utils.email_templates import booking_status_changed_email_template + from ..models.system_settings import SystemSettings + from ..config.settings import settings + import os + + # Get client URL from settings + client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first() + client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")) + + email_html = booking_status_changed_email_template( + booking_number=booking.booking_number, + guest_name=booking.user.full_name if booking.user else "Guest", + status="cancelled", + client_url=client_url + ) + await send_email( + to=booking.user.email, + subject=f"Booking Cancelled - {booking.booking_number}", + html=email_html + ) + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.error(f"Failed to send cancellation email: {e}") return { "status": "success", diff --git a/Backend/src/utils/__pycache__/email_templates.cpython-312.pyc b/Backend/src/utils/__pycache__/email_templates.cpython-312.pyc index b9f275d247db6992e819e15e75f876f2413db39d..3d6d347865970ce328add6c1d88bec16c0334d4b 100644 GIT binary patch delta 4069 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 str: """Booking confirmation email template""" deposit_info = "" - if requires_deposit and deposit_amount: + if requires_deposit and deposit_amount and amount_paid is None: deposit_info = f"""

⚠️ Deposit Required

-

Please pay a deposit of €{deposit_amount:.2f} to confirm your booking.

+

Please pay a deposit of {currency_symbol}{deposit_amount:.2f} to confirm your booking.

Your booking will be confirmed once the deposit is received.

""" + # Payment breakdown section (shown when payment is completed) + payment_breakdown = "" + if amount_paid is not None: + remaining_due = total_price - amount_paid + payment_type_label = "Deposit Payment" if payment_type == "deposit" else "Full Payment" + payment_breakdown = f""" +
+

Payment Information

+ + + + + + + + + + + + + + """ + if remaining_due > 0: + payment_breakdown += f""" + + + + + """ + else: + payment_breakdown += f""" + + + + + """ + payment_breakdown += """ +
Payment Type:{payment_type_label}
Amount Paid:{currency_symbol}{amount_paid:.2f}
Total Booking Price:{currency_symbol}{total_price:.2f}
Remaining Due:{currency_symbol}{remaining_due:.2f}
Status:✅ Fully Paid
+
+ """ + content = f"""
@@ -308,13 +355,25 @@ def booking_confirmation_email_template( Guests: {num_guests} guest{'s' if num_guests > 1 else ''} + {f''' + + Subtotal: + {currency_symbol}{original_price:.2f} + + + Promotion Discount{f' ({promotion_code})' if promotion_code else ''}: + -{currency_symbol}{discount_amount:.2f} + + ''' if original_price and discount_amount and discount_amount > 0 else ''} Total Price: - €{total_price:.2f} + {currency_symbol}{total_price:.2f}
+ {payment_breakdown} + {deposit_info}
@@ -334,7 +393,10 @@ def payment_confirmation_email_template( amount: float, payment_method: str, transaction_id: Optional[str] = None, - client_url: str = "http://localhost:5173" + payment_type: Optional[str] = None, + total_price: Optional[float] = None, + client_url: str = "http://localhost:5173", + currency_symbol: str = "$" ) -> str: """Payment confirmation email template""" transaction_info = "" @@ -346,6 +408,34 @@ def payment_confirmation_email_template( """ + payment_type_info = "" + if payment_type: + payment_type_label = "Deposit Payment (20%)" if payment_type == "deposit" else "Full Payment" + payment_type_info = f""" + + Payment Type: + {payment_type_label} + + """ + + total_price_info = "" + remaining_due_info = "" + if total_price is not None: + total_price_info = f""" + + Total Booking Price: + {currency_symbol}{total_price:.2f} + + """ + if payment_type == "deposit" and total_price > amount: + remaining_due = total_price - amount + remaining_due_info = f""" + + Remaining Due: + {currency_symbol}{remaining_due:.2f} + + """ + content = f"""
@@ -370,10 +460,13 @@ def payment_confirmation_email_template( {payment_method} {transaction_info} + {payment_type_info} + {total_price_info} Amount Paid: - €{amount:.2f} + {currency_symbol}{amount:.2f} + {remaining_due_info}
diff --git a/Frontend/package-lock.json b/Frontend/package-lock.json index b9d3700b..99b1d0d6 100644 --- a/Frontend/package-lock.json +++ b/Frontend/package-lock.json @@ -13,12 +13,14 @@ "@stripe/react-stripe-js": "^2.9.0", "@stripe/stripe-js": "^2.4.0", "@types/react-datepicker": "^6.2.0", + "@types/react-google-recaptcha": "^2.1.9", "axios": "^1.6.2", "date-fns": "^2.30.0", "lucide-react": "^0.294.0", "react": "^18.3.1", "react-datepicker": "^8.9.0", "react-dom": "^18.3.1", + "react-google-recaptcha": "^3.1.0", "react-hook-form": "^7.48.2", "react-router-dom": "^6.20.0", "react-toastify": "^9.1.3", @@ -1610,6 +1612,15 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-google-recaptcha": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.9.tgz", + "integrity": "sha512-nT31LrBDuoSZJN4QuwtQSF3O89FVHC4jLhM+NtKEmVF5R1e8OY0Jo4//x2Yapn2aNHguwgX5doAq8Zo+Ehd0ug==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", @@ -3179,6 +3190,15 @@ "integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==", "license": "MIT" }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4124,6 +4144,19 @@ "node": ">=0.10.0" } }, + "node_modules/react-async-script": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz", + "integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==", + "license": "MIT", + "dependencies": { + "hoist-non-react-statics": "^3.3.0", + "prop-types": "^15.5.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, "node_modules/react-datepicker": { "version": "8.9.0", "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-8.9.0.tgz", @@ -4187,6 +4220,19 @@ "react": "^18.3.1" } }, + "node_modules/react-google-recaptcha": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz", + "integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.5.0", + "react-async-script": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, "node_modules/react-hook-form": { "version": "7.65.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz", diff --git a/Frontend/package.json b/Frontend/package.json index 07ee193f..953b59d7 100644 --- a/Frontend/package.json +++ b/Frontend/package.json @@ -11,19 +11,21 @@ }, "dependencies": { "@hookform/resolvers": "^3.3.2", + "@paypal/react-paypal-js": "^8.1.3", "@stripe/react-stripe-js": "^2.9.0", "@stripe/stripe-js": "^2.4.0", "@types/react-datepicker": "^6.2.0", + "@types/react-google-recaptcha": "^2.1.9", "axios": "^1.6.2", "date-fns": "^2.30.0", "lucide-react": "^0.294.0", "react": "^18.3.1", "react-datepicker": "^8.9.0", "react-dom": "^18.3.1", + "react-google-recaptcha": "^3.1.0", "react-hook-form": "^7.48.2", "react-router-dom": "^6.20.0", "react-toastify": "^9.1.3", - "@paypal/react-paypal-js": "^8.1.3", "yup": "^1.3.3", "zustand": "^4.4.7" }, diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index c2feaa6f..90398ab3 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -216,7 +216,7 @@ function App() { } /> diff --git a/Frontend/src/components/common/Recaptcha.tsx b/Frontend/src/components/common/Recaptcha.tsx new file mode 100644 index 00000000..f9aaf4b2 --- /dev/null +++ b/Frontend/src/components/common/Recaptcha.tsx @@ -0,0 +1,91 @@ +import React, { useEffect, useRef, useState } from 'react'; +import ReCAPTCHA from 'react-google-recaptcha'; +import { recaptchaService } from '../../services/api/systemSettingsService'; + +interface RecaptchaProps { + onChange?: (token: string | null) => void; + onError?: (error: string) => void; + theme?: 'light' | 'dark'; + size?: 'normal' | 'compact'; + className?: string; +} + +const Recaptcha: React.FC = ({ + onChange, + onError, + theme = 'dark', + size = 'normal', + className = '', +}) => { + const recaptchaRef = useRef(null); + const [siteKey, setSiteKey] = useState(''); + const [enabled, setEnabled] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchSettings = async () => { + try { + const response = await recaptchaService.getRecaptchaSettings(); + if (response.status === 'success' && response.data) { + setSiteKey(response.data.recaptcha_site_key || ''); + setEnabled(response.data.recaptcha_enabled || false); + } + } catch (error) { + console.error('Error fetching reCAPTCHA settings:', error); + if (onError) { + onError('Failed to load reCAPTCHA settings'); + } + } finally { + setLoading(false); + } + }; + + fetchSettings(); + }, [onError]); + + const handleChange = (token: string | null) => { + if (onChange) { + onChange(token); + } + }; + + const handleExpired = () => { + if (onChange) { + onChange(null); + } + }; + + const handleError = () => { + if (onError) { + onError('reCAPTCHA error occurred'); + } + if (onChange) { + onChange(null); + } + }; + + if (loading) { + return null; + } + + if (!enabled || !siteKey) { + return null; + } + + return ( +
+ +
+ ); +}; + +export default Recaptcha; + diff --git a/Frontend/src/components/payments/PayPalPaymentWrapper.tsx b/Frontend/src/components/payments/PayPalPaymentWrapper.tsx index 696d29ac..00788b69 100644 --- a/Frontend/src/components/payments/PayPalPaymentWrapper.tsx +++ b/Frontend/src/components/payments/PayPalPaymentWrapper.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import { createPayPalOrder } from '../../services/api/paymentService'; import { Loader2, AlertCircle } from 'lucide-react'; +import { useFormatCurrency } from '../../hooks/useFormatCurrency'; interface PayPalPaymentWrapperProps { bookingId: number; @@ -12,9 +13,12 @@ interface PayPalPaymentWrapperProps { const PayPalPaymentWrapper: React.FC = ({ bookingId, amount, - currency = 'USD', + currency: propCurrency, onError, }) => { + // Get currency from context if not provided as prop + const { currency: contextCurrency } = useFormatCurrency(); + const currency = propCurrency || contextCurrency || 'USD'; const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [approvalUrl, setApprovalUrl] = useState(null); @@ -75,22 +79,29 @@ const PayPalPaymentWrapper: React.FC = ({ if (loading) { return (
- - Initializing PayPal payment... +
+ +
+ + Initializing PayPal payment... +
); } if (error) { return ( -
-
- +
+
+
-

+

Payment Initialization Failed

-

+

{error || 'Unable to initialize PayPal payment. Please try again.'}

@@ -102,57 +113,65 @@ const PayPalPaymentWrapper: React.FC = ({ if (!approvalUrl) { return (
- - Loading PayPal... +
+ +
+ + Loading PayPal... +
); } return ( -
-
-
- - - -
-

- Complete Payment with PayPal -

-

- You will be redirected to PayPal to securely complete your payment of{' '} - - {new Intl.NumberFormat('en-US', { - style: 'currency', - currency: currency, - }).format(amount)} - -

- -

- Secure payment powered by PayPal -

+ +
+

+ Complete Payment with PayPal +

+

+ You will be redirected to PayPal to securely complete your payment of{' '} + + {new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency, + }).format(amount)} + +

+ +

+ Secure payment powered by PayPal +

); }; diff --git a/Frontend/src/components/rooms/RatingStars.tsx b/Frontend/src/components/rooms/RatingStars.tsx index 0e3a23d8..8d9c8931 100644 --- a/Frontend/src/components/rooms/RatingStars.tsx +++ b/Frontend/src/components/rooms/RatingStars.tsx @@ -71,15 +71,15 @@ const RatingStars: React.FC = ({ ); })} {showNumber && ( - + {rating.toFixed(1)} )} diff --git a/Frontend/src/components/rooms/ReviewSection.tsx b/Frontend/src/components/rooms/ReviewSection.tsx index 56efa3fc..bdb5d050 100644 --- a/Frontend/src/components/rooms/ReviewSection.tsx +++ b/Frontend/src/components/rooms/ReviewSection.tsx @@ -10,6 +10,8 @@ import { type Review, } from '../../services/api/reviewService'; import useAuthStore from '../../store/useAuthStore'; +import Recaptcha from '../common/Recaptcha'; +import { recaptchaService } from '../../services/api/systemSettingsService'; interface ReviewSectionProps { roomId: number; @@ -42,6 +44,7 @@ const ReviewSection: React.FC = ({ const [submitting, setSubmitting] = useState(false); const [averageRating, setAverageRating] = useState(0); const [totalReviews, setTotalReviews] = useState(0); + const [recaptchaToken, setRecaptchaToken] = useState(null); const { register, @@ -87,6 +90,22 @@ const ReviewSection: React.FC = ({ return; } + // Verify reCAPTCHA if enabled + if (recaptchaToken) { + try { + const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken); + if (verifyResponse.status === 'error' || !verifyResponse.data.verified) { + toast.error('reCAPTCHA verification failed. Please try again.'); + setRecaptchaToken(null); + return; + } + } catch (error) { + toast.error('reCAPTCHA verification failed. Please try again.'); + setRecaptchaToken(null); + return; + } + } + try { setSubmitting(true); const response = await createReview({ @@ -101,12 +120,14 @@ const ReviewSection: React.FC = ({ ); reset(); fetchReviews(); + setRecaptchaToken(null); } } catch (error: any) { const message = error.response?.data?.message || 'Unable to submit review'; toast.error(message); + setRecaptchaToken(null); } finally { setSubmitting(false); } @@ -121,24 +142,26 @@ const ReviewSection: React.FC = ({ }; return ( -
+
{/* Rating Summary */} -
-

+
+

Customer Reviews

-
+
-
+
{averageRating > 0 ? averageRating.toFixed(1) : 'N/A'}
- -
+
+ +
+
{totalReviews} review{totalReviews !== 1 ? 's' : ''}
@@ -147,29 +170,29 @@ const ReviewSection: React.FC = ({ {/* Review Form */} {isAuthenticated ? ( -
-

+
+

Write Your Review

- setValue('rating', value) } /> {errors.rating && ( -

+

{errors.rating.message}

)} @@ -178,51 +201,66 @@ const ReviewSection: React.FC = ({