// ALVA-PROC-0002 · Månatlig fakturering. // // Kör som eget jobb, inte i webbtjänstens process — av samma skäl som // gallringen: en körning som avbryts mitt i ska kunna köras om, och den // ska synas som en egen körning i driften. // // Jobbet är idempotent på perioden. Kör det två gånger samma dygn och den // andra körningen fakturerar ingenting, eftersom `senast_fakturerad` // redan flyttats. Det är viktigare än det låter: ett fakturajobb som // dubbelfakturerar vid en omstart är värre än ett som inte kör alls. // // node manadsfakturering.mjs verkställer // node manadsfakturering.mjs --torrkor räknar bara import pg from "pg"; import { fakturera } from "./fakturering.mjs"; import { forfallenFakturering, nivaFor } from "./abonnemang.mjs"; /** * Fakturerar alla organisationer vars period löpt ut. * * Nivån hämtas ur abonnemangshändelserna — inte ur en kolumn — så att en * faktura alltid speglar den nivå som gällde när den utfärdades. */ export async function fakturerManaden(pool, { nu = new Date(), torrkor = false } = {}) { const organisationer = await pool.query( `select a.organisation_id, a.registrerad, a.senast_fakturerad, o.namn, o.installningar, (select varde from abonnemangshandelser where organisation_id = a.organisation_id and typ = 'niva' order by skapad desc limit 1) as niva from abonnemang a join organisationer o on o.id = a.organisation_id`, ); const utfardade = []; for (const rad of organisationer.rows) { const period = forfallenFakturering(rad.registrerad, rad.senast_fakturerad, nu); if (!period) continue; const niva = nivaFor(rad.niva ?? "free"); // Enterprise prissätts per avtal och faktureras inte av jobbet — ett // påhittat belopp vore värre än ingen faktura. if (niva.basavgift === null) continue; const aktiva = await pool.query( `select count(*)::int as antal from anvandare where organisation_id = $1 and aktiv`, [rad.organisation_id], ); // Prislistan byggs ur NIVÅN, inte ur den globala listan: det är // abonnemanget kunden valt som avgör vad en användare kostar. const prislista = { valuta: "SEK", plattform_ar: niva.basavgift * 12, anvandare_manad: niva.anvandaravgift, moduler: {}, momssats: 0.25, betalningsvillkor: 30, }; if (torrkor) { utfardade.push({ organisation: rad.namn, period, niva: niva.id, torrkor: true }); continue; } const klient = await pool.connect(); try { await klient.query("begin"); await klient.query("lock table fakturor in exclusive mode"); const nasta = await klient.query(`select coalesce(max(nummer), 0) + 1 as n from fakturor`); const nummer = Number(nasta.rows[0].n); const dokument = fakturera({ nummer, org: { namn: rad.namn, moduler: [] }, aktiva: aktiva.rows[0].antal, period, utfardad: nu.toISOString().slice(0, 10), prislista, }); await klient.query( `insert into fakturor (organisation_id, nummer, beteckning, utfardad, forfaller, valuta, totalt, dokument) values ($1, $2, $3, $4, $5, $6, $7, $8)`, [ rad.organisation_id, nummer, dokument.beteckning, dokument.utfardad, dokument.forfaller, dokument.valuta, dokument.totalt, JSON.stringify(dokument), ], ); await klient.query(`update abonnemang set senast_fakturerad = $2 where organisation_id = $1`, [ rad.organisation_id, period.fran, ]); await klient.query("commit"); utfardade.push({ organisation: rad.namn, beteckning: dokument.beteckning, totalt: dokument.totalt, niva: niva.id }); } catch (fel) { await klient.query("rollback").catch(() => {}); throw fel; } finally { klient.release(); } } return { antal: utfardade.length, utfardade, torrkor }; } if (import.meta.url === `file://${process.argv[1]}`) { const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 2 }); try { const utfall = await fakturerManaden(pool, { torrkor: process.argv.includes("--torrkor") }); console.log(JSON.stringify({ nivå: "info", meddelande: "månadsfakturering", tid: new Date().toISOString(), ...utfall })); } finally { await pool.end(); } }