diff --git a/.env.example b/.env.example index 9fc802e..52efed8 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,19 @@ POSTGRES_OPTIONS="postgresql://postgres:9UMrd6hKBho4RhoyP47t@data1.cyberhybridhu SANDBOX_MODE="true" DISABLE_EXTERNAL_APIS="true" LOCAL_DEV_MODE="true" + +# Stripe Configuration +# STRIPE_SECRET_KEY: Secret key for Stripe API (server-side use only) +# Get this from your Stripe Dashboard: https://dashboard.stripe.com/apikeys +# Example: sk_test_51AbCdEfGhIjKlMnOpQrStUvWxYz1234567890 +STRIPE_SECRET_KEY="" + +# STRIPE_WEBHOOK_SECRET: Secret used to verify Stripe webhook signatures +# Get this from your Stripe Dashboard: https://dashboard.stripe.com/webhooks +# Example: whsec_1234567890abcdef1234567890abcdef1234567890abcdef +STRIPE_WEBHOOK_SECRET="" + +# STRIPE_PUBLISHABLE_KEY: Publishable key for Stripe.js (client-side use) +# Get this from your Stripe Dashboard: https://dashboard.stripe.com/apikeys +# Example: pk_test_51AbCdEfGhIjKlMnOpQrStUvWxYz1234567890 +STRIPE_PUBLISHABLE_KEY="" diff --git a/TODO.md b/TODO.md index b9a09fe..809da28 100644 --- a/TODO.md +++ b/TODO.md @@ -1,34 +1,56 @@ # Project TODO List -This document outlines the remaining tasks, incomplete features, and future development plans for the application. +This document outlines the remaining tasks, incomplete features, and future +development plans for the application. ## High Priority -- **Implement Payment Processing**: The current rent payment system in `routes/tenant/rent/pay/index.tsx` is a placeholder. It needs to be fully implemented with a payment provider like Stripe to handle real transactions securely. + +- **Implement Payment Processing**: The current rent payment system in + `routes/tenant/rent/pay/index.tsx` is a placeholder. It needs to be fully + implemented with a payment provider like Stripe to handle real transactions + securely. ## Backend & API -- **Add Well-Known Endpoint**: Implement a `/.well-known/acme-challenge/:token` endpoint in `main.ts` to support services like Let's Encrypt for SSL certificate validation in production. -- **Enforce Database Constraints**: Ensure the `owner_id` field in the `AccessTokenResponse` interface is unique for each entry, as noted in `utils/database.ts`. This is crucial for maintaining data integrity. + +- **Add Well-Known Endpoint**: Implement a `/.well-known/acme-challenge/:token` + endpoint in `main.ts` to support services like Let's Encrypt for SSL + certificate validation in production. +- **Enforce Database Constraints**: Ensure the `owner_id` field in the + `AccessTokenResponse` interface is unique for each entry, as noted in + `utils/database.ts`. This is crucial for maintaining data integrity. ## Feature Development -- **Complete Landlord Dashboard**: Flesh out all features under the `routes/landlord/` directory. This includes: + +- **Complete Landlord Dashboard**: Flesh out all features under the + `routes/landlord/` directory. This includes: - Property management - Tenant invitation and management (`islands/LandlordInviteTenant.tsx`) - Messaging with tenants (`islands/LandlordMessageThreads.tsx`) - Financial tracking and reporting -- **Complete Tenant Dashboard**: Finalize all features under the `routes/tenant/` directory. This includes: +- **Complete Tenant Dashboard**: Finalize all features under the + `routes/tenant/` directory. This includes: - Viewing lease documents - Tracking rent payments and financial obligations - Messaging with landlords (`islands/TenantMessageThreads.tsx`) - Managing settings (`islands/TenantSettings.tsx`) -- **Enhance User Onboarding**: Improve the onboarding flow for both new landlords and tenants in `routes/onboarding/`. -- **Develop User Profile Management**: Complete the user profile editing and management features in `routes/user-profile/`. +- **Enhance User Onboarding**: Improve the onboarding flow for both new + landlords and tenants in `routes/onboarding/`. +- **Develop User Profile Management**: Complete the user profile editing and + management features in `routes/user-profile/`. ## Future Features & Refinements -- **AI/ML Feature Integration**: The advanced features found in the `islands/` directory need further development and integration into the user experience. This includes: + +- **AI/ML Feature Integration**: The advanced features found in the `islands/` + directory need further development and integration into the user experience. + This includes: - `BrainStudy.tsx` - `Quizzing.tsx` - `ImageGenerater.tsx` - `VoiceRecorder.tsx` -- **Calendar Functionality**: Expand the calendar features in `routes/calendar/` and improve the `islands/FinancialCalendar.tsx` component to be more interactive and informative. -- **Document Management**: Build out a robust document upload, storage, and sharing system for leases, notices, and other rental-related documents. -- **Reminders and Notifications**: Implement a system for sending automated reminders for rent payments, lease renewals, and other important events. +- **Calendar Functionality**: Expand the calendar features in `routes/calendar/` + and improve the `islands/FinancialCalendar.tsx` component to be more + interactive and informative. +- **Document Management**: Build out a robust document upload, storage, and + sharing system for leases, notices, and other rental-related documents. +- **Reminders and Notifications**: Implement a system for sending automated + reminders for rent payments, lease renewals, and other important events. diff --git a/islands/AddPaymentMethodModal.tsx b/islands/AddPaymentMethodModal.tsx new file mode 100644 index 0000000..586c687 --- /dev/null +++ b/islands/AddPaymentMethodModal.tsx @@ -0,0 +1,231 @@ +import { useCallback, useRef, useState } from "preact/hooks"; +import { + encryptDataWithStoredPassword, + getEncryptionPassword, +} from "../helpers.ts"; + +interface AddPaymentMethodModalProps { + userId?: string; +} + +export default function AddPaymentMethodModal( + { userId }: AddPaymentMethodModalProps, +) { + const formRef = useRef(null); + const encryptedRoutingNumberRef = useRef(null); + const encryptedAccountNumberRef = useRef(null); + const bankNameRef = useRef(null); + const accountTypeRef = useRef(null); + const routingNumberRef = useRef(null); + const accountNumberRef = useRef(null); + const isDefaultRef = useRef(null); + const [loading, setLoading] = useState(false); + + // Only encrypt on submit, not on every keystroke - prevents V8 overload + const handleEncryptData = useCallback(async () => { + // Get current values from the form inputs + const routingNumber = routingNumberRef.current?.value || ""; + const accountNumber = accountNumberRef.current?.value || ""; + + // Check if encryption is available and encrypt sensitive fields + if (userId && (routingNumber || accountNumber)) { + const encryptionPassword = getEncryptionPassword(userId); + + if (encryptionPassword) { + try { + // Encrypt routing number + if (routingNumber && encryptedRoutingNumberRef.current) { + const encryptedRoutingNumber = await encryptDataWithStoredPassword( + userId, + routingNumber, + ); + if (encryptedRoutingNumber) { + encryptedRoutingNumberRef.current.value = encryptedRoutingNumber; + } + } + + // Encrypt account number + if (accountNumber && encryptedAccountNumberRef.current) { + const encryptedAccountNumber = await encryptDataWithStoredPassword( + userId, + accountNumber, + ); + if (encryptedAccountNumber) { + encryptedAccountNumberRef.current.value = encryptedAccountNumber; + } + } + } catch (error) { + console.error("Failed to encrypt payment method data:", error); + } + } + } + }, [userId]); + + const handleAddClick = async () => { + // Validate form + const bankName = bankNameRef.current?.value?.trim() || ""; + const accountType = accountTypeRef.current?.value || ""; + const routingNumber = routingNumberRef.current?.value?.trim() || ""; + const accountNumber = accountNumberRef.current?.value?.trim() || ""; + + if (!bankName || !accountType || !routingNumber || !accountNumber) { + alert("Please fill in all required fields."); + return; + } + + if (routingNumber.length !== 9) { + alert("Routing number must be 9 digits."); + return; + } + + if (accountNumber.length < 4) { + alert("Account number must be at least 4 digits."); + return; + } + + setLoading(true); + // Encrypt data before submission + await handleEncryptData(); + + // Clear plaintext fields if encryption was successful + if (userId && (routingNumber || accountNumber)) { + const encryptionPassword = getEncryptionPassword(userId); + + if (encryptionPassword) { + if (routingNumberRef.current) routingNumberRef.current.value = ""; + if (accountNumberRef.current) accountNumberRef.current.value = ""; + } + } + + // Submit the form + setTimeout(() => { + if (formRef.current) { + formRef.current.submit(); + } + }, 100); + }; + + return ( +
+

