370 lines
14 KiB
TypeScript
370 lines
14 KiB
TypeScript
// 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";
|
|
|
|
// Flexible interface for rent payment page data
|
|
interface RentPaymentPageData {
|
|
currentLease?: Lease | null;
|
|
paymentMethods?: PaymentMethod[];
|
|
error?: string;
|
|
success?: boolean;
|
|
paymentAmount?: number;
|
|
dueDate?: string;
|
|
lateFee?: number;
|
|
// Allow additional properties for future extensibility
|
|
[key: string]: string | number | boolean | object | null | undefined;
|
|
}
|
|
|
|
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 leases = await getLeasesByTenant(user_id);
|
|
const paymentMethods = await getPaymentMethodsByUser(user_id);
|
|
const currentLease = leases?.[0]; // Assuming tenant has one active lease
|
|
|
|
return page({
|
|
currentLease,
|
|
paymentMethods,
|
|
error: "",
|
|
success: false,
|
|
paymentAmount: 0,
|
|
dueDate: "",
|
|
lateFee: 0,
|
|
});
|
|
} catch (error) {
|
|
console.error("Error loading rent payment page:", error);
|
|
return page({
|
|
error: "Failed to load payment information",
|
|
});
|
|
}
|
|
},
|
|
|
|
async POST(ctx) {
|
|
const { user_id } = ctx.state.data;
|
|
|
|
if (!user_id) {
|
|
return new Response("", {
|
|
status: 302,
|
|
headers: { Location: "/auth/login" },
|
|
});
|
|
}
|
|
|
|
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 lease_id = formData.get("lease_id") as string;
|
|
|
|
// 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,
|
|
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: redirectUrl.toString() },
|
|
});
|
|
} catch (error) {
|
|
console.error("Error processing payment:", error);
|
|
return page({
|
|
error: "Payment processing failed. Please try again.",
|
|
});
|
|
}
|
|
},
|
|
});
|
|
|
|
export default define.page(
|
|
function RentPayment({ data }: { data: RentPaymentPageData }) {
|
|
const { currentLease, paymentMethods, error } = data;
|
|
|
|
if (!currentLease) {
|
|
return (
|
|
<div class="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
<div class="text-center">
|
|
<h1 class="text-2xl font-bold text-gray-900 mb-4">
|
|
No Active Lease
|
|
</h1>
|
|
<p class="text-gray-600">
|
|
You don't have an active lease to make payments for.
|
|
</p>
|
|
<a
|
|
href="/tenant/dashboard"
|
|
class="mt-4 inline-block text-blue-600 hover:text-blue-500"
|
|
>
|
|
Return to Dashboard
|
|
</a>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div class="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
|
<div class="max-w-3xl mx-auto">
|
|
<div class="text-center mb-8">
|
|
<h2 class="text-3xl font-extrabold text-gray-900">Pay Rent</h2>
|
|
<p class="mt-2 text-sm text-gray-600">
|
|
Make your monthly rent payment securely online
|
|
</p>
|
|
</div>
|
|
|
|
<div class="bg-white shadow sm:rounded-lg">
|
|
<div class="px-4 py-5 sm:p-6">
|
|
{error && (
|
|
<div class="mb-6 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Property Information */}
|
|
<div class="mb-6 p-4 bg-gray-50 rounded-lg">
|
|
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
|
Property Information
|
|
</h3>
|
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500">
|
|
Property Address
|
|
</dt>
|
|
<dd class="mt-1 text-sm text-gray-900">
|
|
{currentLease.property_address || "Property Address"}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500">
|
|
Monthly Rent
|
|
</dt>
|
|
<dd class="mt-1 text-sm text-gray-900">
|
|
${currentLease.rent?.toLocaleString() || "N/A"}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500">Due Date</dt>
|
|
<dd class="mt-1 text-sm text-gray-900">
|
|
1st of each month
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500">Late Fee</dt>
|
|
<dd class="mt-1 text-sm text-gray-900">
|
|
$50 after 5th of month
|
|
</dd>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<form method="POST" class="space-y-6">
|
|
<input type="hidden" name="lease_id" value={currentLease.id} />
|
|
|
|
{/* Payment Amount */}
|
|
<div>
|
|
<label
|
|
for="amount"
|
|
class="block text-sm font-medium text-gray-700"
|
|
>
|
|
Payment Amount
|
|
</label>
|
|
<div class="mt-1 relative rounded-md shadow-sm">
|
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
<span class="text-gray-500 sm:text-sm">$</span>
|
|
</div>
|
|
<input
|
|
type="number"
|
|
name="amount"
|
|
id="amount"
|
|
step="0.01"
|
|
min="0"
|
|
value={currentLease.rent || 0}
|
|
required
|
|
class="focus:ring-blue-500 focus:border-blue-500 block w-full pl-7 pr-12 sm:text-sm border-gray-300 rounded-md"
|
|
placeholder="0.00"
|
|
/>
|
|
</div>
|
|
<p class="mt-2 text-sm text-gray-500">
|
|
Standard monthly rent:
|
|
${currentLease.rent?.toLocaleString() || "N/A"}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Payment Method */}
|
|
<div>
|
|
<label
|
|
for="payment_method_id"
|
|
class="block text-sm font-medium text-gray-700"
|
|
>
|
|
Payment Method
|
|
</label>
|
|
<select
|
|
id="payment_method_id"
|
|
name="payment_method_id"
|
|
required
|
|
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md"
|
|
>
|
|
<option value="">Select payment method</option>
|
|
{paymentMethods && paymentMethods.length > 0
|
|
? (
|
|
paymentMethods.map((method) => (
|
|
<option key={method.id} value={method.id}>
|
|
{method.method_type === "bank_account"
|
|
? "Bank Account"
|
|
: "Credit Card"}
|
|
{method.is_default && " (Default)"}
|
|
</option>
|
|
))
|
|
)
|
|
: (
|
|
<option value="" disabled>
|
|
No payment methods available
|
|
</option>
|
|
)}
|
|
</select>
|
|
{(!paymentMethods || paymentMethods.length === 0) && (
|
|
<p class="mt-2 text-sm text-red-600">
|
|
You need to add a payment method first.{" "}
|
|
<a
|
|
href="/tenant/payment-methods/add"
|
|
class="text-blue-600 hover:text-blue-500"
|
|
>
|
|
Add payment method
|
|
</a>
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Payment Schedule */}
|
|
<div class="bg-blue-50 border border-blue-200 rounded-md p-4">
|
|
<div class="flex">
|
|
<div class="flex-shrink-0">
|
|
<svg
|
|
class="h-5 w-5 text-blue-400"
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
>
|
|
<path
|
|
fill-rule="evenodd"
|
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
|
clip-rule="evenodd"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div class="ml-3">
|
|
<h3 class="text-sm font-medium text-blue-800">
|
|
Automatic Payments Available
|
|
</h3>
|
|
<div class="mt-2 text-sm text-blue-700">
|
|
<p>
|
|
Set up automatic monthly payments to never miss a due
|
|
date.{" "}
|
|
<a
|
|
href="/tenant/autopay/setup"
|
|
class="font-medium underline"
|
|
>
|
|
Enable autopay
|
|
</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Security Notice */}
|
|
<div class="bg-gray-50 border border-gray-200 rounded-md p-4">
|
|
<div class="flex">
|
|
<div class="flex-shrink-0">
|
|
<svg
|
|
class="h-5 w-5 text-gray-400"
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
>
|
|
<path
|
|
fill-rule="evenodd"
|
|
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
|
|
clip-rule="evenodd"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div class="ml-3">
|
|
<h3 class="text-sm font-medium text-gray-800">
|
|
Secure Payment Processing
|
|
</h3>
|
|
<div class="mt-2 text-sm text-gray-600">
|
|
<p>
|
|
Your payment information is encrypted and processed
|
|
securely. You will receive a confirmation email once
|
|
your payment is processed.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Submit Button */}
|
|
<div class="flex justify-between pt-6">
|
|
<a
|
|
href="/tenant/dashboard"
|
|
class="inline-flex justify-center py-2 px-4 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
|
>
|
|
Cancel
|
|
</a>
|
|
<button
|
|
type="submit"
|
|
disabled={!paymentMethods || paymentMethods.length === 0}
|
|
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
|
>
|
|
Pay ${currentLease.rent?.toLocaleString() || "0"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
|
|
|