feat: add rent-due share page for rent payment information
Created a new /share/rent-due/ page to display rent payment information separately from utility bills. Changes: - Created /share/rent-due/[id]/ page structure with RentViewPage component - Created ViewRentCard component to display rent amount and payment info - Added uploadRentProofOfPayment action for tenant proof upload - Added translation keys for rent-specific labels (en/hr) - Updated rent email templates to link to /share/rent-due/ instead of /share/bills-due/ - Updated documentation to reflect new URL structure The rent page displays: - Rent amount - IBAN or Revolut payment information with QR/barcode - Rent proof of payment upload (when enabled) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -76,7 +76,7 @@
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="text-align: center; padding: 0 0 30px 0;">
|
||||
<a href="https://rezije.app/share/bills-due/${shareId}"
|
||||
<a href="https://rezije.app/share/rent-due/${shareId}"
|
||||
style="display: inline-block; padding: 14px 40px; background-color: #5b5ba6; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: bold; border-radius: 6px; border: none;">
|
||||
View Payment Details
|
||||
</a>
|
||||
@@ -88,7 +88,7 @@
|
||||
Or copy and paste this link into your browser:
|
||||
</p>
|
||||
<p style="margin: 0 0 20px 0; font-size: 13px; line-height: 20px; color: #0070F3; text-align: center; word-break: break-all;">
|
||||
https://rezije.app/share/bills-due/${shareId}
|
||||
https://rezije.app/share/rent-due/${shareId}
|
||||
</p>
|
||||
|
||||
<!-- Divider -->
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="text-align: center; padding: 0 0 30px 0;">
|
||||
<a href="https://rezije.app/share/bills-due/${shareId}"
|
||||
<a href="https://rezije.app/share/rent-due/${shareId}"
|
||||
style="display: inline-block; padding: 14px 40px; background-color: #5b5ba6; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: bold; border-radius: 6px; border: none;">
|
||||
Pogledaj detalje uplate
|
||||
</a>
|
||||
@@ -88,7 +88,7 @@
|
||||
Ili kopirajte i zalijepite ovaj link u svoj preglednik:
|
||||
</p>
|
||||
<p style="margin: 0 0 20px 0; font-size: 13px; line-height: 20px; color: #0070F3; text-align: center; word-break: break-all;">
|
||||
https://rezije.app/share/bills-due/${shareId}
|
||||
https://rezije.app/share/rent-due/${shareId}
|
||||
</p>
|
||||
|
||||
<!-- Divider -->
|
||||
|
||||
@@ -113,7 +113,7 @@ Address: BillingLocation-tenantEmail
|
||||
|
||||
<p>Your rent for the apartment ${location.name} is due today.</p>
|
||||
|
||||
<p>For details and payment options please click the following link: <a href="https://rezije.app/share/bills-due/${shareId}">Rent details</a></p>
|
||||
<p>For details and payment options please click the following link: <a href="https://rezije.app/share/rent-due/${shareId}">Rent details</a></p>
|
||||
|
||||
<p>Thank you!</p>
|
||||
|
||||
|
||||
47
web-app/app/[locale]/share/rent-due/[id]/RentViewPage.tsx
Normal file
47
web-app/app/[locale]/share/rent-due/[id]/RentViewPage.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ViewRentCard } from '@/app/ui/ViewRentCard';
|
||||
import { fetchLocationById, setSeenByTenantAt, validateShareAccess } from '@/app/lib/actions/locationActions';
|
||||
import { getUserSettingsByUserId } from '@/app/lib/actions/userSettingsActions';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { myAuth } from '@/app/lib/auth';
|
||||
|
||||
export default async function RentViewPage({ shareId }: { shareId: string }) {
|
||||
// Validate share access (checks checksum + TTL, extracts locationId)
|
||||
const accessValidation = await validateShareAccess(shareId);
|
||||
|
||||
if (!accessValidation.valid || !accessValidation.locationId) {
|
||||
return (
|
||||
<div className="alert alert-error">
|
||||
<p>{accessValidation.error || 'This content is no longer shared'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const locationId = accessValidation.locationId;
|
||||
|
||||
// Fetch location
|
||||
const location = await fetchLocationById(locationId);
|
||||
|
||||
if (!location) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
// Fetch user settings for the location owner
|
||||
const userSettings = await getUserSettingsByUserId(location.userId);
|
||||
|
||||
// Check if the page was accessed by an authenticated user who is the owner
|
||||
const session = await myAuth();
|
||||
const isOwner = session?.user?.id === location.userId;
|
||||
|
||||
// If the page is not visited by the owner, mark it as seen by tenant
|
||||
if (!isOwner) {
|
||||
await setSeenByTenantAt(locationId);
|
||||
}
|
||||
|
||||
return (
|
||||
<ViewRentCard
|
||||
location={location}
|
||||
userSettings={userSettings}
|
||||
shareId={shareId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
14
web-app/app/[locale]/share/rent-due/[id]/page.tsx
Normal file
14
web-app/app/[locale]/share/rent-due/[id]/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Suspense } from 'react';
|
||||
import RentViewPage from './RentViewPage';
|
||||
import { Main } from '@/app/ui/Main';
|
||||
import { LocationEditFormSkeleton } from '@/app/ui/LocationEditForm';
|
||||
|
||||
export default async function Page({ params: { id } }: { params: { id: string } }) {
|
||||
return (
|
||||
<Main>
|
||||
<Suspense fallback={<LocationEditFormSkeleton />}>
|
||||
<RentViewPage shareId={id} />
|
||||
</Suspense>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
@@ -784,6 +784,103 @@ export const uploadUtilBillsProofOfPayment = async (
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload rent proof of payment (for tenants via share link)
|
||||
* Similar to uploadUtilBillsProofOfPayment but for rent payments
|
||||
*/
|
||||
export const uploadRentProofOfPayment = async (
|
||||
shareId: string,
|
||||
formData: FormData,
|
||||
ipAddress?: string
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
|
||||
unstable_noStore();
|
||||
|
||||
try {
|
||||
// 1. EXTRACT AND VALIDATE CHECKSUM (stateless, fast)
|
||||
const extracted = extractShareId(shareId);
|
||||
if (!extracted) {
|
||||
console.log('shareID extraction failed');
|
||||
return { success: false, error: 'Invalid share link' };
|
||||
}
|
||||
|
||||
const { locationId: locationID, checksum } = extracted;
|
||||
|
||||
if (!validateShareChecksum(locationID, checksum)) {
|
||||
console.log('shareID checksum validation failed');
|
||||
return { success: false, error: 'Invalid share link' };
|
||||
}
|
||||
|
||||
// 2. RATE LIMITING (per IP)
|
||||
if (ipAddress) {
|
||||
const rateLimit = checkUploadRateLimit(ipAddress);
|
||||
if (!rateLimit.allowed) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Too many uploads. Try again in ${Math.ceil(rateLimit.resetIn / 60)} minutes.`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3. DATABASE VALIDATION
|
||||
const dbClient = await getDbClient();
|
||||
|
||||
const location = await dbClient.collection<BillingLocation>("lokacije")
|
||||
.findOne({ _id: locationID }, { projection: { userId: 1, rentProofOfPayment: 1, shareTTL: 1 } });
|
||||
|
||||
if (!location || !location.userId) {
|
||||
return { success: false, error: 'Invalid request' };
|
||||
}
|
||||
|
||||
// Check sharing is active and not expired
|
||||
if (!location.shareTTL || new Date() > location.shareTTL) {
|
||||
return { success: false, error: 'This content is no longer shared' };
|
||||
}
|
||||
|
||||
// Check if proof of payment already uploaded
|
||||
if (location.rentProofOfPayment) {
|
||||
return { success: false, error: 'Proof of payment already uploaded for this location' };
|
||||
}
|
||||
|
||||
// 4. FILE VALIDATION
|
||||
const file = formData.get('rentProofOfPayment') as File;
|
||||
|
||||
if (!file || file.size === 0) {
|
||||
return { success: false, error: 'No file provided' };
|
||||
}
|
||||
|
||||
// Validate PDF content (magic bytes, not just MIME type)
|
||||
const pdfValidation = await validatePdfFile(file);
|
||||
if (!pdfValidation.valid) {
|
||||
return { success: false, error: pdfValidation.error };
|
||||
}
|
||||
|
||||
// 5. SERIALIZE & STORE FILE
|
||||
const attachment = await serializeAttachment(file);
|
||||
|
||||
if (!attachment) {
|
||||
return { success: false, error: 'Failed to process file' };
|
||||
}
|
||||
|
||||
// 6. UPDATE DATABASE
|
||||
await dbClient.collection<BillingLocation>("lokacije")
|
||||
.updateOne(
|
||||
{ _id: locationID },
|
||||
{ $set: {
|
||||
rentProofOfPayment: attachment
|
||||
} }
|
||||
);
|
||||
|
||||
// 7. REVALIDATE CACHE
|
||||
revalidatePath(`/share/rent-due/${shareId}`, 'page');
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error('Upload error:', error);
|
||||
return { success: false, error: 'Upload failed. Please try again.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate/activate share link for location
|
||||
* Called when owner clicks "Share" button
|
||||
|
||||
229
web-app/app/ui/ViewRentCard.tsx
Normal file
229
web-app/app/ui/ViewRentCard.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
'use client';
|
||||
|
||||
import { FC, useMemo, useState } from "react";
|
||||
import { BillingLocation, UserSettings } from '@evidencija-rezija/shared-code';
|
||||
import { formatYearMonth } from "../lib/format";
|
||||
import { formatCurrency, formatIban } from "../lib/formatStrings";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { EncodePayment, PaymentParams } from "hub-3a-payment-encoder";
|
||||
import Link from "next/link";
|
||||
import { LinkIcon } from "@heroicons/react/24/outline";
|
||||
import { uploadRentProofOfPayment } from "../lib/actions/locationActions";
|
||||
import QRCode from "react-qr-code";
|
||||
import { TicketIcon } from "@heroicons/react/24/solid";
|
||||
import { Pdf417Barcode } from "./Pdf417Barcode";
|
||||
|
||||
export interface ViewRentCardProps {
|
||||
location: BillingLocation;
|
||||
userSettings: UserSettings | null;
|
||||
shareId?: string;
|
||||
}
|
||||
|
||||
export const ViewRentCard: FC<ViewRentCardProps> = ({ location, userSettings, shareId }) => {
|
||||
|
||||
const {
|
||||
_id,
|
||||
name: locationName,
|
||||
yearMonth,
|
||||
rentAmount,
|
||||
tenantName,
|
||||
tenantStreet,
|
||||
tenantTown,
|
||||
tenantPaymentMethod,
|
||||
rentProofOfPayment,
|
||||
proofOfPaymentType,
|
||||
} = location;
|
||||
|
||||
const router = useRouter();
|
||||
const t = useTranslations("home-page.location-card");
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [attachmentUploadedAt, setAttachmentUploadedAt] = useState<Date | null>(rentProofOfPayment?.uploadedAt ?? null);
|
||||
const [attachmentFilename, setAttachmentFilename] = useState(rentProofOfPayment?.fileName);
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type client-side (quick feedback)
|
||||
if (file.type !== 'application/pdf') {
|
||||
setUploadError('Only PDF files are accepted');
|
||||
e.target.value = ''; // Reset input
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shareId) {
|
||||
setUploadError('Invalid upload link');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('rentProofOfPayment', file);
|
||||
|
||||
const result = await uploadRentProofOfPayment(shareId, formData);
|
||||
|
||||
if (result.success) {
|
||||
setAttachmentFilename(file.name);
|
||||
setAttachmentUploadedAt(new Date());
|
||||
router.refresh();
|
||||
} else {
|
||||
setUploadError(result.error || 'Upload failed');
|
||||
}
|
||||
} catch (error: any) {
|
||||
setUploadError(error.message || 'Upload failed');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
e.target.value = ''; // Reset input
|
||||
}
|
||||
};
|
||||
|
||||
const totalAmount = rentAmount ?? 0;
|
||||
|
||||
const { hub3aText, paymentParams } = useMemo(() => {
|
||||
|
||||
if (!userSettings?.enableIbanPayment || tenantPaymentMethod !== "iban") {
|
||||
return {
|
||||
hub3aText: "",
|
||||
paymentParams: {} as PaymentParams
|
||||
};
|
||||
}
|
||||
|
||||
const locationNameTrimmed_max20 = locationName.trimEnd().trimEnd().substring(0, 19);
|
||||
|
||||
const paymentParams: PaymentParams = {
|
||||
Iznos: (totalAmount / 100).toFixed(2).replace(".", ","),
|
||||
ImePlatitelja: tenantName ?? "",
|
||||
AdresaPlatitelja: tenantStreet ?? "",
|
||||
SjedistePlatitelja: tenantTown ?? "",
|
||||
Primatelj: userSettings?.ownerName ?? "",
|
||||
AdresaPrimatelja: userSettings?.ownerStreet ?? "",
|
||||
SjedistePrimatelja: userSettings?.ownerTown ?? "",
|
||||
IBAN: userSettings?.ownerIBAN ?? "",
|
||||
ModelPlacanja: "HR00",
|
||||
PozivNaBroj: formatYearMonth(yearMonth),
|
||||
SifraNamjene: "",
|
||||
OpisPlacanja: `Najam-${locationNameTrimmed_max20}-${formatYearMonth(yearMonth)}`, // max length 35 = "Najam-" (6) + locationName (20) + "-" (1) + "YYYY-MM" (7) + buffer (1)
|
||||
};
|
||||
|
||||
return ({
|
||||
hub3aText: EncodePayment(paymentParams),
|
||||
paymentParams
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]);
|
||||
|
||||
return (
|
||||
<div data-key={_id} className="card card-compact card-bordered max-w-[30em] min-w-[330px] bg-base-100 border-1 border-neutral my-1">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title mr-[2em] text-[1.3rem]">{formatYearMonth(yearMonth)} {locationName}</h2>
|
||||
<div className="divider my-1"></div>
|
||||
{
|
||||
totalAmount > 0 ?
|
||||
<p className="text-[1.2rem]">
|
||||
{t("rent-amount-label")} <strong>{formatCurrency(totalAmount, userSettings?.currency)}</strong>
|
||||
</p>
|
||||
: null
|
||||
}
|
||||
{
|
||||
userSettings?.enableIbanPayment && tenantPaymentMethod === "iban" ?
|
||||
<>
|
||||
<p className="max-w-[25em] ml-1 mt-1 mb-1">{t("payment-info-header")}</p>
|
||||
<ul className="ml-4 mb-3">
|
||||
<li><strong>{t("payment-iban-label")}</strong><pre className="inline pl-1">{formatIban(paymentParams.IBAN)}</pre></li>
|
||||
<li><strong>{t("payment-recipient-label")}</strong> <pre className="inline pl-1">{paymentParams.Primatelj}</pre></li>
|
||||
<li><strong>{t("payment-recipient-address-label")}</strong><pre className="inline pl-1">{paymentParams.AdresaPrimatelja}</pre></li>
|
||||
<li><strong>{t("payment-recipient-city-label")}</strong><pre className="inline pl-1">{paymentParams.SjedistePrimatelja}</pre></li>
|
||||
<li><strong>{t("payment-amount-label")}</strong> <pre className="inline pl-1">{paymentParams.Iznos} {userSettings?.currency}</pre></li>
|
||||
<li><strong>{t("payment-description-label")}</strong><pre className="inline pl-1">{paymentParams.OpisPlacanja}</pre></li>
|
||||
<li><strong>{t("payment-model-label")}</strong><pre className="inline pl-1">{paymentParams.ModelPlacanja}</pre></li>
|
||||
<li><strong>{t("payment-reference-label")}</strong><pre className="inline pl-1">{paymentParams.PozivNaBroj}</pre></li>
|
||||
</ul>
|
||||
<label className="label p-2 grow bg-white border border-gray-300 rounded-box justify-center">
|
||||
<Pdf417Barcode hub3aText={hub3aText} className="w-full max-w-[35rem] sm:max-w-[25rem]" />
|
||||
</label>
|
||||
</>
|
||||
: null
|
||||
}
|
||||
{
|
||||
userSettings?.enableRevolutPayment && tenantPaymentMethod === "revolut" ? (() => {
|
||||
const revolutPaymentUrl = `https://revolut.me/${userSettings.ownerRevolutProfileName?.replace('@', '')}?amount=${(totalAmount).toFixed(0)}¤cy=${userSettings.currency}`;
|
||||
return (
|
||||
<>
|
||||
<p className="max-w-[25em] ml-1 mt-1 mb-1">{t("payment-info-header")}</p>
|
||||
<div className="flex justify-center">
|
||||
<QRCode value={revolutPaymentUrl} size={200} className="p-4 bg-white border border-gray-300 rounded-box" />
|
||||
</div>
|
||||
<p className="text-center mt-1 mb-3">
|
||||
<LinkIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1 ml-[-.5em]" />
|
||||
<Link
|
||||
href={revolutPaymentUrl}
|
||||
target="_blank"
|
||||
className="underline"
|
||||
>
|
||||
{t("revolut-link-text")}
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
: null
|
||||
}
|
||||
{
|
||||
// Show upload fieldset for rent proof of payment when accessed via share link
|
||||
shareId && proofOfPaymentType !== "none" &&
|
||||
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 pt-0 mt-2">
|
||||
<legend className="fieldset-legend font-semibold uppercase">{t("upload-rent-proof-of-payment-legend")}</legend>
|
||||
{
|
||||
// IF proof of payment was uploaded
|
||||
attachmentUploadedAt ? (
|
||||
// IF file name is available, show link to download
|
||||
// ELSE it's not available that means that the uploaded file was purged by housekeeping
|
||||
// -> don't show anything
|
||||
attachmentFilename ? (
|
||||
<div className="mt-3 ml-[-.5rem]">
|
||||
<Link
|
||||
href={`/share/proof-of-payment/rent/${shareId || _id}/`}
|
||||
target="_blank"
|
||||
className='text-center w-full max-w-[20rem] text-nowrap truncate inline-block'
|
||||
>
|
||||
<TicketIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1 mt-[-.1em] text-teal-500" />
|
||||
{decodeURIComponent(attachmentFilename)}
|
||||
</Link>
|
||||
</div>
|
||||
) : null
|
||||
) : /* ELSE show upload input */ (
|
||||
<div className="form-control w-full">
|
||||
<label className="label">
|
||||
<span className="label-text">{t("upload-rent-proof-of-payment-label")}</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="rentProofOfPayment"
|
||||
name="rentProofOfPayment"
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
className="file-input file-input-bordered grow file-input-sm my-2 block max-w-[17em] md:max-w-[80em] break-words"
|
||||
onChange={handleFileChange}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
{isUploading && (
|
||||
<span className="loading loading-spinner loading-sm"></span>
|
||||
)}
|
||||
</div>
|
||||
{uploadError && (
|
||||
<p className="text-sm text-red-500 mt-1">{uploadError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
}
|
||||
</div>
|
||||
</div>);
|
||||
};
|
||||
@@ -79,7 +79,10 @@
|
||||
"payment-description-label": "Payment description:",
|
||||
"upload-proof-of-payment-legend": "Proof of payment",
|
||||
"upload-proof-of-payment-label": "Here you can upload proof of payment:",
|
||||
"revolut-link-text": "Pay with Revolut"
|
||||
"revolut-link-text": "Pay with Revolut",
|
||||
"rent-amount-label": "Rent amount:",
|
||||
"upload-rent-proof-of-payment-legend": "Rent proof of payment",
|
||||
"upload-rent-proof-of-payment-label": "Here you can upload rent proof of payment:"
|
||||
},
|
||||
"month-card": {
|
||||
"total-due-label": "Monthly due total:",
|
||||
|
||||
@@ -79,7 +79,10 @@
|
||||
"payment-description-label": "Opis plaćanja:",
|
||||
"upload-proof-of-payment-legend": "Potvrda o uplati",
|
||||
"upload-proof-of-payment-label": "Ovdje možete priložiti potvrdu o uplati:",
|
||||
"revolut-link-text": "Plati pomoću Revoluta"
|
||||
"revolut-link-text": "Plati pomoću Revoluta",
|
||||
"rent-amount-label": "Iznos najamnine:",
|
||||
"upload-rent-proof-of-payment-legend": "Potvrda o uplati najamnine",
|
||||
"upload-rent-proof-of-payment-label": "Ovdje možete priložiti potvrdu o uplati najamnine:"
|
||||
},
|
||||
"month-card": {
|
||||
"total-due-label": "Ukupno neplaćeno u mjesecu:",
|
||||
|
||||
Reference in New Issue
Block a user