Merge pull request #43 from nathanAndrinoid/open-swe/ef9cab13-fe22-4ed4-84ae-bb15b8720179

feat: Add payment methods management and Stripe integration for tenant payments
This commit is contained in:
nathanAndrinoid 2026-01-21 19:07:38 -06:00 committed by GitHub
commit 0e8c53fd68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1260 additions and 92 deletions

View File

@ -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=""

46
TODO.md
View File

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

View File

@ -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<HTMLFormElement>(null);
const encryptedRoutingNumberRef = useRef<HTMLInputElement>(null);
const encryptedAccountNumberRef = useRef<HTMLInputElement>(null);
const bankNameRef = useRef<HTMLInputElement>(null);
const accountTypeRef = useRef<HTMLSelectElement>(null);
const routingNumberRef = useRef<HTMLInputElement>(null);
const accountNumberRef = useRef<HTMLInputElement>(null);
const isDefaultRef = useRef<HTMLInputElement>(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 (
<div class="bg-white rounded-lg p-8 shadow-lg max-w-md w-full">
<h3 class="text-xl font-bold mb-4">Add Payment Method</h3>
<form
ref={formRef}
method="POST"
action="/api/create/payment_method"
class="flex flex-col gap-4"
>
<input
type="hidden"
name="sender"
value="/tenant/payment-methods"
/>
<input
ref={encryptedRoutingNumberRef}
type="hidden"
name="routing_number_encrypted"
value=""
/>
<input
ref={encryptedAccountNumberRef}
type="hidden"
name="account_number_encrypted"
value=""
/>
<div class="flex flex-col gap-1">
<label for="bank_name" class="text-sm font-medium text-gray-700">
Bank Name
</label>
<input
ref={bankNameRef}
class="border rounded px-3 py-2"
placeholder="e.g., Chase Bank"
name="bank_name"
required
/>
</div>
<div class="flex flex-col gap-1">
<label for="account_type" class="text-sm font-medium text-gray-700">
Account Type
</label>
<select
ref={accountTypeRef}
class="w-full border p-2 rounded"
name="account_type"
required
>
<option value="">Select account type</option>
<option value="checking">Checking</option>
<option value="savings">Savings</option>
</select>
</div>
<div class="flex flex-col gap-1">
<label for="routing_number" class="text-sm font-medium text-gray-700">
Routing Number
</label>
<input
ref={routingNumberRef}
class="border rounded px-3 py-2"
placeholder="9-digit routing number"
type="text"
inputMode="numeric"
pattern="[0-9]{9}"
maxLength={9}
name="routing_number"
required
/>
</div>
<div class="flex flex-col gap-1">
<label for="account_number" class="text-sm font-medium text-gray-700">
Account Number
</label>
<input
ref={accountNumberRef}
class="border rounded px-3 py-2"
placeholder="Account number"
type="text"
inputMode="numeric"
pattern="[0-9]+"
minLength={4}
name="account_number"
required
/>
</div>
<div class="flex items-center gap-2">
<input
ref={isDefaultRef}
id="is_default"
name="is_default"
type="checkbox"
value="true"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label for="is_default" class="text-sm text-gray-700">
Set as default payment method
</label>
</div>
</form>
<div class="flex gap-2 justify-end mt-4">
<a
href="/tenant/payment-methods"
class="px-4 py-2 rounded bg-gray-300 hover:bg-gray-400 text-center inline-block"
>
Cancel
</a>
<button
type="button"
onClick={handleAddClick}
class="px-4 py-2 rounded bg-green-600 text-white hover:bg-green-700"
disabled={loading}
>
{loading ? "Adding..." : "Add"}
</button>
</div>
</div>
);
}

View File

@ -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 (
<div class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
</div>
);
}
return (
<>
{popupAddPaymentMethodDialog}
<div class="space-y-4">
{processedMethods.length === 0
? (
<div class="text-center py-12 bg-white rounded-lg shadow">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 mx-auto text-gray-400 mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
/>
</svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">
No payment methods
</h3>
<p class="text-gray-500 mb-6">
Add a payment method to get started with rent payments.
</p>
<button
type="button"
onClick={() => _setIsOpeningModal(true)}
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-6 rounded-lg transition-colors"
>
Add Payment Method
</button>
</div>
)
: (
<>
<ul class="space-y-4">
{processedMethods.map((method) => (
<li
key={method.id}
class="bg-white rounded-lg shadow p-6 relative"
>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<h3 class="text-lg font-semibold text-gray-900">
{method.bank_name || "Bank Account"}
</h3>
{method.is_default && (
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Default
</span>
)}
</div>
<div class="space-y-1 text-sm text-gray-600">
<div>
<span class="font-medium">Type:</span>{" "}
{method.account_type === "checking"
? "Checking"
: method.account_type === "savings"
? "Savings"
: method.account_type || "Unknown"}
</div>
<div>
<span class="font-medium">Account:</span>{" "}
{method.maskedAccountNumber || "****"}
</div>
<div>
<span class="font-medium">Routing:</span>{" "}
{method.maskedRoutingNumber || "****"}
</div>
</div>
</div>
<div class="flex items-center gap-2 ml-4">
{!method.is_default && (
<button
type="button"
onClick={() => handleSetDefault(method.id!)}
class="text-blue-600 hover:text-blue-700 text-sm font-medium px-3 py-1.5 rounded border border-blue-600 hover:bg-blue-50 transition-colors"
>
Set Default
</button>
)}
<form
method="POST"
action="/api/delete/payment_method"
class="inline"
>
<input type="hidden" name="id" value={method.id} />
<input
type="hidden"
name="sender"
value={typeof location !== "undefined"
? location.pathname
: ""}
/>
<button
type="submit"
aria-label="Delete payment method"
class="text-red-600 hover:text-red-700 p-2 rounded hover:bg-red-50 transition-colors"
title="Delete payment method"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</form>
</div>
</div>
</li>
))}
</ul>
<div class="mt-6">
<button
type="button"
onClick={() => _setIsOpeningModal(true)}
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-6 rounded-lg shadow transition-colors flex items-center justify-center gap-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
Add Payment Method
</button>
</div>
</>
)}
</div>
</>
);
}

