|
@@ -31,6 +31,77 @@ const upload = multer({
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+function toUtcDate(dateStr) {
|
|
|
|
|
+ const [y, m, d] = String(dateStr).split('-').map(Number);
|
|
|
|
|
+ return new Date(Date.UTC(y, m - 1, d));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getYearRange(year) {
|
|
|
|
|
+ const y = parseInt(year, 10);
|
|
|
|
|
+ const yearStartDate = toUtcDate(`${y}-01-01`);
|
|
|
|
|
+ const yearEndDate = toUtcDate(`${y}-12-31`);
|
|
|
|
|
+ const yearDays = Math.round((yearEndDate - yearStartDate) / 86400000) + 1;
|
|
|
|
|
+ return {
|
|
|
|
|
+ year: y,
|
|
|
|
|
+ yearStartIso: `${y}-01-01`,
|
|
|
|
|
+ yearEndIso: `${y}-12-31`,
|
|
|
|
|
+ yearStartDate,
|
|
|
|
|
+ yearEndDate,
|
|
|
|
|
+ yearDays,
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function overlapDaysInclusive(startDate, endDate, rangeStart, rangeEnd) {
|
|
|
|
|
+ const start = startDate > rangeStart ? startDate : rangeStart;
|
|
|
|
|
+ const end = endDate < rangeEnd ? endDate : rangeEnd;
|
|
|
|
|
+ if (end < start) return 0;
|
|
|
|
|
+ return Math.round((end - start) / 86400000) + 1;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function round2(value) {
|
|
|
|
|
+ return Math.round(Number(value || 0) * 100) / 100;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function computeLeaseBilansForPropertyYear({ propertyId, year, userId }) {
|
|
|
|
|
+ const { yearStartIso, yearEndIso, yearStartDate, yearEndDate, yearDays } = getYearRange(year);
|
|
|
|
|
+
|
|
|
|
|
+ const leases = (await pool.query(`
|
|
|
|
|
+ SELECT l.*, t.first_name, t.last_name, t.email
|
|
|
|
|
+ FROM leases l
|
|
|
|
|
+ JOIN tenants t ON l.tenant_id = t.id
|
|
|
|
|
+ WHERE l.property_id = $1 AND l.user_id = $2
|
|
|
|
|
+ AND (l.start_date <= $3 AND (l.end_date IS NULL OR l.end_date >= $4))
|
|
|
|
|
+ ORDER BY l.start_date ASC
|
|
|
|
|
+ `, [propertyId, userId, yearEndIso, yearStartIso])).rows;
|
|
|
|
|
+
|
|
|
|
|
+ const leaseBilans = await Promise.all(leases.map(async lease => {
|
|
|
|
|
+ const provisions = (await pool.query(`
|
|
|
|
|
+ SELECT SUM(charges_paid) as total
|
|
|
|
|
+ FROM payments
|
|
|
|
|
+ WHERE lease_id = $1 AND period_year = $2
|
|
|
|
|
+ `, [lease.id, parseInt(year, 10)])).rows[0];
|
|
|
|
|
+
|
|
|
|
|
+ const leaseStartDate = toUtcDate(lease.start_date);
|
|
|
|
|
+ const leaseEndDate = lease.end_date ? toUtcDate(lease.end_date) : yearEndDate;
|
|
|
|
|
+ const occupiedDays = overlapDaysInclusive(leaseStartDate, leaseEndDate, yearStartDate, yearEndDate);
|
|
|
|
|
+ const occupancyRatio = yearDays > 0 ? occupiedDays / yearDays : 0;
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ lease_id: lease.id,
|
|
|
|
|
+ tenant_name: `${lease.first_name} ${lease.last_name}`,
|
|
|
|
|
+ tenant_email: lease.email,
|
|
|
|
|
+ monthly_provision: lease.charges_amount,
|
|
|
|
|
+ provisions_percues: parseFloat(provisions?.total || 0),
|
|
|
|
|
+ occupied_days: occupiedDays,
|
|
|
|
|
+ year_days: yearDays,
|
|
|
|
|
+ occupancy_ratio: occupancyRatio,
|
|
|
|
|
+ occupancy_ratio_percent: round2(occupancyRatio * 100),
|
|
|
|
|
+ };
|
|
|
|
|
+ }));
|
|
|
|
|
+
|
|
|
|
|
+ return { leaseBilans, yearDays };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// List charges (filter by property_id, year)
|
|
// List charges (filter by property_id, year)
|
|
|
router.get('/', async (req, res) => {
|
|
router.get('/', async (req, res) => {
|
|
|
const { property_id, year } = req.query;
|
|
const { property_id, year } = req.query;
|
|
@@ -168,58 +239,36 @@ router.get('/bilan/:property_id/:year', async (req, res) => {
|
|
|
GROUP BY category ORDER BY total DESC
|
|
GROUP BY category ORDER BY total DESC
|
|
|
`, [property_id, year, req.userId])).rows;
|
|
`, [property_id, year, req.userId])).rows;
|
|
|
|
|
|
|
|
- // All leases on this property (active or that were active during the year)
|
|
|
|
|
- const leases = (await pool.query(`
|
|
|
|
|
- SELECT l.*, t.first_name, t.last_name, t.email
|
|
|
|
|
- FROM leases l
|
|
|
|
|
- JOIN tenants t ON l.tenant_id = t.id
|
|
|
|
|
- WHERE l.property_id = $1 AND l.user_id = $2
|
|
|
|
|
- AND (l.start_date <= $3 AND (l.end_date IS NULL OR l.end_date >= $4))
|
|
|
|
|
- `, [property_id, req.userId, `${year}-12-31`, `${year}-01-01`])).rows;
|
|
|
|
|
-
|
|
|
|
|
- // For each lease, get provisions perçues for the year
|
|
|
|
|
- const leaseBilans = await Promise.all(leases.map(async lease => {
|
|
|
|
|
- const provisions = (await pool.query(`
|
|
|
|
|
- SELECT SUM(charges_paid) as total
|
|
|
|
|
- FROM payments
|
|
|
|
|
- WHERE lease_id = $1 AND period_year = $2
|
|
|
|
|
- `, [lease.id, year])).rows[0];
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- lease_id: lease.id,
|
|
|
|
|
- tenant_name: `${lease.first_name} ${lease.last_name}`,
|
|
|
|
|
- tenant_email: lease.email,
|
|
|
|
|
- monthly_provision: lease.charges_amount,
|
|
|
|
|
- provisions_percues: provisions?.total || 0
|
|
|
|
|
- };
|
|
|
|
|
- }));
|
|
|
|
|
|
|
+ const { leaseBilans } = await computeLeaseBilansForPropertyYear({
|
|
|
|
|
+ propertyId: parseInt(property_id, 10),
|
|
|
|
|
+ year,
|
|
|
|
|
+ userId: req.userId,
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
- // Calculate total provisions across all tenants for proportional split
|
|
|
|
|
const totalProvisions = leaseBilans.reduce((s, l) => s + l.provisions_percues, 0);
|
|
const totalProvisions = leaseBilans.reduce((s, l) => s + l.provisions_percues, 0);
|
|
|
|
|
|
|
|
- // Compute each tenant's share
|
|
|
|
|
|
|
+ // Compute each tenant's share based on actual occupancy in the year.
|
|
|
const bilans = leaseBilans.map(l => {
|
|
const bilans = leaseBilans.map(l => {
|
|
|
- const ratio = totalProvisions > 0 ? l.provisions_percues / totalProvisions : (leaseBilans.length > 0 ? 1 / leaseBilans.length : 0);
|
|
|
|
|
- const quote_part_charges = totalChargesReelles * ratio;
|
|
|
|
|
|
|
+ const quote_part_charges = totalChargesReelles * l.occupancy_ratio;
|
|
|
const trop_percu = l.provisions_percues - quote_part_charges;
|
|
const trop_percu = l.provisions_percues - quote_part_charges;
|
|
|
return {
|
|
return {
|
|
|
...l,
|
|
...l,
|
|
|
- ratio_percent: Math.round(ratio * 100 * 100) / 100,
|
|
|
|
|
- quote_part_charges: Math.round(quote_part_charges * 100) / 100,
|
|
|
|
|
- trop_percu: Math.round(trop_percu * 100) / 100
|
|
|
|
|
|
|
+ ratio_percent: l.occupancy_ratio_percent,
|
|
|
|
|
+ quote_part_charges: round2(quote_part_charges),
|
|
|
|
|
+ trop_percu: round2(trop_percu)
|
|
|
};
|
|
};
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
res.json({
|
|
res.json({
|
|
|
property: prop,
|
|
property: prop,
|
|
|
year: parseInt(year),
|
|
year: parseInt(year),
|
|
|
- total_charges_reelles: Math.round(totalChargesReelles * 100) / 100,
|
|
|
|
|
- total_deductible: Math.round(totalDeductible * 100) / 100,
|
|
|
|
|
- total_non_recoverable: Math.round(totalNonRecoverable * 100) / 100,
|
|
|
|
|
|
|
+ total_charges_reelles: round2(totalChargesReelles),
|
|
|
|
|
+ total_deductible: round2(totalDeductible),
|
|
|
|
|
+ total_non_recoverable: round2(totalNonRecoverable),
|
|
|
charges_count: totalChargesRow?.count || 0,
|
|
charges_count: totalChargesRow?.count || 0,
|
|
|
charges_by_category: chargesByCategory,
|
|
charges_by_category: chargesByCategory,
|
|
|
charges_non_recov_by_category: chargesNonRecovByCategory,
|
|
charges_non_recov_by_category: chargesNonRecovByCategory,
|
|
|
- total_provisions_percues: Math.round(totalProvisions * 100) / 100,
|
|
|
|
|
|
|
+ total_provisions_percues: round2(totalProvisions),
|
|
|
bilans
|
|
bilans
|
|
|
});
|
|
});
|
|
|
});
|
|
});
|
|
@@ -277,28 +326,22 @@ router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
|
|
|
GROUP BY category ORDER BY total DESC
|
|
GROUP BY category ORDER BY total DESC
|
|
|
`, [property_id, year, req.userId])).rows;
|
|
`, [property_id, year, req.userId])).rows;
|
|
|
|
|
|
|
|
- const leases = (await pool.query(`
|
|
|
|
|
- SELECT l.*, t.first_name, t.last_name, t.email FROM leases l
|
|
|
|
|
- JOIN tenants t ON l.tenant_id = t.id
|
|
|
|
|
- WHERE l.property_id = $1 AND l.user_id = $2
|
|
|
|
|
- AND (l.start_date <= $3 AND (l.end_date IS NULL OR l.end_date >= $4))
|
|
|
|
|
- `, [property_id, req.userId, `${year}-12-31`, `${year}-01-01`])).rows;
|
|
|
|
|
|
|
+ const { leaseBilans } = await computeLeaseBilansForPropertyYear({
|
|
|
|
|
+ propertyId: parseInt(property_id, 10),
|
|
|
|
|
+ year,
|
|
|
|
|
+ userId: req.userId,
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
- const leaseBilans = await Promise.all(leases.map(async lease => {
|
|
|
|
|
- const provisions = (await pool.query(`SELECT SUM(charges_paid) as total FROM payments WHERE lease_id = $1 AND period_year = $2`, [lease.id, year])).rows[0];
|
|
|
|
|
- return {
|
|
|
|
|
- lease_id: lease.id,
|
|
|
|
|
- tenant_name: `${lease.first_name} ${lease.last_name}`,
|
|
|
|
|
- tenant_email: lease.email,
|
|
|
|
|
- provisions_percues: provisions?.total || 0
|
|
|
|
|
- };
|
|
|
|
|
- }));
|
|
|
|
|
const totalProvisions = leaseBilans.reduce((s, l) => s + l.provisions_percues, 0);
|
|
const totalProvisions = leaseBilans.reduce((s, l) => s + l.provisions_percues, 0);
|
|
|
const bilans = leaseBilans.map(l => {
|
|
const bilans = leaseBilans.map(l => {
|
|
|
- const ratio = totalProvisions > 0 ? l.provisions_percues / totalProvisions : (leaseBilans.length > 0 ? 1 / leaseBilans.length : 0);
|
|
|
|
|
- const quote_part = totalChargesReelles * ratio;
|
|
|
|
|
|
|
+ const quote_part = totalChargesReelles * l.occupancy_ratio;
|
|
|
const trop_percu = l.provisions_percues - quote_part;
|
|
const trop_percu = l.provisions_percues - quote_part;
|
|
|
- return { ...l, ratio_percent: Math.round(ratio * 100 * 100) / 100, quote_part_charges: Math.round(quote_part * 100) / 100, trop_percu: Math.round(trop_percu * 100) / 100 };
|
|
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...l,
|
|
|
|
|
+ ratio_percent: l.occupancy_ratio_percent,
|
|
|
|
|
+ quote_part_charges: round2(quote_part),
|
|
|
|
|
+ trop_percu: round2(trop_percu)
|
|
|
|
|
+ };
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
const catLabels = { eau: 'Eau', gaz: 'Gaz', electricite: 'Électricité', entretien: 'Entretien / Réparations', ascenseur: 'Ascenseur', ordures: 'Ordures ménagères', assurance: 'Assurance', autre: 'Autre' };
|
|
const catLabels = { eau: 'Eau', gaz: 'Gaz', electricite: 'Électricité', entretien: 'Entretien / Réparations', ascenseur: 'Ascenseur', ordures: 'Ordures ménagères', assurance: 'Assurance', autre: 'Autre' };
|
|
@@ -484,62 +527,83 @@ router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
-// Close annual exercise — create charge_regularization for each tenant
|
|
|
|
|
|
|
+// Close annual exercise for one lease (or all not-yet-closed leases if lease_id is omitted)
|
|
|
router.post('/bilan/:property_id/:year/close', async (req, res) => {
|
|
router.post('/bilan/:property_id/:year/close', async (req, res) => {
|
|
|
const { property_id, year } = req.params;
|
|
const { property_id, year } = req.params;
|
|
|
|
|
+ const leaseIdRaw = req.body?.lease_id || req.query?.lease_id;
|
|
|
|
|
+ const leaseId = leaseIdRaw ? parseInt(leaseIdRaw, 10) : null;
|
|
|
|
|
|
|
|
const prop = (await pool.query('SELECT * FROM properties WHERE id = $1 AND user_id = $2', [property_id, req.userId])).rows[0];
|
|
const prop = (await pool.query('SELECT * FROM properties WHERE id = $1 AND user_id = $2', [property_id, req.userId])).rows[0];
|
|
|
if (!prop) return res.status(403).json({ error: 'Bien non autorisé' });
|
|
if (!prop) return res.status(403).json({ error: 'Bien non autorisé' });
|
|
|
|
|
|
|
|
- // Check not already closed for this property/year
|
|
|
|
|
- const alreadyClosed = (await pool.query(
|
|
|
|
|
- 'SELECT id FROM charge_regularizations WHERE user_id = $1 AND property_id = $2 AND year = $3',
|
|
|
|
|
- [req.userId, property_id, parseInt(year)]
|
|
|
|
|
- )).rows[0];
|
|
|
|
|
- if (alreadyClosed) return res.status(409).json({ error: `L'exercice ${year} a déjà été clôturé pour ce bien.` });
|
|
|
|
|
-
|
|
|
|
|
- // Recompute bilan (recoverable only)
|
|
|
|
|
|
|
+ // Recompute recoverable charges for the property/year.
|
|
|
const totalRow = (await pool.query(
|
|
const totalRow = (await pool.query(
|
|
|
`SELECT SUM(amount) as total FROM charges WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable_type = 'recoverable'`,
|
|
`SELECT SUM(amount) as total FROM charges WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable_type = 'recoverable'`,
|
|
|
[property_id, year, req.userId]
|
|
[property_id, year, req.userId]
|
|
|
)).rows[0];
|
|
)).rows[0];
|
|
|
- const totalCharges = totalRow?.total || 0;
|
|
|
|
|
|
|
+ const totalCharges = parseFloat(totalRow?.total || 0);
|
|
|
|
|
|
|
|
- const leases = (await pool.query(`
|
|
|
|
|
- SELECT l.*, t.first_name, t.last_name FROM leases l
|
|
|
|
|
- JOIN tenants t ON l.tenant_id = t.id
|
|
|
|
|
- WHERE l.property_id = $1 AND l.user_id = $2
|
|
|
|
|
- AND (l.start_date <= $3 AND (l.end_date IS NULL OR l.end_date >= $4))
|
|
|
|
|
- `, [property_id, req.userId, `${year}-12-31`, `${year}-01-01`])).rows;
|
|
|
|
|
|
|
+ const { leaseBilans } = await computeLeaseBilansForPropertyYear({
|
|
|
|
|
+ propertyId: parseInt(property_id, 10),
|
|
|
|
|
+ year,
|
|
|
|
|
+ userId: req.userId,
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
- if (leases.length === 0) return res.status(400).json({ error: 'Aucun locataire sur cette période.' });
|
|
|
|
|
|
|
+ if (leaseBilans.length === 0) return res.status(400).json({ error: 'Aucun bail sur cette période.' });
|
|
|
|
|
|
|
|
- const leaseBilans = await Promise.all(leases.map(async lease => {
|
|
|
|
|
- const prov = (await pool.query('SELECT SUM(charges_paid) as total FROM payments WHERE lease_id = $1 AND period_year = $2', [lease.id, year])).rows[0];
|
|
|
|
|
- return { lease_id: lease.id, tenant: `${lease.first_name} ${lease.last_name}`, provisions: prov?.total || 0 };
|
|
|
|
|
- }));
|
|
|
|
|
- const totalProvisions = leaseBilans.reduce((s, l) => s + l.provisions, 0);
|
|
|
|
|
|
|
+ let targets = leaseBilans;
|
|
|
|
|
+ if (leaseId) {
|
|
|
|
|
+ const selected = leaseBilans.find(l => l.lease_id === leaseId);
|
|
|
|
|
+ if (!selected) return res.status(404).json({ error: 'Bail non trouvé pour ce bien/période.' });
|
|
|
|
|
+
|
|
|
|
|
+ const alreadyClosedForLease = (await pool.query(
|
|
|
|
|
+ 'SELECT id FROM charge_regularizations WHERE user_id = $1 AND lease_id = $2 AND year = $3 LIMIT 1',
|
|
|
|
|
+ [req.userId, leaseId, parseInt(year, 10)]
|
|
|
|
|
+ )).rows[0];
|
|
|
|
|
+ if (alreadyClosedForLease) {
|
|
|
|
|
+ return res.status(409).json({ error: `Le bail sélectionné est déjà clôturé pour ${year}.` });
|
|
|
|
|
+ }
|
|
|
|
|
+ targets = [selected];
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const closedLeaseIds = (await pool.query(
|
|
|
|
|
+ 'SELECT DISTINCT lease_id FROM charge_regularizations WHERE user_id = $1 AND property_id = $2 AND year = $3',
|
|
|
|
|
+ [req.userId, property_id, parseInt(year, 10)]
|
|
|
|
|
+ )).rows.map(r => r.lease_id);
|
|
|
|
|
+ targets = leaseBilans.filter(l => !closedLeaseIds.includes(l.lease_id));
|
|
|
|
|
+ if (targets.length === 0) {
|
|
|
|
|
+ return res.status(409).json({ error: `Tous les baux de ${year} sont déjà clôturés pour ce bien.` });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
const client = await pool.connect();
|
|
const client = await pool.connect();
|
|
|
try {
|
|
try {
|
|
|
await client.query('BEGIN');
|
|
await client.query('BEGIN');
|
|
|
const results = [];
|
|
const results = [];
|
|
|
- for (const l of leaseBilans) {
|
|
|
|
|
- const ratio = totalProvisions > 0 ? l.provisions / totalProvisions : 1 / leaseBilans.length;
|
|
|
|
|
- const quote_part = totalCharges * ratio;
|
|
|
|
|
|
|
+ for (const l of targets) {
|
|
|
|
|
+ const quote_part = totalCharges * l.occupancy_ratio;
|
|
|
// Positive = trop-perçu (credit tenant), Negative = complément dû (debit tenant)
|
|
// Positive = trop-perçu (credit tenant), Negative = complément dû (debit tenant)
|
|
|
- const trop_percu = Math.round((l.provisions - quote_part) * 100) / 100;
|
|
|
|
|
|
|
+ const trop_percu = round2(l.provisions_percues - quote_part);
|
|
|
|
|
+ const quotePartRounded = round2(quote_part);
|
|
|
const label = trop_percu >= 0
|
|
const label = trop_percu >= 0
|
|
|
- ? `Régularisation charges ${year} : trop-perçu de ${trop_percu.toFixed(2)} € à déduire`
|
|
|
|
|
- : `Régularisation charges ${year} : complément de ${Math.abs(trop_percu).toFixed(2)} € à appeler`;
|
|
|
|
|
|
|
+ ? `Régularisation charges ${year} (bail ${l.lease_id}) : trop-perçu de ${trop_percu.toFixed(2)} € à déduire (prorata occupation ${l.occupied_days}/${l.year_days} jours)`
|
|
|
|
|
+ : `Régularisation charges ${year} (bail ${l.lease_id}) : complément de ${Math.abs(trop_percu).toFixed(2)} € à appeler (prorata occupation ${l.occupied_days}/${l.year_days} jours)`;
|
|
|
await client.query(
|
|
await client.query(
|
|
|
'INSERT INTO charge_regularizations (lease_id, user_id, property_id, year, amount, notes) VALUES ($1, $2, $3, $4, $5, $6)',
|
|
'INSERT INTO charge_regularizations (lease_id, user_id, property_id, year, amount, notes) VALUES ($1, $2, $3, $4, $5, $6)',
|
|
|
- [l.lease_id, req.userId, parseInt(property_id), parseInt(year), trop_percu, label]
|
|
|
|
|
|
|
+ [l.lease_id, req.userId, parseInt(property_id, 10), parseInt(year, 10), trop_percu, label]
|
|
|
);
|
|
);
|
|
|
- results.push({ lease_id: l.lease_id, tenant: l.tenant, trop_percu });
|
|
|
|
|
|
|
+ results.push({
|
|
|
|
|
+ lease_id: l.lease_id,
|
|
|
|
|
+ tenant: l.tenant_name,
|
|
|
|
|
+ occupied_days: l.occupied_days,
|
|
|
|
|
+ year_days: l.year_days,
|
|
|
|
|
+ ratio_percent: l.occupancy_ratio_percent,
|
|
|
|
|
+ quote_part_charges: quotePartRounded,
|
|
|
|
|
+ provisions_percues: round2(l.provisions_percues),
|
|
|
|
|
+ trop_percu,
|
|
|
|
|
+ });
|
|
|
}
|
|
}
|
|
|
await client.query('COMMIT');
|
|
await client.query('COMMIT');
|
|
|
- res.json({ success: true, year: parseInt(year), property: prop.name, regularizations: results });
|
|
|
|
|
|
|
+ res.json({ success: true, year: parseInt(year, 10), property: prop.name, regularizations: results });
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
await client.query('ROLLBACK');
|
|
await client.query('ROLLBACK');
|
|
|
throw e;
|
|
throw e;
|