Add Payment Method

+
+ + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + Cancel + + +
+
+ ); +} diff --git a/islands/PaymentMethodsList.tsx b/islands/PaymentMethodsList.tsx new file mode 100644 index 0000000..b08f47a --- /dev/null +++ b/islands/PaymentMethodsList.tsx @@ -0,0 +1,302 @@ +// islands/PaymentMethodsList.tsx +import { PaymentMethod } from "../services/paymentService.ts"; +import { useEffect, useState } from "preact/hooks"; +import { PopAddPaymentMethodDialog } from "./dialogs/PopAddPaymentMethodDialog.tsx"; +import { + decryptDataWithStoredPassword, + getEncryptionPassword, +} from "../helpers.ts"; + +interface Props { + paymentMethods: PaymentMethod[]; + userId?: string; + openModal?: boolean; +} + +interface ProcessedPaymentMethod extends PaymentMethod { + maskedAccountNumber?: string; + maskedRoutingNumber?: string; +} + +export default function PaymentMethodsList( + { paymentMethods, userId, openModal = false }: Props, +) { + const [_popAddPaymentMethod, popupAddPaymentMethodDialog] = + PopAddPaymentMethodDialog({ + userId, + defaultOpen: openModal, + }); + const [processedMethods, setProcessedMethods] = useState< + ProcessedPaymentMethod[] + >([]); + const [isProcessing, setIsProcessing] = useState(false); + const [_isOpeningModal, _setIsOpeningModal] = useState(openModal); + + // Process payment methods on component mount and when paymentMethods or userId change + useEffect(() => { + setTimeout(() => { + const lockRoute = localStorage.getItem("lock-route"); + const url = new URL(globalThis.location.href); + const currentRoute = url.pathname + url.search; + + if (lockRoute && lockRoute === currentRoute) { + localStorage.removeItem("lock-route"); + } + }, 100); + + if (!userId) { + setProcessedMethods(paymentMethods as ProcessedPaymentMethod[]); + return; + } + + const processMethods = async () => { + setIsProcessing(true); + const encryptionPassword = getEncryptionPassword(userId); + + if (!encryptionPassword) { + // No encryption key available, use methods as-is + setProcessedMethods(paymentMethods as ProcessedPaymentMethod[]); + setIsProcessing(false); + return; + } + + const updatedMethods: ProcessedPaymentMethod[] = []; + + for (const method of paymentMethods) { + const processedMethod: ProcessedPaymentMethod = { ...method }; + + // Decrypt and mask account number + if (method.account_number_encrypted) { + try { + const decryptedData = await decryptDataWithStoredPassword( + userId, + method.account_number_encrypted, + ); + if (decryptedData) { + const accountNumber = decryptedData; + // Show only last 4 digits + processedMethod.maskedAccountNumber = "****" + + accountNumber.slice(-4); + } + } catch (error) { + console.error("Failed to decrypt account number:", error); + } + } + + // Decrypt and mask routing number + if (method.routing_number_encrypted) { + try { + const decryptedData = await decryptDataWithStoredPassword( + userId, + method.routing_number_encrypted, + ); + if (decryptedData) { + const routingNumber = decryptedData; + // Show only last 4 digits + processedMethod.maskedRoutingNumber = "****" + + routingNumber.slice(-4); + } + } catch (error) { + console.error("Failed to decrypt routing number:", error); + } + } + + updatedMethods.push(processedMethod); + } + + setProcessedMethods(updatedMethods); + setIsProcessing(false); + }; + + processMethods(); + }, [paymentMethods, userId]); + + const handleSetDefault = async (methodId: string) => { + try { + const response = await fetch("/api/update/payment_method", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: methodId, is_default: true }), + }); + + const data = await response.json(); + + if (data.success) { + // Refresh the page to show updated default + globalThis.location.reload(); + } else { + console.error("Failed to set default payment method:", data.error); + alert("Failed to set default payment method"); + } + } catch (error) { + console.error("Error setting default payment method:", error); + alert("Failed to set default payment method"); + } + }; + + if (isProcessing) { + return ( +
+
+
+ ); + } + + return ( + <> + {popupAddPaymentMethodDialog} + +
+ {processedMethods.length === 0 + ? ( +
+ + + +

+ No payment methods +

+

+ Add a payment method to get started with rent payments. +

+ +
+ ) + : ( + <> +
    + {processedMethods.map((method) => ( +
  • +
    +
    +
    +

    + {method.bank_name || "Bank Account"} +

    + {method.is_default && ( + + Default + + )} +
    +
    +
    + Type:{" "} + {method.account_type === "checking" + ? "Checking" + : method.account_type === "savings" + ? "Savings" + : method.account_type || "Unknown"} +
    +
    + Account:{" "} + {method.maskedAccountNumber || "****"} +
    +
    + Routing:{" "} + {method.maskedRoutingNumber || "****"} +
    +
    +
    + +
    + {!method.is_default && ( + + )} + +
    + + + +
    +
    +
    +
  • + ))} +
+ +
+ +
+ + )} +
+ + ); +} diff --git a/islands/TenantSettings.tsx b/islands/TenantSettings.tsx index 6c8b442..74cdcd8 100644 --- a/islands/TenantSettings.tsx +++ b/islands/TenantSettings.tsx @@ -185,6 +185,14 @@ export default function TenantSettings({ user }: TenantSettingsProps) { checked={paymentPrefs.payment_reminders} onChange={() => handlePaymentChange("payment_reminders")} /> + {/* Auto-pay setup is handled on the Payment Methods page, not here */}
diff --git a/islands/dialogs/AddPaymentMethodDialog.tsx b/islands/dialogs/AddPaymentMethodDialog.tsx new file mode 100644 index 0000000..f54a8b7 --- /dev/null +++ b/islands/dialogs/AddPaymentMethodDialog.tsx @@ -0,0 +1,325 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import { + encryptDataWithStoredPassword, + getEncryptionPassword, +} from "../../helpers.ts"; + +interface AddPaymentMethodDialogProps { + onClose: () => void; + userId?: string; +} + +type FieldErrorState = { + bank_name: boolean; + account_type: boolean; + routing_number: boolean; + account_number: boolean; +}; + +export default function AddPaymentMethodDialog( + { onClose, userId }: AddPaymentMethodDialogProps, +) { + const formRef = useRef(null); + const firstInputRef = useRef(null); + const [loading, setLoading] = useState(false); + const [fieldErrors, setFieldErrors] = useState({ + bank_name: false, + account_type: false, + routing_number: false, + account_number: false, + }); + + useEffect(() => { + // Focus first input when dialog opens + setTimeout(() => { + firstInputRef.current?.focus(); + }, 100); + }, []); + + const clearFieldError = (field: keyof FieldErrorState) => { + setFieldErrors((prev) => ({ ...prev, [field]: false })); + }; + + const getInputErrorClass = (hasError: boolean) => { + return hasError ? "border-red-500 focus:border-red-500" : ""; + }; + + const addNewPaymentMethod = async () => { + if (!formRef.current) return; + + // Collect form data + const formData = new FormData(formRef.current); + const bankName = formData.get("bank_name")?.toString().trim() || ""; + const accountType = formData.get("account_type")?.toString() || ""; + const routingNumber = formData.get("routing_number")?.toString().trim() || + ""; + const accountNumber = formData.get("account_number")?.toString().trim() || + ""; + const isDefault = formData.get("is_default") === "true"; + const sender = typeof location !== "undefined" ? location.pathname : "/"; + + const validation = { + bank_name: !bankName, + account_type: !accountType, + routing_number: !routingNumber || routingNumber.length !== 9, + account_number: !accountNumber || accountNumber.length < 4, + }; + setFieldErrors(validation); + + if ( + validation.bank_name || validation.account_type || + validation.routing_number || validation.account_number + ) { + return; + } + + setLoading(true); + localStorage.setItem("lock-route", "/tenant/payment-methods?modal=off"); + + // Build submission data + const submitData = new FormData(); + submitData.set("sender", sender); + submitData.set("bank_name", bankName); + submitData.set("account_type", accountType); + submitData.set("is_default", isDefault ? "true" : "false"); + + // Encrypt sensitive fields if password is available + const encryptionPassword = userId ? getEncryptionPassword(userId) : null; + + if (encryptionPassword && (routingNumber || accountNumber)) { + try { + // Encrypt routing number + const encryptedRoutingNumber = await encryptDataWithStoredPassword( + userId!, + routingNumber, + ); + + // Encrypt account number + const encryptedAccountNumber = await encryptDataWithStoredPassword( + userId!, + accountNumber, + ); + + if (encryptedRoutingNumber && encryptedAccountNumber) { + submitData.set("routing_number_encrypted", encryptedRoutingNumber); + submitData.set("account_number_encrypted", encryptedAccountNumber); + // Send empty plaintext when encrypted + submitData.set("routing_number", ""); + submitData.set("account_number", ""); + } else { + throw new Error("Encryption returned empty result"); + } + } catch (error) { + console.error("Encryption failed:", error); + alert("Failed to encrypt payment method data. Please try again."); + setLoading(false); + return; + } + } else { + // No encryption available, send plaintext (not recommended for production) + console.warn("No encryption password available, sending plaintext"); + submitData.set("routing_number", routingNumber); + submitData.set("account_number", accountNumber); + } + + // Submit the form + try { + const response = await fetch("/api/create/payment_method", { + method: "POST", + body: submitData, + }); + + const data = await response.json(); + + if (data.success) { + // Redirect to payment methods page + globalThis.location.href = "/tenant/payment-methods"; + } else { + console.error("Failed to add payment method:", data.error); + alert(data.error || "Failed to add payment method. Please try again."); + setLoading(false); + } + } catch (error) { + console.error("Error adding payment method:", error); + alert("Failed to add payment method. Please try again."); + setLoading(false); + } + }; + + return ( +
+
+
+

+ Add Payment Method +

+ +
+
+ + clearFieldError("bank_name")} + /> +
+ Bank name is required. +
+
+ +
+ + +
+ Account type is required. +
+
+ +
+ + clearFieldError("routing_number")} + /> +
+ Routing number must be 9 digits. +
+
+ +
+ + clearFieldError("account_number")} + /> +
+ Account number is required (min 4 digits). +
+
+ +
+ + +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/islands/dialogs/PopAddPaymentMethodDialog.tsx b/islands/dialogs/PopAddPaymentMethodDialog.tsx new file mode 100644 index 0000000..b61048a --- /dev/null +++ b/islands/dialogs/PopAddPaymentMethodDialog.tsx @@ -0,0 +1,40 @@ +import { useState } from "preact/hooks"; +import AddPaymentMethodDialog from "./AddPaymentMethodDialog.tsx"; + +interface PopAddPaymentMethodDialogOptions { + userId?: string; + defaultOpen?: boolean; +} + +interface PopupController { + resolve?: (value: boolean) => void; +} + +export function PopAddPaymentMethodDialog( + options: PopAddPaymentMethodDialogOptions = {}, +) { + const [popup, setPopup] = useState(() => { + return options.defaultOpen ? { resolve: () => {} } : null; + }); + + // Show the popup and return a Promise for the answer + const popAddPaymentMethod = (): Promise => { + return new Promise((resolve) => { + setPopup({ resolve }); + }); + }; + + // Render the popup if needed + const popupAddPaymentMethodDialog = popup + ? ( + { + globalThis.location.href = "/tenant/payment-methods?modal=off"; + }} + userId={options.userId} + /> + ) + : null; + + return [popAddPaymentMethod, popupAddPaymentMethodDialog] as const; +} diff --git a/main.ts b/main.ts index ab8c7c3..1b9ea66 100755 --- a/main.ts +++ b/main.ts @@ -201,5 +201,3 @@ if (isAppReady) { // if (import.meta.main) { // await app.listen(); // } - - diff --git a/routes/api/payments/cancel_auto_pay.ts b/routes/api/payments/cancel_auto_pay.ts new file mode 100644 index 0000000..3d26fc9 --- /dev/null +++ b/routes/api/payments/cancel_auto_pay.ts @@ -0,0 +1,79 @@ +// routes/api/payments/cancel_auto_pay.ts +import { define } from "../../../utils.ts"; +import { cancelAutoPayment } from "../../../services/paymentService.ts"; +import { SessionCookie } from "../../auth/callback.ts"; + +export const handler = define.handlers({ + async POST(ctx) { + try { + const stateData: SessionCookie = ctx.state.data; + const user_id = stateData.user_id; + + if (!user_id) { + return new Response( + JSON.stringify({ + error: "User not authenticated", + }), + { + status: 401, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const contentType = ctx.req.headers.get("content-type") || ""; + let data: Record = {}; + + // Handle both JSON and form data + if (contentType.includes("application/json")) { + const jsonData = await ctx.req.json(); + data = jsonData; + } else { + const formData = await ctx.req.formData(); + data = { + lease_id: formData.get("lease_id")?.toString() || "", + }; + } + + const lease_id = data.lease_id as string; + + if (!lease_id) { + return new Response( + JSON.stringify({ + error: "Lease ID is required", + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const result = await cancelAutoPayment(lease_id, user_id); + + return new Response( + JSON.stringify({ + success: true, + lease: result[0], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + } catch (e) { + console.error("Error canceling auto payment:", e); + return new Response( + JSON.stringify({ + error: e instanceof Error + ? e.message + : "Failed to cancel auto payment", + }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + }, + ); + } + }, +}); diff --git a/routes/api/payments/create_intent.ts b/routes/api/payments/create_intent.ts index a0a4315..8d99b89 100644 --- a/routes/api/payments/create_intent.ts +++ b/routes/api/payments/create_intent.ts @@ -90,4 +90,3 @@ export const handler = define.handlers({ } }, }); - diff --git a/routes/api/payments/set_auto_pay.ts b/routes/api/payments/set_auto_pay.ts new file mode 100644 index 0000000..bb59d3e --- /dev/null +++ b/routes/api/payments/set_auto_pay.ts @@ -0,0 +1,92 @@ +// routes/api/payments/set_auto_pay.ts +import { define } from "../../../utils.ts"; +import { setAutoPayment } from "../../../services/paymentService.ts"; +import { SessionCookie } from "../../auth/callback.ts"; + +export const handler = define.handlers({ + async POST(ctx) { + try { + const stateData: SessionCookie = ctx.state.data; + const user_id = stateData.user_id; + + if (!user_id) { + return new Response( + JSON.stringify({ + error: "User not authenticated", + }), + { + status: 401, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const contentType = ctx.req.headers.get("content-type") || ""; + let data: Record = {}; + + // Handle both JSON and form data + if (contentType.includes("application/json")) { + const jsonData = await ctx.req.json(); + data = jsonData; + } else { + const formData = await ctx.req.formData(); + data = { + lease_id: formData.get("lease_id")?.toString() || "", + payment_method_id: formData.get("payment_method_id")?.toString() || + "", + }; + } + + const lease_id = data.lease_id as string; + const payment_method_id = data.payment_method_id as string; + + if (!lease_id) { + return new Response( + JSON.stringify({ + error: "Lease ID is required", + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + if (!payment_method_id) { + return new Response( + JSON.stringify({ + error: "Payment method ID is required", + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const result = await setAutoPayment(lease_id, user_id, payment_method_id); + + return new Response( + JSON.stringify({ + success: true, + lease: result[0], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + } catch (e) { + console.error("Error setting auto payment:", e); + return new Response( + JSON.stringify({ + error: e instanceof Error ? e.message : "Failed to set auto payment", + }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + }, + ); + } + }, +}); diff --git a/routes/api/receipt/[id].ts b/routes/api/receipt/[id].ts index 18b025e..9316308 100644 --- a/routes/api/receipt/[id].ts +++ b/routes/api/receipt/[id].ts @@ -38,7 +38,9 @@ export const handler = define.handlers({
Lease ID
${leaseId}
Amount
$${amount.toLocaleString()}
Method
${method}
-
Date
${new Date(date).toLocaleString()}
+
Date
${ + new Date(date).toLocaleString() + }
`; @@ -49,4 +51,3 @@ export const handler = define.handlers({ }); }, }); - diff --git a/routes/api/retrieve/payment_transactions.ts b/routes/api/retrieve/payment_transactions.ts index bd6ff5d..a04e1b7 100644 --- a/routes/api/retrieve/payment_transactions.ts +++ b/routes/api/retrieve/payment_transactions.ts @@ -43,20 +43,3 @@ export const handler = define.handlers({ } }, }); - - - - - - - - - - - - - - - - - diff --git a/routes/landlord/payments/index.tsx b/routes/landlord/payments/index.tsx index 6f0dd53..b973a2f 100644 --- a/routes/landlord/payments/index.tsx +++ b/routes/landlord/payments/index.tsx @@ -282,4 +282,3 @@ export default define.page(function LandlordPayments({ data }) { ); }); - diff --git a/routes/landlord/tenants/invite.tsx b/routes/landlord/tenants/invite.tsx index 0df6389..b43eebc 100644 --- a/routes/landlord/tenants/invite.tsx +++ b/routes/landlord/tenants/invite.tsx @@ -150,40 +150,3 @@ export default define.page(function InviteTenantPage({ data }) { ); }); - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/routes/tenant/payment-methods/index.tsx b/routes/tenant/payment-methods/index.tsx new file mode 100644 index 0000000..1994d25 --- /dev/null +++ b/routes/tenant/payment-methods/index.tsx @@ -0,0 +1,97 @@ +// routes/tenant/payment-methods/index.tsx +import { define } from "../../../utils.ts"; +import { page } from "fresh"; +import { PaymentMethod } from "../../../services/paymentService.ts"; +import PaymentMethodsList from "../../../islands/PaymentMethodsList.tsx"; + +interface PaymentMethodsPageData { + paymentMethods?: PaymentMethod[]; + error?: string; +} + +export const handler = define.handlers({ + async GET(ctx) { + const { user_id } = ctx.state.data; + + if (!user_id) { + return new Response("", { + status: 302, + headers: { Location: "/auth/login" }, + }); + } + + try { + const response = await fetch("/api/retrieve/payment_methods", { + headers: { + "Cookie": ctx.req.headers.get("Cookie") || "", + }, + }); + + const data = await response.json(); + + if (!data.success) { + console.error("Failed to fetch payment methods:", data.error); + return page({ + error: "Failed to load payment methods", + }); + } + + return page({ + paymentMethods: data.payment_methods || [], + }); + } catch (error) { + console.error("Error loading payment methods:", error); + return page({ + error: "Failed to load payment methods", + }); + } + }, +}); + +export default define.page(function PaymentMethodsPage({ + data, +}: { + data: PaymentMethodsPageData; +}) { + const { paymentMethods, error } = data as PaymentMethodsPageData; + + if (error) { + return ( +
+
+

Error

+

{error}

+ +
+
+ ); + } + + return ( +
+
+
+
+

+ Payment Methods +

+

+ Manage your payment methods for rent payments. +

+
+
+
+ +
+ +
+
+ ); +}); diff --git a/routes/tenant/payments/index.tsx b/routes/tenant/payments/index.tsx index 1a87503..e61fb05 100644 --- a/routes/tenant/payments/index.tsx +++ b/routes/tenant/payments/index.tsx @@ -103,7 +103,7 @@ export default define.page(function TenantPayments({ data }) { Payment Methods Manage Payment Methods diff --git a/routes/tenant/rent/confirmation/index.tsx b/routes/tenant/rent/confirmation/index.tsx index ef0090f..316cb00 100644 --- a/routes/tenant/rent/confirmation/index.tsx +++ b/routes/tenant/rent/confirmation/index.tsx @@ -21,9 +21,7 @@ export default define.page(function RentConfirmation({ data }) {

Payment Successful

- {amount && ( -

Amount: ${amount}

- )} + {amount &&

Amount: ${amount}

}

A receipt is available from your payment history.

@@ -55,4 +53,3 @@ export default define.page(function RentConfirmation({ data }) { ); }); - diff --git a/routes/tenant/rent/pay/index.tsx b/routes/tenant/rent/pay/index.tsx index 93ba0b7..a2e4aed 100644 --- a/routes/tenant/rent/pay/index.tsx +++ b/routes/tenant/rent/pay/index.tsx @@ -1,13 +1,10 @@ // routes/tenant/rent/pay/index.tsx import { define } from "../../../../utils.ts"; -import { SessionCookie } from "../../../auth/callback.ts"; import { getLeasesByTenant } from "../../../../services/leaseService.ts"; import { getPaymentMethodsByUser, PaymentMethod, } from "../../../../services/paymentService.ts"; -import { createPaymentTransaction } from "../../../../services/paymentService.ts"; -import { getE2ENow, isE2EEnabled } from "../../../../services/e2eFixtures.ts"; import { page } from "fresh"; import { Lease } from "../../../../db/interfaces.ts"; @@ -70,24 +67,46 @@ export const handler = define.handlers({ try { const formData = await ctx.req.formData(); const amount = parseFloat(formData.get("amount") as string); - const payment_method_id = formData.get("payment_method_id") as string; + const _payment_method_id = formData.get("payment_method_id") as string; const lease_id = formData.get("lease_id") as string; - // TODO: Implement actual payment processing with Stripe or similar - // For now, simulate successful payment - if (isE2EEnabled()) { - await createPaymentTransaction( + // Call the payment intent creation endpoint + const intentResponse = await fetch("/api/payments/create_intent", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ lease_id, amount, - getE2ENow(), - "ach", - "completed", + payment_method: "ach", + }), + }); + + const intentData = await intentResponse.json(); + + if (!intentData.success) { + console.error("Payment intent creation failed:", intentData.error); + return page({ + error: intentData.error || + "Failed to create payment intent. Please try again.", + }); + } + + // Redirect to confirmation page with payment details + const redirectUrl = new URL("/tenant/rent/confirmation", ctx.req.url); + redirectUrl.searchParams.set("success", "true"); + redirectUrl.searchParams.set("amount", String(amount)); + + if (intentData.mode === "stripe") { + redirectUrl.searchParams.set( + "payment_intent_id", + intentData.payment_intent_id, ); + redirectUrl.searchParams.set("client_secret", intentData.client_secret); } return new Response("", { status: 302, - headers: { Location: "/tenant/rent/confirmation?success=true" }, + headers: { Location: redirectUrl.toString() }, }); } catch (error) { console.error("Error processing payment:", error); @@ -347,4 +366,3 @@ export default define.page( ); }, ); - diff --git a/services/paymentService.ts b/services/paymentService.ts index 5d19831..bd5df7e 100644 --- a/services/paymentService.ts +++ b/services/paymentService.ts @@ -387,4 +387,3 @@ export async function cancelAutoPayment( .where(eq(leases.id, lease_id)) .returning(); } - diff --git a/utils/database.ts b/utils/database.ts index b957f61..0764722 100644 --- a/utils/database.ts +++ b/utils/database.ts @@ -592,4 +592,3 @@ export async function upsertAccessToken(tokenResponse: AccessTokenResponse) { tokenResponse, ); } -