View File

@ -185,6 +185,14 @@ export default function TenantSettings({ user }: TenantSettingsProps) {
checked={paymentPrefs.payment_reminders}
onChange={() => handlePaymentChange("payment_reminders")}
/>
<div class="mt-4">
<a
href="/tenant/payment-methods"
class="text-blue-600 hover:text-blue-500 font-medium"
>
Manage Payment Methods →
</a>
</div>
{/* Auto-pay setup is handled on the Payment Methods page, not here */}
</div>
</div>

View File

@ -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<HTMLFormElement>(null);
const firstInputRef = useRef<HTMLInputElement>(null);
const [loading, setLoading] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrorState>({
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 (
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div class="p-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">
Add Payment Method
</h2>
<form ref={formRef}>
<div class="flex flex-col gap-1 my-4">
<label
for="bank_name"
class="text-sm font-medium text-gray-700"
>
Bank Name
</label>
<input
ref={firstInputRef}
class={`w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
getInputErrorClass(fieldErrors.bank_name)
}`}
name="bank_name"
placeholder="e.g., Chase Bank"
type="text"
required
onInput={() => clearFieldError("bank_name")}
/>
<div
class={`text-red-500 text-xs min-h-4 ${
fieldErrors.bank_name ? "visible" : "invisible"
}`}
aria-live="polite"
>
Bank name is required.
</div>
</div>
<div class="flex flex-col gap-1 my-4">
<label
for="account_type"
class="text-sm font-medium text-gray-700"
>
Account Type
</label>
<select
class={`w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
getInputErrorClass(fieldErrors.account_type)
}`}
name="account_type"
required
onInput={() => clearFieldError("account_type")}
>
<option value="">Select account type</option>
<option value="checking">Checking</option>
<option value="savings">Savings</option>
</select>
<div
class={`text-red-500 text-xs min-h-4 ${
fieldErrors.account_type ? "visible" : "invisible"
}`}
aria-live="polite"
>
Account type is required.
</div>
</div>
<div class="flex flex-col gap-1 my-4">
<label
for="routing_number"
class="text-sm font-medium text-gray-700"
>
Routing Number
</label>
<input
class={`w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
getInputErrorClass(fieldErrors.routing_number)
}`}
name="routing_number"
placeholder="9-digit routing number"
type="text"
inputMode="numeric"
pattern="[0-9]{9}"
maxLength={9}
required
onInput={() => clearFieldError("routing_number")}
/>
<div
class={`text-red-500 text-xs min-h-4 ${
fieldErrors.routing_number ? "visible" : "invisible"
}`}
aria-live="polite"
>
Routing number must be 9 digits.
</div>
</div>
<div class="flex flex-col gap-1 my-4">
<label
for="account_number"
class="text-sm font-medium text-gray-700"
>
Account Number
</label>
<input
class={`w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
getInputErrorClass(fieldErrors.account_number)
}`}
name="account_number"
placeholder="Account number"
type="text"
inputMode="numeric"
pattern="[0-9]+"
minLength={4}
required
onInput={() => clearFieldError("account_number")}
/>
<div
class={`text-red-500 text-xs min-h-4 ${
fieldErrors.account_number ? "visible" : "invisible"
}`}
aria-live="polite"
>
Account number is required (min 4 digits).
</div>
</div>
<div class="flex items-center gap-2 my-4">
<input
id="is_default"
name="is_default"
type="checkbox"
value="true"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label
for="is_default"
class="text-sm text-gray-700"
>
Set as default payment method
</label>
</div>
</form>
<div class="flex justify-end gap-2 mt-6">
<button
type="button"
class={`px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 ${
loading ? "opacity-70" : ""
}`}
onClick={() => {
localStorage.setItem(
"lock-route",
"/tenant/payment-methods?modal=off",
);
setLoading(true);
onClose();
}}
disabled={loading}
>
Cancel
</button>
<button
type="button"
class={`px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 ${
loading ? "opacity-70" : ""
}`}
disabled={loading}
onClick={addNewPaymentMethod}
>
{loading ? "Adding..." : "Add Payment Method"}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -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<PopupController | null>(() => {
return options.defaultOpen ? { resolve: () => {} } : null;
});
// Show the popup and return a Promise for the answer
const popAddPaymentMethod = (): Promise<boolean> => {
return new Promise((resolve) => {
setPopup({ resolve });
});
};
// Render the popup if needed
const popupAddPaymentMethodDialog = popup
? (
<AddPaymentMethodDialog
onClose={() => {
globalThis.location.href = "/tenant/payment-methods?modal=off";
}}
userId={options.userId}
/>
)
: null;
return [popAddPaymentMethod, popupAddPaymentMethodDialog] as const;
}

View File

@ -201,5 +201,3 @@ if (isAppReady) {
// if (import.meta.main) {
// await app.listen();
// }

View File

@ -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<string, string> = {};
// 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" },
},
);
}
},
});

