Files
ClaudeandClaude Sonnet 5.5 3e32ddfe86 Kundenansicht neu E-Fix M8: Wechselangebot beim Einschalten nur, wenn der Wechsel möglich ist
aboDeckt nennt falsches_abo vor gekuendigt, beginnt_spaeter und nicht_aktiv (die Reihenfolge bleibt gepinnt). Die 409-Antwort setzte wechsel: true
allein aus grund === 'falsches_abo': ein Hosting-Abo in der Probezeit, mit offener Zahlung oder vorgemerkter Kündigung bekam das Angebot, landete auf
#wechsel bei "Ein Wechsel ist derzeit nicht möglich" und verlor den eigentlichen Grund. Jetzt prüft pruefeAboDeckung bei falsches_abo zusätzlich
wechselMoeglich (aboFunktionen.js, dieselben Bedingungen wie canSwitch und assertCanSwitch: Hosting, aktiv, nicht gekündigt, kein offener Wechsel);
ein Lesefehler dabei nimmt nur das Angebot weg. Test in funktionEinschalten.test.js.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:47:36 +00:00

195 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { config } from '../config.js'
import { updateDocument } from './appwriteAdmin.js'
import { aboDeckt, wechselMoeglich } from './aboFunktionen.js'
import { meldeEingeschaltet } from './aboFunktionenMeldung.js'
import { berlinDay } from './deadlines.js'
import { bestellFehler } from './featureOrder.js'
import { featureConfig, ticketTexte } from './featureRegistry.js'
import { withLocks } from './keyedLock.js'
import { assertOwnProject, customerOf, ensureFeatureDoc, getFeatureDoc, notizVorn, stateFields, ticketWoid, unlockedByVon } from './siteFeatures.js'
import { ABRECHNUNGS_ZEILEN, createFeatureTicket } from './ticketAdmin.js'
import { einschaltenZeiten } from './zeiten/einschalten.js'
/**
* Generisches Einschalten einer Funktion im Abo-Modell (V7, Spec 5.4): ohne Zahlung, mit Einrichtungs-Ticket. Gleiches Muster wie
* einschaltenZeiten (Sperre feature-order, Nachweis vor dem Zustand, Ticket danach, Heilung statt Doppelticket). Am Ende meldet
* meldeEingeschaltet (aboFunktionenMeldung.js, P6) das Einschalten mit K38 an den Kunden und T24 ans Team; die Team-Aktivierung
* (adminActivate) steht in siteFeatures.js. Fuer news, termine, shop und unterkunft laeuft alles hier; zeiten geht ueber
* einschaltenZeiten (Neubau, Ticket und K27 stehen dort) mit einer Vorpruefung, die dieselben Codes liefert.
*/
/** Wie die Einrichtung abgerechnet wird: im Abo-Stundenkonto (abo) oder im Projekt enthalten (projekt). */
export const ABRECHNUNGEN = Object.freeze(['abo', 'projekt'])
export const EINSCHALTEN_TEXTE = Object.freeze({
bereits: 'Diese Funktion ist für dieses Projekt schon eingeschaltet.',
laeuft: 'Für diese Funktion läuft schon eine Bestellung.',
abgeschaltet: 'Diese Funktion wurde für dieses Projekt abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.',
ticket: 'Die Einrichtung konnte gerade nicht angelegt werden. Bitte versuch es gleich noch einmal.',
abo: Object.freeze({
kein_abo: 'Für diese Funktion brauchst du ein laufendes Abo.',
falsches_abo: 'Diese Funktion ist im Wartungs-Abo enthalten.',
gekuendigt: 'Dein Abo ist gekündigt. Zum Einschalten brauchst du ein laufendes Abo.',
nicht_aktiv: 'Dein Abo ist gerade nicht aktiv, zum Beispiel wegen einer offenen Zahlung. Sobald es wieder aktiv ist, kannst du die Funktion einschalten.',
beginnt_spaeter: 'Dein Abo beginnt am {datum}. Ab dann kannst du einschalten.',
}),
nichtPruefbar: 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.',
})
/** Berliner Kalendertag eines Zeitpunkts (ISO) als TT.MM.JJJJ; kein Zeitpunkt ist ein TypeError. */
export function datumDe(zeitpunkt) {
const ms = Date.parse(zeitpunkt)
if (Number.isNaN(ms)) throw new TypeError(`datumDe: kein Zeitpunkt: ${String(zeitpunkt)}`)
const [jahr, monat, tag] = berlinDay(new Date(ms)).split('-')
return `${tag}.${monat}.${jahr}`
}
/**
* Kundentext zu einem Abo-Grund. Bei beginnt_spaeter steht der Berliner Tag von `ab` (ISO) als TT.MM.JJJJ im Text,
* z. B. "Dein Abo beginnt am 03.11.2026. Ab dann kannst du einschalten."
*/
export function aboFehlerText(grund, ab = null) {
if (!Object.hasOwn(EINSCHALTEN_TEXTE.abo, grund)) throw new TypeError(`aboFehlerText: unbekannter Grund ${String(grund)}`)
const text = EINSCHALTEN_TEXTE.abo[grund]
return grund === 'beginnt_spaeter' ? text.replace('{datum}', datumDe(ab)) : text
}
/**
* 409 abo_fehlt: body.grund, body.wechsel (Hosting-Kunde, der wirklich wechseln kann: Wechsel ins Wartungs-Abo anbieten) und bei beginnt_spaeter body.ab.
* wechsel ist nur bei falsches_abo und moeglichem Wechsel true (E-Fix M8): aboDeckt nennt falsches_abo vor gekuendigt, beginnt_spaeter und nicht_aktiv
* (die Reihenfolge der Gruende bleibt), ein Hosting-Abo in der Probezeit, bei offener Zahlung oder mit vorgemerkter Kuendigung bekaeme sonst ein
* Angebot, das auf #wechsel in "Ein Wechsel ist derzeit nicht möglich" endet.
*/
const aboFehlt = (grund, ab, wechsel = false) =>
bestellFehler(409, aboFehlerText(grund, ab), 'abo_fehlt', { grund, wechsel: grund === 'falsches_abo' && wechsel === true, ...(grund === 'beginnt_spaeter' ? { ab } : {}) })
const konflikt = (code, text) => bestellFehler(409, text, code)
/**
* Abo frisch lesen und gegen die Funktion pruefen (immer ohne Zwischenspeicher): 409 abo_fehlt oder nichts. Lesefehler sind 503,
* nie stillschweigend erlaubt. Auch fuer die Vorpruefungen der Route.
*/
export async function pruefeAboDeckung(customerId, key, now = new Date()) {
let ergebnis
try {
ergebnis = await aboDeckt(customerId, key, { now })
} catch (err) {
console.error(`[funktionen] Abo für das Einschalten nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
throw bestellFehler(503, EINSCHALTEN_TEXTE.nichtPruefbar)
}
if (ergebnis.ok === true) return
let wechsel = false
if (ergebnis.grund === 'falsches_abo') {
// Ein Lesefehler hier nimmt nur das Angebot weg (fail-closed fuer das Angebot, nicht fuer die Pruefung)
wechsel = await wechselMoeglich(customerId, { now }).catch(() => false)
}
throw aboFehlt(ergebnis.grund, ergebnis.ab, wechsel)
}
/** setup ohne Ticket und durch Abo oder Projekt eingeschaltet: der Dienst heilt (nur das Ticket), es entsteht nichts Neues. */
const heilbar = (doc) => doc?.state === 'setup' && !doc.ticketId && ['abo', 'projekt'].includes(unlockedByVon(doc))
/**
* Zustand des Dokuments pruefen: wirft den 409 mit Code (bestellung_laeuft, abgeschaltet, bereits_eingeschaltet); ein fehlendes oder
* ausgeschaltetes (off) Dokument und die Heilung (setup ohne Ticket, durch Abo oder Projekt) sind frei. Auch fuer die Vorpruefungen der Route.
*/
export function pruefeEinschaltZustand(doc) {
if (!doc || doc.state === 'off') return
if (doc.state === 'ordered') throw konflikt('bestellung_laeuft', EINSCHALTEN_TEXTE.laeuft)
if (doc.state === 'disabled') throw konflikt('abgeschaltet', EINSCHALTEN_TEXTE.abgeschaltet)
if (!heilbar(doc)) throw konflikt('bereits_eingeschaltet', EINSCHALTEN_TEXTE.bereits)
}
/** Notiz des Teams: staff.adminNote oder "<ISO> <Name>: Eingeschaltet (<abrechnung>)" vor der bisherigen Notiz; ohne beides keine. */
function teamNotiz(staff, bisherige, abrechnung, now) {
if (typeof staff?.adminNote === 'string' && staff.adminNote) return staff.adminNote.slice(0, 300)
if (!staff?.name) return null
return notizVorn(bisherige, `${now.toISOString()} ${staff.name}: Eingeschaltet (${abrechnung})`)
}
/** Einrichtungs-Ticket anlegen und an das Dokument haengen; jeder Fehler hier ist 503 (der Zustand bleibt setup, der naechste Aufruf heilt). */
async function legeTicketAn(doc, { key, feature, project, customerId, abrechnung }) {
try {
const customer = await customerOf(customerId).catch(() => null)
const texte = await ticketTexte({ feature, doc, project, customer })
const { ticketId } = await createFeatureTicket({ customerId, project, featureKey: key, siteKey: doc.siteKey, ...texte, abrechnung })
return await updateDocument(config.collections.portalFeatures, doc.$id, { ticketId })
} catch (err) {
console.error(`[funktionen] Einrichtung ${key} (${doc.$id}) nicht angelegt: ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${err?.message ?? ''}`.trim())
throw Object.assign(new Error(EINSCHALTEN_TEXTE.ticket), { status: 503, cause: err })
}
}
/**
* Funktion fuer ein Projekt einschalten (Zustand setup, Einrichtungs-Ticket mit Abrechnung).
* @param {{ customerId: string, projectId: string, key: string, by: 'kunde'|'team', abrechnung: 'abo'|'projekt',
* nachweis?: (() => Promise<unknown>)|null, staff?: { name?: string, adminNote?: string }|null, now?: Date }} p
* by 'kunde': das Abo wird unter der Sperre frisch gegen die Funktion geprueft (aboDeckt); by 'team': keine Abo-Pruefung.
* nachweis: speichert den Zustimmungsnachweis, bevor der Zustand geaendert wird; wirft er, bleibt alles wie es war.
* @returns {Promise<{ doc: object, ticketWoid: string|null }>}
* @throws {TypeError} key ausserhalb des Registers (auch analyse), by oder abrechnung ungueltig
* @throws {Error} 403/409 (Projekt), 409 bereits_eingeschaltet | bestellung_laeuft | abgeschaltet | abo_fehlt, 503 (Abo nicht lesbar, Ticket gescheitert)
*/
export async function einschalten({ customerId, projectId, key, by, abrechnung, nachweis = null, staff = null, now = new Date() } = {}) {
const feature = featureConfig(key)
if (!feature) throw new TypeError(`einschalten: unbekannte Funktion ${String(key)}`)
if (by !== 'kunde' && by !== 'team') throw new TypeError(`einschalten: by muss kunde oder team sein, nicht ${String(by)}`)
if (!ABRECHNUNGEN.includes(abrechnung)) throw new TypeError(`einschalten: abrechnung muss abo oder projekt sein, nicht ${String(abrechnung)}`)
const project = await assertOwnProject(customerId, projectId, { forNewOrder: true })
// Oeffnungszeiten: Sperre, Nachweis, Zustand, Neubau, Ticket und K27 stehen in einschaltenZeiten; die Vorpruefung unter der Sperre
// liefert dieselben Codes wie unten (nur fuer den Kunden, das Team schaltet wie bisher)
if (key === 'zeiten') {
const vorpruefung = async () => {
await pruefeAboDeckung(customerId, key, now)
pruefeEinschaltZustand(await getFeatureDoc(customerId, projectId, key))
}
const { doc, changed } = await einschaltenZeiten({ customerId, projectId, by, staff, nachweis, now, abrechnung, vorpruefung })
const woid = await ticketWoid(doc.ticketId)
// nur wenn dieser Aufruf den Zustand gesetzt hat (K27 sendet holeZeitenNach); ein idempotenter Aufruf meldet nichts
if (changed === true) await meldeEingeschaltet({ doc, ticketWoid: woid, by, abrechnung })
return { doc, ticketWoid: woid }
}
// Abrechnung, mit der dieser Aufruf endet: bei der Heilung die am Dokument gespeicherte
let abgerechnet = abrechnung
const doc = await withLocks([`feature-order:${customerId}:${projectId}`], async () => {
// 1 Abo frisch (nie aus einem Zwischenspeicher); fuer das Team entfaellt es
if (by === 'kunde') await pruefeAboDeckung(customerId, key, now)
// 2 Zustand: Heilung (setup ohne Ticket, durch Abo oder Projekt) legt nur das Ticket an
const bisher = await getFeatureDoc(customerId, projectId, key)
pruefeEinschaltZustand(bisher)
if (heilbar(bisher)) {
const gespeichert = Object.hasOwn(ABRECHNUNGS_ZEILEN, bisher.einrichtungAbrechnung) ? bisher.einrichtungAbrechnung : abrechnung
abgerechnet = gespeichert
return legeTicketAn(bisher, { key, feature, project, customerId, abrechnung: gespeichert })
}
// 3 Zustimmung speichern, vor dem Zustand: scheitert sie, bleibt alles wie es war
if (nachweis) await nachweis()
// 4 Dokument auf setup. Reste einer frueheren Abschaltung gehoeren zum alten Lauf und enden hier.
const vorhanden = bisher ?? (await ensureFeatureDoc(customerId, projectId, key))
const notiz = by === 'team' ? teamNotiz(staff, vorhanden.adminNote, abrechnung, now) : null
const gespeichert = await updateDocument(config.collections.portalFeatures, vorhanden.$id, {
...stateFields('setup'),
featureKey: key,
unlockedBy: abrechnung,
unlockedByPurchase: false,
einrichtungAbrechnung: abrechnung,
disabledAt: null,
disabledReason: null,
aboEndeMailAt: null,
...(notiz ? { adminNote: notiz } : {}),
})
// 5 Ticket; scheitert es, bleibt setup ohne Ticket und der naechste Aufruf heilt
return legeTicketAn(gespeichert, { key, feature, project, customerId, abrechnung })
})
// Der Aufruf hat den Zustand gesetzt oder das Ticket angelegt (Heilung zaehlt); die Meldung wirft nie
const woid = await ticketWoid(doc.ticketId)
await meldeEingeschaltet({ doc, ticketWoid: woid, by, abrechnung: abgerechnet })
return { doc, ticketWoid: woid }
}