Buchungskalender: origin/main (Aufträge) zusammenführen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
fd971309a8
145 files changed
+42590
-129
No files matched your search
+61
-13
@@ -5,6 +5,8 @@ import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { getCustomerByEmail, getDocument, updateDocument } from '../services/appwriteAdmin.js'
|
||||
import { eventIdFor, listContractEvents, openWithdrawals, recordContractEvent, updateContractEvent } from '../services/contractEvents.js'
|
||||
import { lateStartAt, withdrawalDeadline } from '../services/deadlines.js'
|
||||
import { findAuftragByNummer } from '../services/auftraege/auftragStore.js'
|
||||
import { widerrufeAuftrag } from '../services/auftraege/bestaetigung.js'
|
||||
import { getLedger } from '../services/hourLedger.js'
|
||||
import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
|
||||
import { changeStorage, previewStorageChange, recordPaidStorage, storageTaskKind } from '../services/mailStorage.js'
|
||||
@@ -727,7 +729,8 @@ export function publicRateLimitState() {
|
||||
|
||||
// Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert
|
||||
const CONTRACTS = ['abo', 'zusatzspeicher']
|
||||
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'unterkunft', 'zeiten']
|
||||
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'unterkunft', 'zeiten', 'projekt']
|
||||
const AB_NUMMER = /^AB-\d{4}-\d{4}$/
|
||||
// Bestellung einer Funktion -> Funktionsschluessel im Register ('funktion' meint die News-Pinnwand; jede Funktion hat ihre eigene Frist)
|
||||
const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine', shop: 'shop', unterkunft: 'unterkunft', zeiten: 'zeiten' }
|
||||
// Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals)
|
||||
@@ -735,7 +738,7 @@ const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspe
|
||||
|
||||
/**
|
||||
* Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl.
|
||||
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'shop' | 'unterkunft' | 'zeiten'; packages (nur beim
|
||||
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'shop' | 'unterkunft' | 'zeiten' | 'projekt'; packages (nur beim
|
||||
* Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026).
|
||||
*/
|
||||
function validDeclaration(body) {
|
||||
@@ -764,6 +767,7 @@ function validDeclaration(body) {
|
||||
contract,
|
||||
order,
|
||||
packages,
|
||||
abNummer: String(body?.abNummer || '').trim().toUpperCase().slice(0, 20),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,7 +808,8 @@ function contractLabel(contract, sub) {
|
||||
}
|
||||
|
||||
/** Beschriftung der widerrufenen Bestellung fuer K04 (an den Kunden) und T09 (intern). */
|
||||
function orderLabel(order, sub, { customer = false } = {}) {
|
||||
function orderLabel(order, sub, { customer = false, abNummer = '' } = {}) {
|
||||
if (order === 'projekt') return AB_NUMMER.test(abNummer) ? `Projektauftrag (Auftragsbestätigung ${abNummer})` : 'Projektauftrag (Auftragsbestätigung)'
|
||||
if (order === 'wechsel') return 'Wechsel ins Wartungs-Abo'
|
||||
if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher'
|
||||
if (order === 'funktion') return 'Funktion (z. B. News-Pinnwand)'
|
||||
@@ -1148,6 +1153,37 @@ async function withdrawFeature(d, receivedAt, featureKey) {
|
||||
return { customer, orders, deadline, lines, inTime: Boolean(deadline) && receivedAt <= deadline }
|
||||
}
|
||||
|
||||
/** Projektauftrag (Aufträge und Rechnungen, Spec 2026-10-06 Abschnitt 2): Nummer und E-Mail-Adresse müssen zur gesendeten AB passen. Ohne gültige Nummer wird nichts nachgeschlagen. Wirft bei Datenbankfehlern. */
|
||||
async function withdrawProjekt(d, receivedAt) {
|
||||
const auftrag = AB_NUMMER.test(d.abNummer) ? await findAuftragByNummer(d.abNummer) : null
|
||||
const adressen = [auftrag?.gesendetAn, auftrag?.kunde?.email].map((adresse) => String(adresse || '').trim().toLowerCase())
|
||||
if (!auftrag || !adressen.includes(d.email.toLowerCase())) return { auftrag: null, ergebnis: 'ungeprueft', fristEnde: null }
|
||||
return widerrufeAuftrag(auftrag, { jetzt: receivedAt })
|
||||
}
|
||||
|
||||
const PROJEKT_EINGANG = { widerrufen: 'RECHTZEITIG', nach_frist: 'NACH Fristende, vor einer Ablehnung bitte prüfen', bereits_widerrufen: 'bereits widerrufen' }
|
||||
|
||||
/** Werte für die Zeilen „Ergebnis“, „Widerrufsfrist bis“ und „Eingang“ im Block projekt von T09 (nur das Team sieht sie). */
|
||||
function projektWerte({ ergebnis, auftrag, fristEnde }, abNummer) {
|
||||
const ergebnisText = {
|
||||
widerrufen: () => `Auftragsbestätigung auf „widerrufen“ gesetzt${auftrag.fruehbeginn ? '; Frühbeginn war verlangt: Wertersatz prüfen (§ 357a Abs. 2 BGB)' : ''}`,
|
||||
bereits_widerrufen: () => 'war schon widerrufen, nichts geändert',
|
||||
kein_widerrufsrecht: () => 'Unternehmen: kein gesetzliches Widerrufsrecht, nichts geändert',
|
||||
nach_frist: () => 'nichts geändert',
|
||||
nicht_widerrufbar: () => `Status „${auftrag.status}“, nichts geändert, bitte prüfen`,
|
||||
fehler: () => 'ungeprüft: Prüfung fehlgeschlagen (Datenbank), bitte von Hand prüfen',
|
||||
}[ergebnis]
|
||||
return {
|
||||
ereignisse: ergebnisText
|
||||
? ergebnisText()
|
||||
: AB_NUMMER.test(abNummer)
|
||||
? `ungeprüft: keine Auftragsbestätigung zu Nummer „${abNummer}“ und dieser E-Mail-Adresse gefunden, nichts geändert`
|
||||
: 'ungeprüft: keine gültige AB-Nummer angegeben, nichts geändert, bitte von Hand zuordnen',
|
||||
fristende: ergebnis === 'kein_widerrufsrecht' ? 'entfällt (Unternehmen)' : fristEnde ? `${fmtDate(fristEnde)}, 24 Uhr` : 'keine Frist gefunden',
|
||||
rechtzeitig: PROJEKT_EINGANG[ergebnis] ?? 'ungeprüft',
|
||||
}
|
||||
}
|
||||
|
||||
/** Zeile je widerrufenem Ereignis fuer T09: ID, Inhalt (Speicher: Menge vorher -> nachher), Bestelldatum. */
|
||||
function describeWithdrawn(events) {
|
||||
return events
|
||||
@@ -1169,10 +1205,14 @@ router.post('/public/withdraw', async (req, res) => {
|
||||
// Funktion (News, Terminplaner oder Webshop): kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature)
|
||||
const featureKey = FEATURE_OF_ORDER[d.order] ?? null
|
||||
const feature = featureKey !== null
|
||||
const projekt = d.order === 'projekt'
|
||||
let pr = { auftrag: null, ergebnis: 'ungeprueft', fristEnde: null }
|
||||
const funktion = feature ? featureConfig(featureKey) : null
|
||||
let fn = { customer: null, orders: [], deadline: null, lines: '', inTime: false }
|
||||
try {
|
||||
if (feature) {
|
||||
if (projekt) {
|
||||
pr = await withdrawProjekt(d, receivedAt)
|
||||
} else if (feature) {
|
||||
fn = await withdrawFeature(d, receivedAt, featureKey)
|
||||
result = { ...result, deadline: fn.deadline, inTime: fn.inTime, late: !fn.inTime && Boolean(fn.deadline) }
|
||||
} else {
|
||||
@@ -1184,8 +1224,11 @@ router.post('/public/withdraw', async (req, res) => {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[abo] Widerruf nicht verarbeitbar:', err.message)
|
||||
if (projekt) pr = { auftrag: null, ergebnis: 'fehler', fristEnde: null }
|
||||
}
|
||||
const { inTime } = result
|
||||
const projektAngaben = projekt ? projektWerte(pr, d.abNummer) : null
|
||||
const produktText = projekt ? 'Projektauftrag' : feature ? funktion.label.name : productLabelOf(sub)
|
||||
// Fuer T09: warum sich die Frist nicht pruefen liess
|
||||
const pruefhinweis = result.unreadable ? 'Ereignisse nicht lesbar – bitte von Hand prüfen' : result.startMissing ? 'Abo-Beginn fehlt' : ''
|
||||
|
||||
@@ -1193,8 +1236,8 @@ router.post('/public/withdraw', async (req, res) => {
|
||||
template: 'K04',
|
||||
values: {
|
||||
name: d.name,
|
||||
bestellung: orderLabel(d.order, sub, { customer: true }),
|
||||
produkt: feature ? funktion.label.name : productLabelOf(sub),
|
||||
bestellung: orderLabel(d.order, sub, { customer: true, abNummer: d.abNummer }),
|
||||
produkt: produktText,
|
||||
eingang_zeitpunkt: fmtDateTime(receivedAt),
|
||||
email: d.email,
|
||||
kundennummer: d.customerNumber,
|
||||
@@ -1205,7 +1248,9 @@ router.post('/public/withdraw', async (req, res) => {
|
||||
wechsel: inTime && d.order === 'wechsel',
|
||||
zusatzspeicher: inTime && d.order === 'zusatzspeicher',
|
||||
funktion: feature,
|
||||
pruefen: !inTime && !feature,
|
||||
pruefen: !inTime && !feature && !projekt,
|
||||
projekt,
|
||||
projekt_unternehmen: projekt && pr.ergebnis === 'kein_widerrufsrecht',
|
||||
},
|
||||
}).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message))
|
||||
|
||||
@@ -1216,13 +1261,15 @@ router.post('/public/withdraw', async (req, res) => {
|
||||
email: d.email,
|
||||
name: d.name,
|
||||
kundennummer: d.customerNumber,
|
||||
bestellung: orderLabel(d.order, sub),
|
||||
produkt: feature ? funktion.label.name : productLabelOf(sub),
|
||||
bestellung: orderLabel(d.order, sub, { abNummer: d.abNummer }),
|
||||
produkt: produktText,
|
||||
preis: feature ? funktion.priceLabel : '',
|
||||
stripe_abo_id: sub?.stripeSubscriptionId || '',
|
||||
abo_beginn: fmtDate(sub?.startedAt),
|
||||
fristende: result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden',
|
||||
rechtzeitig: feature
|
||||
fristende: projekt ? projektAngaben.fristende : result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden',
|
||||
rechtzeitig: projekt
|
||||
? projektAngaben.rechtzeitig
|
||||
: feature
|
||||
? !fn.customer
|
||||
? 'ungeprüft (kein Kunde zu dieser E-Mail-Adresse gefunden)'
|
||||
: !fn.orders.length
|
||||
@@ -1243,14 +1290,14 @@ router.post('/public/withdraw', async (req, res) => {
|
||||
: result.deadline
|
||||
? 'ungeprüft (keine offene Frist: Bestellung gescheitert)'
|
||||
: 'ungeprüft (keine Bestellung dieser Art gefunden)',
|
||||
ereignisse: feature ? fn.lines : describeWithdrawn(result.withdrawn),
|
||||
ereignisse: projekt ? projektAngaben.ereignisse : feature ? fn.lines : describeWithdrawn(result.withdrawn),
|
||||
pruefhinweis,
|
||||
},
|
||||
blocks: {
|
||||
kundennummer: Boolean(d.customerNumber),
|
||||
abo_gefunden: Boolean(sub),
|
||||
auf_kuendigung_gesetzt: result.canceled,
|
||||
kein_abo_gefunden: !sub && !feature,
|
||||
kein_abo_gefunden: !sub && !feature && !projekt,
|
||||
kuendigung_fehlgeschlagen: result.cancelFailed,
|
||||
abschluss: inTime && d.order === 'abschluss',
|
||||
wechsel: inTime && d.order === 'wechsel',
|
||||
@@ -1262,6 +1309,7 @@ router.post('/public/withdraw', async (req, res) => {
|
||||
abschluss_verspaetet: Boolean(sub) && d.order === 'abschluss' && result.late && !result.alreadyWithdrawn,
|
||||
nicht_pruefbar: Boolean(sub) && Boolean(pruefhinweis),
|
||||
bereits_widerrufen: Boolean(sub) && result.alreadyWithdrawn,
|
||||
projekt,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Auftragsbestätigungen (Admin), Mount: /api/admin/finanzen/auftraege (vor dem Finanzen-Router, Übersicht V5 „L3“).
|
||||
* GET / (?kunde=), POST /, GET|PATCH|DELETE /:id, GET /:id/pdf, POST /:id/projekt, /:id/senden, /:id/erinnern,
|
||||
* /:id/neue-fassung, /:id/stornieren. Nur dünne HTTP-Schicht: Kunde laden, Schalter prüfen, Dienst aufrufen, Fehler über
|
||||
* sendeFehler. Ist der Schalter für den Kunden aus, antworten alle Routen 404 mit SCHALTER_AUS_MELDUNG.
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { auftraegeAktiv } from '../../services/auftraege/schalter.js'
|
||||
import { SCHALTER_AUS_MELDUNG } from '../../services/auftraege/konstanten.js'
|
||||
import { AuftragError, sendeFehler } from '../../services/auftraege/fehler.js'
|
||||
import { auftragFuerTeam } from '../../services/auftraege/auftragSicht.js'
|
||||
import { listAuftraege } from '../../services/auftraege/auftragStore.js'
|
||||
import {
|
||||
aendereEntwurf, erinnereAuftrag, ladeAuftrag, ladeGespeichertesPdf, ladeKunde, legeEntwurfAn, loescheEntwurf, neueFassung,
|
||||
ordneProjektZu, sendeAuftrag, storniereAuftrag, vorschauPdf,
|
||||
} from '../../services/auftraege/auftraege.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(requireAppwriteStaff)
|
||||
|
||||
async function ladeKundeMitSchalter(kundenId) {
|
||||
const kunde = await ladeKunde(kundenId)
|
||||
if (!auftraegeAktiv(kunde)) throw new AuftragError(404, SCHALTER_AUS_MELDUNG)
|
||||
return kunde
|
||||
}
|
||||
|
||||
async function ladeMitSchalter(id) {
|
||||
const auftrag = await ladeAuftrag(id)
|
||||
await ladeKundeMitSchalter(auftrag.kundenId)
|
||||
return auftrag
|
||||
}
|
||||
|
||||
const vonAdmin = (req) => req.appwriteUser?.email
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const kundenId = req.query.kunde
|
||||
if (typeof kundenId !== 'string' || kundenId === '') return res.status(400).json({ error: 'Kunde fehlt.' })
|
||||
await ladeKundeMitSchalter(kundenId)
|
||||
return res.json({ auftraege: (await listAuftraege({ kundenId })).map(auftragFuerTeam) })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigungen laden', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const kundenId = req.body?.kundenId
|
||||
if (typeof kundenId === 'string' && kundenId !== '') await ladeKundeMitSchalter(kundenId)
|
||||
return res.json({ auftrag: auftragFuerTeam(await legeEntwurfAn(req.body, { von: vonAdmin(req) })) })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung anlegen', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
return res.json({ auftrag: auftragFuerTeam(await ladeMitSchalter(req.params.id)) })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung laden', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.patch('/:id', async (req, res) => {
|
||||
try {
|
||||
await ladeMitSchalter(req.params.id)
|
||||
return res.json({ auftrag: auftragFuerTeam(await aendereEntwurf(req.params.id, req.body ?? {})) })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung ändern', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
await ladeMitSchalter(req.params.id)
|
||||
await loescheEntwurf(req.params.id)
|
||||
return res.json({ ok: true })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung löschen', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const auftrag = await ladeMitSchalter(req.params.id)
|
||||
const entwurf = auftrag.status === 'entwurf'
|
||||
const buffer = entwurf ? await vorschauPdf(auftrag.id) : await ladeGespeichertesPdf(auftrag)
|
||||
const name = entwurf ? 'Auftragsbestaetigung-Entwurf.pdf' : /^AB-\d{4}-\d{4}$/.test(auftrag.nummer) ? `${auftrag.nummer}.pdf` : 'Auftragsbestaetigung.pdf'
|
||||
res.set({ 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="${name}"`, 'Cache-Control': 'no-store' })
|
||||
return res.send(buffer)
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung-PDF laden', err)
|
||||
}
|
||||
})
|
||||
|
||||
/** Antwort der Aktionen, die eine Mail auslösen: { auftrag, mailFehler }. */
|
||||
const mitMail = ({ auftrag, mailFehler }) => ({ auftrag: auftragFuerTeam(auftrag), mailFehler })
|
||||
|
||||
router.post('/:id/senden', async (req, res) => {
|
||||
try {
|
||||
await ladeMitSchalter(req.params.id)
|
||||
return res.json(mitMail(await sendeAuftrag(req.params.id, { von: vonAdmin(req) })))
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung senden', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:id/erinnern', async (req, res) => {
|
||||
try {
|
||||
await ladeMitSchalter(req.params.id)
|
||||
return res.json(mitMail(await erinnereAuftrag(req.params.id)))
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung erinnern', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:id/neue-fassung', async (req, res) => {
|
||||
try {
|
||||
await ladeMitSchalter(req.params.id)
|
||||
return res.json({ auftrag: auftragFuerTeam(await neueFassung(req.params.id, { von: vonAdmin(req) })) })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Neue Fassung anlegen', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:id/stornieren', async (req, res) => {
|
||||
try {
|
||||
await ladeMitSchalter(req.params.id)
|
||||
return res.json({ auftrag: auftragFuerTeam(await storniereAuftrag(req.params.id)) })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung stornieren', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:id/projekt', async (req, res) => {
|
||||
try {
|
||||
await ladeMitSchalter(req.params.id)
|
||||
return res.json({ auftrag: auftragFuerTeam(await ordneProjektZu(req.params.id, req.body?.projektId)) })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Projekt zuordnen', err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { withLocks } from '../../services/keyedLock.js'
|
||||
import { AuftragError } from '../../services/auftraege/fehler.js'
|
||||
import { EnableBankingError } from '../../services/finance/enableBanking.js'
|
||||
import { completeBankAuth, startBankAuth, syncBank } from '../../services/finance/bankSync.js'
|
||||
import { getConnection, listTransactions, publicConnection, saveConnection } from '../../services/finance/bankStore.js'
|
||||
@@ -21,13 +22,14 @@ import {
|
||||
createProjectInvoice,
|
||||
deleteDraft,
|
||||
listInvoices,
|
||||
rechnungsVorschau,
|
||||
sendInvoiceNow,
|
||||
voidInvoice,
|
||||
} from '../../services/finance/stripeFinance.js'
|
||||
|
||||
/**
|
||||
* Finanzen im WOMS (nur Admins): Übersicht, Bankstatus, Abruf, Neuverbindung, Buchungen und deren Zuordnung,
|
||||
* Rechnungen (lesen, anlegen, senden, stornieren, Entwurf löschen), Einstellungen und Fixkosten. Die Handler sind klein
|
||||
* Rechnungen (lesen, anlegen, Vorschau, senden, stornieren, Entwurf löschen), Einstellungen und Fixkosten. Die Handler sind klein
|
||||
* und rufen die Dienste in server/services/finance auf.
|
||||
*/
|
||||
const router = Router()
|
||||
@@ -148,7 +150,7 @@ const isStripeError = (err) => typeof err?.type === 'string' && err.type.startsW
|
||||
* Fehler einen status tragen: nur die beiden eigenen Fehlerarten werden weitergegeben.
|
||||
*/
|
||||
function sendFinanceError(res, what, err) {
|
||||
if (err instanceof AssignError || err instanceof InvoiceError) {
|
||||
if (err instanceof AssignError || err instanceof InvoiceError || err instanceof AuftragError) {
|
||||
// Ein 502 an einer bestehenden Rechnung (nicht gesendet, nicht lesbar) hat seine Ursache bei Stripe: nur Art, Status und Code ins Protokoll
|
||||
if (err.status === 502 && err.cause) logFailure(what, err.cause)
|
||||
const body = { error: err.message }
|
||||
@@ -186,7 +188,6 @@ router.delete('/buchungen/:id/zuordnung', async (req, res) => {
|
||||
// --- Rechnungen (Stripe) ----------------------------------------------------------------------------------------
|
||||
|
||||
const INVOICE_FILTERS = new Set(['alle', 'offen', 'ueberfaellig', 'bezahlt', 'entwurf'])
|
||||
const DEFAULT_DAYS_UNTIL_DUE = 14
|
||||
const createdOf = (invoice) => invoice.createdDate ?? ''
|
||||
|
||||
router.get('/rechnungen', async (req, res) => {
|
||||
@@ -208,12 +209,24 @@ router.get('/rechnungen', async (req, res) => {
|
||||
})
|
||||
|
||||
router.post('/rechnungen', async (req, res) => {
|
||||
const { customerId, items, daysUntilDue, memo, senden, idempotencyKey } = req.body || {}
|
||||
const {
|
||||
customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum,
|
||||
daysUntilDue, memo, senden, idempotencyKey,
|
||||
} = req.body || {}
|
||||
try {
|
||||
// Die Typen der Felder prueft baueRechnung (400); die Route reicht sie unveraendert durch
|
||||
const rechnung = await createProjectInvoice({
|
||||
customerId,
|
||||
art,
|
||||
projektId,
|
||||
auftragId,
|
||||
items,
|
||||
daysUntilDue: daysUntilDue ?? DEFAULT_DAYS_UNTIL_DUE,
|
||||
arbeitsblattIds,
|
||||
anzahlung,
|
||||
zeilentexte,
|
||||
leistungszeitraum,
|
||||
// Ohne Angabe entscheidet createProjectInvoice (AB-Zahlungsziel bei Anzahlung und Schluss, sonst 14 Tage)
|
||||
daysUntilDue: daysUntilDue ?? undefined,
|
||||
memo,
|
||||
// Nur ein echtes Ja sendet sofort: alles andere bleibt ein Entwurf
|
||||
senden: senden === true || senden === 'true',
|
||||
@@ -227,6 +240,16 @@ router.post('/rechnungen', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Vorschau: dieselben Pruefungen wie das Anlegen, aber ohne Schreibzugriff
|
||||
router.post('/rechnungen/vorschau', async (req, res) => {
|
||||
const { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum } = req.body || {}
|
||||
try {
|
||||
return res.json(await rechnungsVorschau({ customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum }))
|
||||
} catch (err) {
|
||||
return sendFinanceError(res, 'Rechnungsvorschau', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/rechnungen/:id/senden', async (req, res) => {
|
||||
try {
|
||||
return res.json({ rechnung: await sendInvoiceNow(req.params.id) })
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Projektkonten (Admin). Mount: /api/admin/finanzen/projektkonten (vor dem Finanzen-Router).
|
||||
* GET /?kunde=<id> Projektkonten eines Kunden samt Zeile "Ohne Projekt"
|
||||
* GET /:projektId Konto, ABs, Rechnungen und offene Arbeitsblaetter eines Projekts
|
||||
* Nur lesen (Strang L1).
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { sendeFehler } from '../../services/auftraege/fehler.js'
|
||||
import { projektkontenFuerKunde, projektkontoDetail } from '../../services/auftraege/projektkonto.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(requireAppwriteStaff)
|
||||
|
||||
// Appwrite-UID: erst pruefen, dann an Appwrite geben
|
||||
const KENNUNG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const kunde = req.query.kunde
|
||||
if (typeof kunde !== 'string' || !KENNUNG.test(kunde)) return res.status(400).json({ error: 'Kunde fehlt oder ist ungültig.' })
|
||||
try {
|
||||
res.json(await projektkontenFuerKunde(kunde))
|
||||
} catch (err) {
|
||||
sendeFehler(res, 'Projektkonten laden', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:projektId', async (req, res) => {
|
||||
const { projektId } = req.params
|
||||
if (!KENNUNG.test(projektId)) return res.status(404).json({ error: 'Projekt nicht gefunden.' })
|
||||
try {
|
||||
res.json(await projektkontoDetail(projektId))
|
||||
} catch (err) {
|
||||
sendeFehler(res, 'Projektkonto laden', err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Aufträge des angemeldeten Kunden (Übersicht V3/V5, Strang L5). Mount: /api/auftraege. requireSession schützt jede
|
||||
* Route (fail-closed). Ein Kunde sieht und bestätigt nur eigene, gesendete Auftragsbestätigungen (keine Entwürfe);
|
||||
* in der Kundenansicht ist Bestätigen nie angeboten, und der Wächter der Kundenansicht sperrt den POST von selbst
|
||||
* (es gibt keinen Eintrag in KUNDENANSICHT_ALLOWED).
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { getDocument } from '../services/appwriteAdmin.js'
|
||||
import { isKundenansicht } from '../services/kundenansicht.js'
|
||||
import { getAuftrag, listAuftraege } from '../services/auftraege/auftragStore.js'
|
||||
import { oeffentlicheAnsicht } from '../services/auftraege/auftragSicht.js'
|
||||
import { bestaetigeAuftrag, GESPERRT_MELDUNG } from '../services/auftraege/bestaetigung.js'
|
||||
import { AuftragError, sendeFehler } from '../services/auftraege/fehler.js'
|
||||
import { auftraegeAktiv } from '../services/auftraege/schalter.js'
|
||||
import { sendePdf } from './auftrag.js'
|
||||
|
||||
const NICHT_GEFUNDEN = 'Auftragsbestätigung nicht gefunden.'
|
||||
const HAEKCHEN = 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.'
|
||||
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
|
||||
|
||||
const router = Router()
|
||||
router.use(requireSession)
|
||||
router.use((_req, res, next) => {
|
||||
res.set('Cache-Control', 'no-store')
|
||||
next()
|
||||
})
|
||||
|
||||
const ansicht = (a, req) => ({
|
||||
...oeffentlicheAnsicht(a),
|
||||
id: a.id,
|
||||
kannBestaetigen: !isKundenansicht(req.session) && (a.status === 'versendet' || (a.status === 'abgerechnet' && !a.bestaetigtAm)),
|
||||
})
|
||||
|
||||
/** Eigene, gesendete AB laden; sonst ist 404 schon gesendet und das Ergebnis `null`. */
|
||||
async function ladeEigene(req, res) {
|
||||
const nichtGefunden = () => {
|
||||
res.status(404).json({ error: NICHT_GEFUNDEN })
|
||||
return null
|
||||
}
|
||||
if (!ID_RE.test(String(req.params.id))) return nichtGefunden()
|
||||
const kundenId = getSessionCustomerId(req)
|
||||
const kunde = await getDocument(config.collections.customers, kundenId)
|
||||
if (!auftraegeAktiv(kunde)) return nichtGefunden()
|
||||
const a = await getAuftrag(req.params.id)
|
||||
if (!a || a.kundenId !== kundenId || a.status === 'entwurf') return nichtGefunden()
|
||||
return a
|
||||
}
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const kundenId = getSessionCustomerId(req)
|
||||
const kunde = await getDocument(config.collections.customers, kundenId)
|
||||
if (!auftraegeAktiv(kunde)) return res.json({ aktiv: false, auftraege: [] })
|
||||
const liste = await listAuftraege({ kundenId })
|
||||
res.json({ aktiv: true, auftraege: liste.filter((a) => a.status !== 'entwurf').map((a) => ansicht(a, req)) })
|
||||
} catch (err) {
|
||||
sendeFehler(res, 'auftraege', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const a = await ladeEigene(req, res)
|
||||
if (a) await sendePdf(res, a)
|
||||
} catch (err) {
|
||||
sendeFehler(res, 'auftraege', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:id/bestaetigen', async (req, res) => {
|
||||
try {
|
||||
const a = await ladeEigene(req, res)
|
||||
if (!a) return
|
||||
if (GESPERRT_MELDUNG[a.status]) return res.status(410).json({ error: GESPERRT_MELDUNG[a.status] })
|
||||
const body = req.body && typeof req.body === 'object' ? req.body : {}
|
||||
if (body.bestaetigt !== true) return res.status(400).json({ error: HAEKCHEN })
|
||||
const neu = await bestaetigeAuftrag(a, { ueber: 'portal', fruehbeginn: body.fruehbeginn === true })
|
||||
res.json({ auftrag: ansicht(neu, req) })
|
||||
} catch (err) {
|
||||
if (err instanceof AuftragError && err.status === 409 && Object.values(GESPERRT_MELDUNG).includes(err.message)) {
|
||||
return res.status(410).json({ error: err.message })
|
||||
}
|
||||
sendeFehler(res, 'auftraege', err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Öffentliche Link-Routen einer Auftragsbestätigung (Übersicht V5, Strang L5): ansehen, PDF, bestätigen – ohne
|
||||
* Anmeldung, das Token steht im Link (Fragment `#t=`, die Seite schickt es hierher). Mount: /api/auftrag.
|
||||
* Ratenbegrenzung nur im Arbeitsspeicher (GET 120/h, POST 10/h je IP); nichts von der Anfrage wird gespeichert oder
|
||||
* protokolliert. Ungültige, unbekannte, abgelaufene Links und abgeschalteter Schalter antworten gleich (404).
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
import { createWriteLimiter } from '../services/writeLimiter.js'
|
||||
import { findAuftragByTokenHash } from '../services/auftraege/auftragStore.js'
|
||||
import { lesePdf } from '../services/auftraege/ablage.js'
|
||||
import { oeffentlicheAnsicht } from '../services/auftraege/auftragSicht.js'
|
||||
import { bestaetigeAuftrag, GESPERRT_MELDUNG, UNGUELTIGER_LINK } from '../services/auftraege/bestaetigung.js'
|
||||
import { AuftragError, sendeFehler } from '../services/auftraege/fehler.js'
|
||||
import { auftraegeAktiv } from '../services/auftraege/schalter.js'
|
||||
import { hashToken } from '../services/auftraege/token.js'
|
||||
|
||||
const UNGUELTIG = UNGUELTIGER_LINK
|
||||
const HAEKCHEN = 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.'
|
||||
const ZU_VIELE = 'Zu viele Anfragen. Bitte später erneut versuchen.'
|
||||
|
||||
const echteUhr = () => new Date()
|
||||
let uhr = echteUhr
|
||||
const jetztMs = () => uhr().getTime()
|
||||
const getLimiter = createWriteLimiter({ max: 120, now: jetztMs })
|
||||
const postLimiter = createWriteLimiter({ max: 10, now: jetztMs })
|
||||
const nachIp = (req) => String(req.ip)
|
||||
|
||||
/** Nur für Tests: feste Uhr der Routen (`null` = echte Uhr). */
|
||||
export function setzeUhrFuerTests(fn) {
|
||||
uhr = typeof fn === 'function' ? fn : echteUhr
|
||||
}
|
||||
|
||||
/** Nur für Tests: Zähler leeren und echte Uhr. */
|
||||
export function resetAuftragRoutesForTests() {
|
||||
getLimiter.reset()
|
||||
postLimiter.reset()
|
||||
uhr = echteUhr
|
||||
}
|
||||
|
||||
const router = Router()
|
||||
router.use((_req, res, next) => {
|
||||
res.set({
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Robots-Tag': 'noindex',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
})
|
||||
next()
|
||||
})
|
||||
|
||||
/** Gemeinsamer Vorlauf der Link-Routen; bei `null` ist die Antwort (404/410) schon gesendet. */
|
||||
async function ladeNachToken(req, res) {
|
||||
const nichtGueltig = () => {
|
||||
res.status(404).json({ error: UNGUELTIG })
|
||||
return null
|
||||
}
|
||||
const hash = hashToken(req.params.token)
|
||||
if (hash === null) return nichtGueltig()
|
||||
const a = await findAuftragByTokenHash(hash)
|
||||
if (!a) return nichtGueltig()
|
||||
if (!auftraegeAktiv({ email: a.kunde?.email || a.gesendetAn })) return nichtGueltig()
|
||||
if (a.status === 'entwurf') return nichtGueltig()
|
||||
if (GESPERRT_MELDUNG[a.status]) {
|
||||
res.status(410).json({ error: GESPERRT_MELDUNG[a.status] })
|
||||
return null
|
||||
}
|
||||
const bis = a.tokenGueltigBis ? Date.parse(a.tokenGueltigBis) : Number.NaN
|
||||
if (!Number.isFinite(bis) || jetztMs() > bis) return nichtGueltig()
|
||||
return a
|
||||
}
|
||||
|
||||
/** AB-PDF aus der Ablage senden (inline); fehlt die Datei, 404 `PDF nicht gefunden.`. */
|
||||
export async function sendePdf(res, auftrag) {
|
||||
let buffer
|
||||
try {
|
||||
buffer = await lesePdf(auftrag.pdfDatei)
|
||||
} catch (err) {
|
||||
if (err instanceof AuftragError) throw err
|
||||
console.error(`[auftraege] PDF zu ${auftrag.nummer} nicht lesbar: ${err.code || err.message}`)
|
||||
throw new AuftragError(404, 'PDF nicht gefunden.')
|
||||
}
|
||||
const nummer = String(auftrag.nummer || 'Auftragsbestaetigung').replace(/[^A-Za-z0-9-]/g, '') || 'Auftragsbestaetigung'
|
||||
res.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="${nummer}.pdf"` })
|
||||
res.end(buffer)
|
||||
}
|
||||
|
||||
router.get('/:token', getLimiter.middleware(nachIp, ZU_VIELE), async (req, res) => {
|
||||
try {
|
||||
const a = await ladeNachToken(req, res)
|
||||
if (a) res.json({ auftrag: oeffentlicheAnsicht(a) })
|
||||
} catch (err) {
|
||||
sendeFehler(res, 'auftrag', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:token/pdf', getLimiter.middleware(nachIp, ZU_VIELE), async (req, res) => {
|
||||
try {
|
||||
const a = await ladeNachToken(req, res)
|
||||
if (a) await sendePdf(res, a)
|
||||
} catch (err) {
|
||||
sendeFehler(res, 'auftrag', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:token/bestaetigen', postLimiter.middleware(nachIp, ZU_VIELE), async (req, res) => {
|
||||
try {
|
||||
const a = await ladeNachToken(req, res)
|
||||
if (!a) return
|
||||
const body = req.body && typeof req.body === 'object' ? req.body : {}
|
||||
if (body.bestaetigt !== true) return res.status(400).json({ error: HAEKCHEN })
|
||||
const neu = await bestaetigeAuftrag(a, { ueber: 'link', fruehbeginn: body.fruehbeginn === true, jetzt: uhr() })
|
||||
res.json({ auftrag: oeffentlicheAnsicht(neu) })
|
||||
} catch (err) {
|
||||
sendeFehler(res, 'auftrag', err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Arbeitsblaetter fuers Ticketsystem. Mount: /api/team/arbeitsblaetter, in team/index.js hinter requireStaffJwt (jeder
|
||||
* Mitarbeiter, daher kein eigener Schutz).
|
||||
* GET /abgerechnet?ticketId=<id> { abgerechnet: { <Blatt-ID>: '<Rechnungsnummer>' | 'Entwurf' } }, ohne Betraege
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
import { sendeFehler } from '../../services/auftraege/fehler.js'
|
||||
import { abgerechnetFuerTicket } from '../../services/auftraege/projektkonto.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Appwrite-UID: erst pruefen, dann an Appwrite geben
|
||||
const KENNUNG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
|
||||
|
||||
router.get('/abgerechnet', async (req, res) => {
|
||||
const ticketId = req.query.ticketId
|
||||
if (typeof ticketId !== 'string' || !KENNUNG.test(ticketId)) return res.status(400).json({ error: 'ticketId fehlt oder ist ungültig.' })
|
||||
try {
|
||||
res.json({ abgerechnet: await abgerechnetFuerTicket(ticketId) })
|
||||
} catch (err) {
|
||||
sendeFehler(res, 'Abgerechnete Arbeitsblätter laden', err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -5,6 +5,7 @@ import { funktionenAdminRouter } from '../admin/funktionenAdmin.js'
|
||||
import { offersAdminRouter } from '../offers.js'
|
||||
import { storageTasksAdminRouter } from '../storageTasks.js'
|
||||
import { takeoverAdminRouter } from '../takeoverCredits.js'
|
||||
import arbeitsblaetterTeamRoutes from './arbeitsblaetter.js'
|
||||
import customersRoutes from './customers.js'
|
||||
import filesRoutes from './files.js'
|
||||
import previewRoutes from './preview.js'
|
||||
@@ -31,5 +32,7 @@ router.use('/termine', termineTeamRoutes)
|
||||
router.use('/shop', shopTeamRoutes)
|
||||
// Buchungskalender: Stand je Projekt und Entscheidung ueber die Anfrage
|
||||
router.use('/unterkunft', unterkunftTeamRoutes)
|
||||
// Abgerechnete Arbeitsblaetter (Abzeichen am Ticket, alle Mitarbeiter)
|
||||
router.use('/arbeitsblaetter', arbeitsblaetterTeamRoutes)
|
||||
|
||||
export default router
|
||||
Reference in new issue
Block a user