|
-
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" &&
+
+ }
+
+ );
+};
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:",
|