From 494d358130ca5b3cdc58ac9489d7e4aacdcf427f Mon Sep 17 00:00:00 2001 From: Knee Cola Date: Wed, 31 Dec 2025 11:56:01 +0100 Subject: [PATCH] feat: add rent-due share page for rent payment information MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../email-template--rent-due--en.html | 4 +- .../email-template--rent-due--hr.html | 4 +- sprints/email-worker.md | 2 +- .../share/rent-due/[id]/RentViewPage.tsx | 47 ++++ .../app/[locale]/share/rent-due/[id]/page.tsx | 14 ++ web-app/app/lib/actions/locationActions.ts | 97 ++++++++ web-app/app/ui/ViewRentCard.tsx | 229 ++++++++++++++++++ web-app/messages/en.json | 5 +- web-app/messages/hr.json | 5 +- 9 files changed, 400 insertions(+), 7 deletions(-) create mode 100644 web-app/app/[locale]/share/rent-due/[id]/RentViewPage.tsx create mode 100644 web-app/app/[locale]/share/rent-due/[id]/page.tsx create mode 100644 web-app/app/ui/ViewRentCard.tsx diff --git a/email-worker/email-templates/email-template--rent-due--en.html b/email-worker/email-templates/email-template--rent-due--en.html index d159efd..8941b7f 100644 --- a/email-worker/email-templates/email-template--rent-due--en.html +++ b/email-worker/email-templates/email-template--rent-due--en.html @@ -76,7 +76,7 @@
- View Payment Details @@ -88,7 +88,7 @@ Or copy and paste this link into your browser:

- https://rezije.app/share/bills-due/${shareId} + https://rezije.app/share/rent-due/${shareId}

diff --git a/email-worker/email-templates/email-template--rent-due--hr.html b/email-worker/email-templates/email-template--rent-due--hr.html index 7fb676a..4b939b2 100644 --- a/email-worker/email-templates/email-template--rent-due--hr.html +++ b/email-worker/email-templates/email-template--rent-due--hr.html @@ -76,7 +76,7 @@
- Pogledaj detalje uplate @@ -88,7 +88,7 @@ Ili kopirajte i zalijepite ovaj link u svoj preglednik:

- https://rezije.app/share/bills-due/${shareId} + https://rezije.app/share/rent-due/${shareId}

diff --git a/sprints/email-worker.md b/sprints/email-worker.md index c6d5e0f..a350201 100644 --- a/sprints/email-worker.md +++ b/sprints/email-worker.md @@ -113,7 +113,7 @@ Address: BillingLocation-tenantEmail

Your rent for the apartment ${location.name} is due today.

-

For details and payment options please click the following link: Rent details

+

For details and payment options please click the following link: Rent details

Thank you!

diff --git a/web-app/app/[locale]/share/rent-due/[id]/RentViewPage.tsx b/web-app/app/[locale]/share/rent-due/[id]/RentViewPage.tsx new file mode 100644 index 0000000..43335f2 --- /dev/null +++ b/web-app/app/[locale]/share/rent-due/[id]/RentViewPage.tsx @@ -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 ( +
+

{accessValidation.error || 'This content is no longer shared'}

+
+ ); + } + + 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 ( + + ); +} diff --git a/web-app/app/[locale]/share/rent-due/[id]/page.tsx b/web-app/app/[locale]/share/rent-due/[id]/page.tsx new file mode 100644 index 0000000..78a65d4 --- /dev/null +++ b/web-app/app/[locale]/share/rent-due/[id]/page.tsx @@ -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 ( +
+ }> + + +
+ ); +} diff --git a/web-app/app/lib/actions/locationActions.ts b/web-app/app/lib/actions/locationActions.ts index f11c4e5..ae75494 100644 --- a/web-app/app/lib/actions/locationActions.ts +++ b/web-app/app/lib/actions/locationActions.ts @@ -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("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("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 diff --git a/web-app/app/ui/ViewRentCard.tsx b/web-app/app/ui/ViewRentCard.tsx new file mode 100644 index 0000000..d09d251 --- /dev/null +++ b/web-app/app/ui/ViewRentCard.tsx @@ -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 = ({ 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(null); + const [attachmentUploadedAt, setAttachmentUploadedAt] = useState(rentProofOfPayment?.uploadedAt ?? null); + const [attachmentFilename, setAttachmentFilename] = useState(rentProofOfPayment?.fileName); + + const handleFileChange = async (e: React.ChangeEvent) => { + 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 ( +
+
+

{formatYearMonth(yearMonth)} {locationName}

+
+ { + totalAmount > 0 ? +

+ {t("rent-amount-label")} {formatCurrency(totalAmount, userSettings?.currency)} +

+ : null + } + { + userSettings?.enableIbanPayment && tenantPaymentMethod === "iban" ? + <> +

{t("payment-info-header")}

+
    +
  • {t("payment-iban-label")}
    {formatIban(paymentParams.IBAN)}
  • +
  • {t("payment-recipient-label")}
    {paymentParams.Primatelj}
  • +
  • {t("payment-recipient-address-label")}
    {paymentParams.AdresaPrimatelja}
  • +
  • {t("payment-recipient-city-label")}
    {paymentParams.SjedistePrimatelja}
  • +
  • {t("payment-amount-label")}
    {paymentParams.Iznos} {userSettings?.currency}
  • +
  • {t("payment-description-label")}
    {paymentParams.OpisPlacanja}
  • +
  • {t("payment-model-label")}
    {paymentParams.ModelPlacanja}
  • +
  • {t("payment-reference-label")}
    {paymentParams.PozivNaBroj}
  • +
+ + + : null + } + { + userSettings?.enableRevolutPayment && tenantPaymentMethod === "revolut" ? (() => { + const revolutPaymentUrl = `https://revolut.me/${userSettings.ownerRevolutProfileName?.replace('@', '')}?amount=${(totalAmount).toFixed(0)}¤cy=${userSettings.currency}`; + return ( + <> +

{t("payment-info-header")}

+
+ +
+

+ + + {t("revolut-link-text")} + +

+ + ); + })() + : null + } + { + // Show upload fieldset for rent proof of payment when accessed via share link + shareId && proofOfPaymentType !== "none" && +
+ {t("upload-rent-proof-of-payment-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 ? ( +
+ + + {decodeURIComponent(attachmentFilename)} + +
+ ) : null + ) : /* ELSE show upload input */ ( +
+ +
+ + {isUploading && ( + + )} +
+ {uploadError && ( +

{uploadError}

+ )} +
+ )} +
+ } +
+
); +}; diff --git a/web-app/messages/en.json b/web-app/messages/en.json index 00243ec..d9e9438 100644 --- a/web-app/messages/en.json +++ b/web-app/messages/en.json @@ -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:", diff --git a/web-app/messages/hr.json b/web-app/messages/hr.json index a18424e..8de1ea7 100644 --- a/web-app/messages/hr.json +++ b/web-app/messages/hr.json @@ -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:",