Files
evidencija-rezija/web-app/app/lib/actions/billActions.ts
Knee Cola 7e7eb5a2d8 fix: only trigger bill forwarding for tenant-paid bills
Modified bill forwarding logic to only consider bills marked as "billed to tenant"
when determining if all bills are ready for forwarding. Bills billed to landlord
should not affect the forwarding trigger.

Changes:
- Filter out landlord bills before checking if all bills are paid/attached
- Improved status check to explicitly look for "pending" or "sent" status
- Added edge case handling when no tenant bills exist

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 09:44:55 +01:00

746 lines
25 KiB
TypeScript

'use server';
import { z } from 'zod';
import { getDbClient } from '../dbClient';
import { Bill, BilledTo, FileAttachment, BillingLocation } from '@evidencija-rezija/shared-code';
import { ObjectId } from 'mongodb';
import { withUser } from '@/app/lib/auth';
import { AuthenticatedUser } from '../types/next-auth';
import { gotoHomeWithMessage } from './navigationActions';
import { getTranslations, getLocale } from "next-intl/server";
import { IntlTemplateFn } from '@/app/i18n';
import { unstable_noStore, revalidatePath } from 'next/cache';
import { extractShareId, validateShareChecksum } from '@evidencija-rezija/shared-code';
import { validatePdfFile } from '../validators/pdfValidator';
import { checkUploadRateLimit } from '../uploadRateLimiter';
export type State = {
errors?: {
billName?: string[];
billAttachment?: string[],
billNotes?: string[],
payedAmount?: string[],
};
message?: string | null;
}
/**
* Schema for validating bill form fields
* @description this is defined as factory function so that it can be used with the next-intl library
*/
const FormSchema = (t: IntlTemplateFn) => z.object({
_id: z.string(),
billName: z.coerce.string().min(1, t("bill-name-required")),
billNotes: z.string(),
addToSubsequentMonths: z.boolean().optional(),
payedAmount: z.string().nullable().transform((val, ctx) => {
if (!val || val === '') {
return null;
}
const parsed = parseFloat(val.replace(',', '.'));
if (isNaN(parsed)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("not-a-number"),
});
// This is a special symbol you can use to
// return early from the transform function.
// It has type `never` so it does not affect the
// inferred return type.
return z.NEVER;
}
if (parsed < 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("negative-number")
});
// This is a special symbol you can use to
// return early from the transform function.
// It has type `never` so it does not affect the
// inferred return type.
return z.NEVER;
}
return Math.floor(parsed * 100); // value is stored in cents
}),
});
/**
* Checks if billFwdStatus should be updated to "pending" based on attachment conditions
* @param location - The billing location containing the bill
* @param currentBillId - The ID of the bill being updated (to exclude from check)
* @param hasNewAttachment - Whether a new attachment is being added
* @returns true if billFwdStatus should be set to "pending"
*/
const shouldUpdateBillFwdStatusWhenAttached = (
location: BillingLocation,
currentBillId: string | undefined,
hasNewAttachment: boolean
): boolean => {
// Only proceed if a new attachment is being added
if (!hasNewAttachment) {
return false;
}
// Check billFwdEnabled is true
if (location.billFwdEnabled !== true) {
return false;
}
// Check billFwdStrategy is "when-attached"
if (location.billFwdStrategy !== "when-attached") {
return false;
}
// Check bills have already been sent or are pending -> don't sent them again
if (location.billFwdStatus === "pending" || location.billFwdStatus === "sent") {
return false;
}
// Check if ALL other bills have attachments
const otherBills = location.bills.filter(bill => bill._id !== currentBillId);
// filter only bills billed to tenant
// because bills billed to landlord should not trigger forwarding
const billsPayedByTenant = otherBills.filter(bill => bill.billedTo === BilledTo.Tenant);
if(billsPayedByTenant.length === 0) {
// No other bills billed to tenant exist, so do not trigger forwarding
return false;
}
const allOtherBillsHaveAttachments = billsPayedByTenant.every(bill => bill.attachment !== null);
return allOtherBillsHaveAttachments;
};
/**
* Checks if billFwdStatus should be updated to "pending" based on paid status
* @param location - The billing location containing the bill
* @param currentBillId - The ID of the bill being updated (to exclude from check)
* @param isPaid - Whether the current bill is being marked as paid
* @returns true if billFwdStatus should be set to "pending"
*/
const shouldUpdateBillFwdStatusWhenPayed = (
location: BillingLocation,
currentBillId: string | undefined,
isPaid: boolean
): boolean => {
// Only proceed if the bill is being marked as paid
if (!isPaid) {
return false;
}
// Check billFwdEnabled is true
if (location.billFwdEnabled !== true) {
return false;
}
// Check billFwdStrategy is "when-payed"
if (location.billFwdStrategy !== "when-payed") {
return false;
}
// Check bills have already been sent or are pending -> don't sent them again
if (location.billFwdStatus === "pending" || location.billFwdStatus === "sent") {
return false;
}
// Check if ALL other bills are paid
const otherBills = location.bills.filter(bill => bill._id !== currentBillId);
// filter only bills billed to tenant
// because bills billed to landlord should not trigger forwarding
const billsPayedByTenant = otherBills.filter(bill => bill.billedTo === BilledTo.Tenant);
if(billsPayedByTenant.length === 0) {
// No other bills billed to tenant exist, so do not trigger forwarding
return false;
}
const allOtherBillsPaid = billsPayedByTenant.every(bill => bill.paid === true);
return allOtherBillsPaid;
};
/**
* converts the file to a format stored in the database
* @param billAttachment
* @returns
*/
const serializeAttachment = async (billAttachment: File | null): Promise<FileAttachment | null> => {
if (!billAttachment) {
return null;
}
const {
name: fileName,
size: fileSize,
type: fileType,
lastModified: fileLastModified,
} = billAttachment;
if (!fileName || fileName === 'undefined' || fileSize === 0) {
return null;
}
// convert the billAttachment file contents to format that can be stored in the database
const fileContents = await billAttachment.arrayBuffer();
const fileContentsBase64 = Buffer.from(fileContents).toString('base64');
// create an object to store the file in the database
return ({
fileName,
fileSize,
fileType,
fileLastModified,
fileContentsBase64,
uploadedAt: new Date()
});
}
/**
* Server-side action which adds or updates a bill
* @param locationId location of the bill
* @param billId ID of the bill
* @param prevState previous state of the form
* @param formData form data
* @returns
*/
export const updateOrAddBill = withUser(async (user: AuthenticatedUser, locationId: string, billId: string | undefined, billYear: number | undefined, billMonth: number | undefined, prevState: State, formData: FormData) => {
unstable_noStore();
const { id: userId } = user;
const t = await getTranslations("bill-edit-form.validation");
// FormSchema
const validatedFields = FormSchema(t)
.omit({ _id: true })
.safeParse({
billName: formData.get('billName'),
billNotes: formData.get('billNotes'),
addToSubsequentMonths: formData.get('addToSubsequentMonths') === 'on',
payedAmount: formData.get('payedAmount'),
});
// If form validation fails, return errors early. Otherwise, continue...
if (!validatedFields.success) {
console.log("updateBill.validation-error");
return ({
errors: validatedFields.error.flatten().fieldErrors,
message: t("form-error-message"),
});
}
const {
billName,
billNotes,
addToSubsequentMonths,
payedAmount,
} = validatedFields.data;
const billPaid = formData.get('billPaid') === 'on';
const billedTo = (formData.get('billedTo') as BilledTo) ?? BilledTo.Tenant;
const hub3aTextEncoded = formData.get('hub3aText')?.valueOf() as string;
const hub3aText = hub3aTextEncoded ? decodeURIComponent(hub3aTextEncoded) : undefined;
// update the bill in the mongodb
const dbClient = await getDbClient();
// First validate that the file is acceptable
const attachmentFile = formData.get('billAttachment') as File;
// validate max file size from env variable
const maxFileSizeKB = parseInt(process.env.MAX_PROOF_OF_PAYMENT_UPLOAD_SIZE_KB || '1024', 10);
const maxFileSizeBytes = maxFileSizeKB * 1024;
if (attachmentFile && attachmentFile.size > maxFileSizeBytes) {
return { success: false, error: `File size exceeds the maximum limit of ${maxFileSizeKB} KB` };
}
// Validate file type
if (attachmentFile && attachmentFile.size > 0 && attachmentFile.type !== 'application/pdf') {
return { success: false, error: 'Only PDF files are accepted' };
}
const billAttachment = await serializeAttachment(attachmentFile);
// Fetch the location to check billFwdStatus conditions
const location = await dbClient.collection<BillingLocation>("lokacije").findOne({
_id: locationId,
userId
});
if (!location) {
return { success: false, error: 'Location not found' };
}
// Check if we should update billFwdStatus to "pending"
const shouldSetFwdPendingWhenAttached = shouldUpdateBillFwdStatusWhenAttached(location, billId, billAttachment !== null);
const shouldSetFwdPendingWhenPayed = shouldUpdateBillFwdStatusWhenPayed(location, billId, billPaid);
const shouldSetFwdPending = shouldSetFwdPendingWhenAttached || shouldSetFwdPendingWhenPayed;
if (billId) {
// if there is an attachment, update the attachment field
// otherwise, do not update the attachment field
const mongoDbSet = billAttachment ? {
"bills.$[elem].name": billName,
"bills.$[elem].paid": billPaid,
"bills.$[elem].billedTo": billedTo,
"bills.$[elem].attachment": billAttachment,
"bills.$[elem].notes": billNotes,
"bills.$[elem].payedAmount": payedAmount,
"bills.$[elem].hub3aText": hub3aText,
} : {
"bills.$[elem].name": billName,
"bills.$[elem].paid": billPaid,
"bills.$[elem].billedTo": billedTo,
"bills.$[elem].notes": billNotes,
"bills.$[elem].payedAmount": payedAmount,
"bills.$[elem].hub3aText": hub3aText,
};
// Add billFwdStatus if needed
if (shouldSetFwdPending) {
(mongoDbSet as any).billFwdStatus = "pending";
}
// update bill in given location with the given locationID
await dbClient.collection<BillingLocation>("lokacije").updateOne(
{
_id: locationId, // find a location with the given locationID
userId // make sure that the location belongs to the user
},
{
$set: mongoDbSet
}, {
arrayFilters: [
{ "elem._id": { $eq: billId } } // find a bill with the given billID
]
});
} else {
// Create new bill - add to current location first
const newBill = {
_id: (new ObjectId()).toHexString(),
name: billName,
paid: billPaid,
billedTo: billedTo,
attachment: billAttachment,
notes: billNotes,
payedAmount,
hub3aText,
};
// Build update operation
const updateOp: any = {
$push: {
bills: newBill
}
};
// Add billFwdStatus update if needed
if (shouldSetFwdPending) {
updateOp.$set = {
billFwdStatus: "pending"
};
}
// Add to current location
await dbClient.collection<BillingLocation>("lokacije").updateOne(
{
_id: locationId, // find a location with the given locationID
userId // make sure that the location belongs to the user
},
updateOp);
// If addToSubsequentMonths is enabled, add to subsequent months
if (addToSubsequentMonths && billYear && billMonth) {
// Get the current location to find its name
const currentLocation = await dbClient.collection<BillingLocation>("lokacije")
.findOne({ _id: locationId, userId }, { projection: { name: 1 } });
if (currentLocation) {
// Find all subsequent months that have the same location name
const subsequentLocations = await dbClient.collection<BillingLocation>("lokacije")
.find({
userId,
name: currentLocation.name,
$or: [
{ "yearMonth.year": { $gt: billYear } },
{
"yearMonth.year": billYear,
"yearMonth.month": { $gt: billMonth }
}
]
}, { projection: { _id: 1 } })
.toArray();
// For each subsequent location, check if bill with same name already exists
const updateOperations = [];
for (const location of subsequentLocations) {
const existingBill = await dbClient.collection<BillingLocation>("lokacije")
.findOne({
_id: location._id,
"bills.name": billName
}, {
// We only need to know if a matching bill exists; avoid conflicting projections
projection: { _id: 1 }
});
// Only add if bill with same name doesn't already exist
if (!existingBill) {
updateOperations.push({
updateOne: {
filter: { _id: location._id, userId },
update: {
$push: {
bills: {
_id: (new ObjectId()).toHexString(),
name: billName,
paid: false, // New bills in subsequent months are unpaid
billedTo: BilledTo.Tenant, // Default to tenant for subsequent months
attachment: null, // No attachment for subsequent months
notes: billNotes,
payedAmount: null,
hub3aText: undefined,
}
}
}
}
});
}
}
// Execute all update operations at once if any
if (updateOperations.length > 0) {
await dbClient.collection<BillingLocation>("lokacije").bulkWrite(updateOperations);
}
}
}
}
if (billYear && billMonth) {
const locale = await getLocale();
await gotoHomeWithMessage(locale, 'billSaved', { year: billYear, month: billMonth });
}
// This return is needed for TypeScript, but won't be reached due to redirect
return {
message: null,
errors: undefined,
};
})
/*
Funkcija zamijenjena sa `fetchBillByUserAndId`, koja brže radi i ne treba korisnika
export const fetchBillByUserAndId = withUser(async (user:AuthenticatedUser, locationID:string, billID:string, includeAttachmentBinary:boolean = false) => {
const { id: userId } = user;
const dbClient = await getDbClient();
// don't include the attachment binary data in the response
// if the attachment binary data is not needed
const projection = includeAttachmentBinary ? {} : {
"bills.attachment.fileContentsBase64": 0,
};
// find a location with the given locationID
const billLocation = await dbClient.collection<BillingLocation>("lokacije").findOne(
{
_id: locationID,
userId
},
{
projection
})
if(!billLocation) {
console.log(`Location ${locationID} not found`);
return(null);
}
// find a bill with the given billID
const bill = billLocation?.bills.find(({ _id }) => _id.toString() === billID);
if(!bill) {
console.log('Bill not found');
return(null);
}
return([billLocation, bill] as [BillingLocation, Bill]);
})
*/
export const fetchBillById = async (locationID: string, billID: string, includeAttachmentBinary: boolean = false) => {
unstable_noStore();
const dbClient = await getDbClient();
// don't include the attachment binary data in the response
// if the attachment binary data is not needed
const projection = includeAttachmentBinary ? {} : {
"bills.attachment.fileContentsBase64": 0,
};
// find a location with the given locationID
const billLocation = await dbClient.collection<BillingLocation>("lokacije").findOne(
{
_id: locationID,
},
{
projection
})
if (!billLocation) {
console.log(`Location ${locationID} not found`);
return (null);
}
// find a bill with the given billID
const bill = billLocation?.bills.find(({ _id }) => _id.toString() === billID);
if (!bill) {
console.log('Bill not found');
return (null);
}
return ([billLocation, bill] as [BillingLocation, Bill]);
};
export const deleteBillById = withUser(async (user: AuthenticatedUser, locationID: string, billID: string, year: number, month: number, _prevState: any, formData?: FormData) => {
unstable_noStore();
const { id: userId } = user;
const dbClient = await getDbClient();
const deleteInSubsequentMonths = formData?.get('deleteInSubsequentMonths') === 'on';
if (deleteInSubsequentMonths) {
// Get the current location and bill to find the bill name and location name
const location = await dbClient.collection<BillingLocation>("lokacije")
.findOne({ _id: locationID, userId }, {
projection: {
"name": 1,
"bills._id": 1,
"bills.name": 1
}
});
if (location) {
const bill = location.bills.find(b => b._id === billID);
if (bill) {
// Find all subsequent locations with the same name that have the same bill
const subsequentLocations = await dbClient.collection<BillingLocation>("lokacije")
.find({
userId,
name: location.name,
$or: [
{ "yearMonth.year": { $gt: year } },
{
"yearMonth.year": year,
"yearMonth.month": { $gt: month }
}
],
"bills.name": bill.name
}, { projection: { _id: 1 } })
.toArray();
// Delete the bill from all subsequent locations (by name)
const updateOperations = subsequentLocations.map(loc => ({
updateOne: {
filter: { _id: loc._id, userId },
update: {
$pull: {
bills: { name: bill.name } as Partial<Bill>
}
}
}
}));
// Also delete from current location (by ID for precision)
updateOperations.push({
updateOne: {
filter: { _id: locationID, userId },
update: {
$pull: {
bills: { _id: billID }
}
}
}
});
// Execute all delete operations
if (updateOperations.length > 0) {
await dbClient.collection<BillingLocation>("lokacije").bulkWrite(updateOperations);
}
}
}
} else {
// Delete only from current location (original behavior)
await dbClient.collection<BillingLocation>("lokacije").updateOne(
{
_id: locationID, // find a location with the given locationID
userId // make sure that the location belongs to the user
},
{
// remove the bill with the given billID
$pull: {
bills: {
_id: billID
}
}
});
}
const locale = await getLocale();
await gotoHomeWithMessage(locale, 'billDeleted');
// This return is needed for TypeScript, but won't be reached due to redirect
return {
message: null,
errors: undefined,
};
});
/**
* Uploads proof of payment for the given bill
* SECURITY: Validates checksum, TTL, PDF content, and rate limits by IP
*
* @param shareId - Combined location ID + checksum (40 chars)
* @param billID - The bill ID to attach proof of payment to
* @param formData - Form data containing the PDF file
* @param ipAddress - Optional IP address for rate limiting
*/
export const uploadProofOfPayment = async (
shareId: string,
billID: 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) {
return { success: false, error: 'Invalid share link' };
}
const { locationId: locationID, checksum } = extracted;
if (!validateShareChecksum(locationID, checksum)) {
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, bills: 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' };
}
// Verify bill exists in location
const bill = location.bills.find(b => b._id === billID);
if (!bill) {
return { success: false, error: 'Invalid request' };
}
// Check if proof of payment already uploaded
if (bill.proofOfPayment?.uploadedAt) {
return { success: false, error: 'Proof of payment already uploaded for this bill' };
}
// 4. FILE VALIDATION
const file = formData.get('proofOfPayment') 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: {
"bills.$[elem].proofOfPayment": attachment
}
},
{
arrayFilters: [{ "elem._id": { $eq: billID } }]
}
);
// 7. CLEANUP EXPIRED SHARES (integrated, no cron needed)
await cleanupExpiredShares(dbClient);
// 8. REVALIDATE CACHE
revalidatePath(`/share/location/${shareId}`, 'page');
return { success: true };
} catch (error: any) {
console.error('Upload error:', error);
return { success: false, error: 'Upload failed. Please try again.' };
}
};
/**
* Clean up expired shares during upload processing
* Removes shareTTL and shareFirstVisitedAt from expired locations
*/
async function cleanupExpiredShares(dbClient: any) {
const now = new Date();
await dbClient.collection("lokacije").updateMany(
{ shareTTL: { $lt: now } },
{ $unset: { shareTTL: "", shareFirstVisitedAt: "" } }
);
}