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:
Knee Cola
2025-12-31 11:56:01 +01:00
parent 1e83172491
commit 494d358130
9 changed files with 400 additions and 7 deletions

View File

@@ -76,7 +76,7 @@
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr> <tr>
<td style="text-align: center; padding: 0 0 30px 0;"> <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;"> 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 View Payment Details
</a> </a>
@@ -88,7 +88,7 @@
Or copy and paste this link into your browser: Or copy and paste this link into your browser:
</p> </p>
<p style="margin: 0 0 20px 0; font-size: 13px; line-height: 20px; color: #0070F3; text-align: center; word-break: break-all;"> <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> </p>
<!-- Divider --> <!-- Divider -->

View File

@@ -76,7 +76,7 @@
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr> <tr>
<td style="text-align: center; padding: 0 0 30px 0;"> <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;"> 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 Pogledaj detalje uplate
</a> </a>
@@ -88,7 +88,7 @@
Ili kopirajte i zalijepite ovaj link u svoj preglednik: Ili kopirajte i zalijepite ovaj link u svoj preglednik:
</p> </p>
<p style="margin: 0 0 20px 0; font-size: 13px; line-height: 20px; color: #0070F3; text-align: center; word-break: break-all;"> <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> </p>
<!-- Divider --> <!-- Divider -->

View File

@@ -113,7 +113,7 @@ Address: BillingLocation-tenantEmail
<p>Your rent for the apartment ${location.name} is due today.</p> <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> <p>Thank you!</p>

View 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}
/>
);
}

View 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>
);
}

View File

@@ -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 * Generate/activate share link for location
* Called when owner clicks "Share" button * Called when owner clicks "Share" button

View 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)}&currency=${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>);
};

View File

@@ -79,7 +79,10 @@
"payment-description-label": "Payment description:", "payment-description-label": "Payment description:",
"upload-proof-of-payment-legend": "Proof of payment", "upload-proof-of-payment-legend": "Proof of payment",
"upload-proof-of-payment-label": "Here you can upload 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": { "month-card": {
"total-due-label": "Monthly due total:", "total-due-label": "Monthly due total:",

View File

@@ -79,7 +79,10 @@
"payment-description-label": "Opis plaćanja:", "payment-description-label": "Opis plaćanja:",
"upload-proof-of-payment-legend": "Potvrda o uplati", "upload-proof-of-payment-legend": "Potvrda o uplati",
"upload-proof-of-payment-label": "Ovdje možete priložiti potvrdu 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": { "month-card": {
"total-due-label": "Ukupno neplaćeno u mjesecu:", "total-due-label": "Ukupno neplaćeno u mjesecu:",