View File

@ -90,4 +90,3 @@ export const handler = define.handlers({
}
},
});

View File

@ -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<string, string> = {};
// 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" },
},
);
}
},
});

View File

@ -38,7 +38,9 @@ export const handler = define.handlers({
<div class="row"><div class="label">Lease ID</div><div class="value">${leaseId}</div></div>
<div class="row"><div class="label">Amount</div><div class="value">$${amount.toLocaleString()}</div></div>
<div class="row"><div class="label">Method</div><div class="value">${method}</div></div>
<div class="row"><div class="label">Date</div><div class="value">${new Date(date).toLocaleString()}</div></div>
<div class="row"><div class="label">Date</div><div class="value">${
new Date(date).toLocaleString()
}</div></div>
</div>
</body>
</html>`;
@ -49,4 +51,3 @@ export const handler = define.handlers({
});
},
});

View File

@ -43,20 +43,3 @@ export const handler = define.handlers({
}
},
});

View File

@ -282,4 +282,3 @@ export default define.page(function LandlordPayments({ data }) {
</div>
);
});

View File

@ -150,40 +150,3 @@ export default define.page(function InviteTenantPage({ data }) {
</div>
);
});

View File

@ -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<PaymentMethodsPageData>({
error: "Failed to load payment methods",
});
}
return page<PaymentMethodsPageData>({
paymentMethods: data.payment_methods || [],
});
} catch (error) {
console.error("Error loading payment methods:", error);
return page<PaymentMethodsPageData>({
error: "Failed to load payment methods",
});
}
},
});
export default define.page(function PaymentMethodsPage({
data,
}: {
data: PaymentMethodsPageData;
}) {
const { paymentMethods, error } = data as PaymentMethodsPageData;
if (error) {
return (
<div class="min-h-screen bg-gray-50 p-8">
<div class="max-w-4xl mx-auto">
<h1 class="text-3xl font-bold text-red-600 mb-4">Error</h1>
<p class="text-gray-700">{error}</p>
<div class="mt-6">
<a
href="/tenant/dashboard"
class="text-blue-600 hover:text-blue-500 font-medium"
>
Go to Dashboard
</a>
</div>
</div>
</div>
);
}
return (
<div class="min-h-screen bg-gray-50">
<div class="bg-white shadow">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6">
<h1 class="text-3xl font-bold text-gray-900">
Payment Methods
</h1>
<p class="mt-1 text-sm text-gray-500">
Manage your payment methods for rent payments.
</p>
</div>
</div>
</div>
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8 py-8">
<PaymentMethodsList paymentMethods={paymentMethods || []} />
</div>
</div>
);
});

View File

@ -103,7 +103,7 @@ export default define.page(function TenantPayments({ data }) {
Payment Methods
</h3>
<a
href="/tenant/settings/payment-methods" // Placeholder for settings route
href="/tenant/payment-methods"
class="text-sm font-medium text-blue-600 hover:text-blue-500"
>
Manage Payment Methods

View File

@ -21,9 +21,7 @@ export default define.page(function RentConfirmation({ data }) {
<h1 class="text-2xl font-bold text-green-700 mb-2">
Payment Successful
</h1>
{amount && (
<p class="text-gray-700 mb-4">Amount: ${amount}</p>
)}
{amount && <p class="text-gray-700 mb-4">Amount: ${amount}</p>}
<p class="text-gray-600 mb-6">
A receipt is available from your payment history.
</p>
@ -55,4 +53,3 @@ export default define.page(function RentConfirmation({ data }) {
</div>
);
});

View File

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

View File

@ -387,4 +387,3 @@ export async function cancelAutoPayment(
.where(eq(leases.id, lease_id))
.returning();
}

View File

@ -592,4 +592,3 @@ export async function upsertAccessToken(tokenResponse: AccessTokenResponse) {
tokenResponse,
);
}