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>
124 lines
6.2 KiB
JavaScript
124 lines
6.2 KiB
JavaScript
import { config } from '../config.js'
|
|
import { getDocument } from './appwriteAdmin.js'
|
|
import { ACTIVE_STATUSES, getCurrentSubscription } from './stripeBilling.js'
|
|
import { listContractEvents, openWithdrawals } from './contractEvents.js'
|
|
import { neueAnsichtFuerKunde, neueAnsichtModus } from './kundenansicht/schalter.js'
|
|
|
|
/**
|
|
* Abo-Zuordnung der Funktionen (V5, Spec E3/E8): Welches Abo deckt welche Funktion? Ein Lesepfad (lies) fuer alle
|
|
* Fragen, damit Einschalten, Katalog und Abgleich nie verschieden urteilen. Fail-closed: ein Lesefehler wirft, er wird
|
|
* nie zu kein_abo. Wer ihn abfaengt, entscheidet selbst (Einschalten: 503, Abgleichsjob: Kunde ueberspringen).
|
|
*
|
|
* Produkt: ohne productKey gilt Wartung (Altbestand, wie routes/abo.js). Ist das juengste wechsel-Ereignis dieses Abos
|
|
* widerrufen, sagt der Spiegel noch Wartung, es gilt aber Hosting (der Widerruf setzt den Spiegel nicht zurueck, wie in
|
|
* aboStatus.js). Ein Abo mit unbekanntem productKey deckt nichts (produkt null, laeuft false).
|
|
*/
|
|
export const ENTHALTEN_IN = Object.freeze({
|
|
analyse: ['hosting', 'wartung'],
|
|
news: ['wartung'],
|
|
termine: ['wartung'],
|
|
shop: ['wartung'],
|
|
unterkunft: ['wartung'],
|
|
zeiten: ['wartung'],
|
|
})
|
|
|
|
// Nur diese Ereignisse haben eine Widerrufsfrist, die den Beginn einer Funktion betrifft (Speicher-Plus zaehlt nicht)
|
|
const FRIST_TYPEN = new Set(['abschluss', 'wechsel'])
|
|
|
|
function pruefeSchluessel(key) {
|
|
if (!Object.hasOwn(ENTHALTEN_IN, key)) throw new TypeError(`Unbekannte Funktion: ${String(key)}`)
|
|
}
|
|
|
|
/** Zeitpunkt in ms aus einem Text oder Date; NaN, wenn es keiner ist (null und '' sind keine Zeitpunkte). */
|
|
function zeitpunkt(wert) {
|
|
if (wert instanceof Date) return wert.getTime()
|
|
return typeof wert === 'string' && wert !== '' ? Date.parse(wert) : Number.NaN
|
|
}
|
|
|
|
async function lies(customerId, now) {
|
|
const abo = await getCurrentSubscription(customerId)
|
|
const ohne = { laeuft: false, produkt: null, gekuendigt: false, widerrufsfristBis: null, status: abo?.status ?? null, trialEnd: null }
|
|
if (!abo || !ACTIVE_STATUSES.has(abo.status)) return ohne
|
|
const spiegel = abo.productKey || 'wartung'
|
|
if (spiegel !== 'wartung' && spiegel !== 'hosting') return ohne
|
|
|
|
// Ereignisse dieses Abos: Wechsel-Regel und offene Widerrufsfristen
|
|
const ereignisse = (await listContractEvents(customerId)).filter((e) => e.subscriptionId === abo.$id)
|
|
const wechselWiderrufen = ereignisse.find((e) => e.type === 'wechsel')?.status === 'widerrufen' // neueste zuerst
|
|
const wechselOffen = ereignisse.some((e) => e.type === 'wechsel' && e.status === 'offen')
|
|
const produkt = spiegel === 'wartung' && wechselWiderrufen ? 'hosting' : spiegel
|
|
|
|
const enden = openWithdrawals(ereignisse.filter((e) => FRIST_TYPEN.has(e.type)), now).map((f) => zeitpunkt(f.until))
|
|
const spaetestes = enden.length ? Math.max(...enden) : null
|
|
return {
|
|
laeuft: true,
|
|
produkt,
|
|
gekuendigt: Boolean(abo.cancelAtPeriodEnd || abo.cancelAt),
|
|
widerrufsfristBis: spaetestes === null ? null : new Date(spaetestes).toISOString(),
|
|
status: abo.status,
|
|
trialEnd: abo.trialEnd,
|
|
wechselOffen,
|
|
}
|
|
}
|
|
|
|
/** { produkt: 'hosting' | 'wartung' | null, laeuft, gekuendigt, widerrufsfristBis: ISO | null } (genau diese vier Schluessel) */
|
|
export async function aboProdukt(customerId, { now = new Date() } = {}) {
|
|
const { produkt, laeuft, gekuendigt, widerrufsfristBis } = await lies(customerId, now)
|
|
return { produkt, laeuft, gekuendigt, widerrufsfristBis }
|
|
}
|
|
|
|
/**
|
|
* Kann der Kunde jetzt ins Wartungs-Abo wechseln (E-Fix M8, wie canSwitch in GET /api/abo und assertCanSwitch in planSwitch.js)? Hosting-Abo, Status active,
|
|
* nicht gekuendigt (cancelAtPeriodEnd oder cancelAt), kein Wechsel, der auf die Zahlung wartet. Lesefehler werden weitergereicht.
|
|
*/
|
|
export async function wechselMoeglich(customerId, { now = new Date() } = {}) {
|
|
const l = await lies(customerId, now)
|
|
return l.laeuft && l.produkt === 'hosting' && !l.gekuendigt && l.status === 'active' && !l.wechselOffen
|
|
}
|
|
|
|
/**
|
|
* Deckt das Abo des Kunden diese Funktion? { ok: true } | { ok: false, grund } mit grund in der Reihenfolge
|
|
* kein_abo, falsches_abo, gekuendigt, beginnt_spaeter (mit ab = Ende der Probezeit als ISO), nicht_aktiv.
|
|
* beginnt_spaeter: Status trialing mit gueltigem trialEnd nach now (Privatkunde, der ohne Haekchen bestellt hat).
|
|
*/
|
|
export async function aboDeckt(customerId, key, { now = new Date() } = {}) {
|
|
pruefeSchluessel(key)
|
|
const l = await lies(customerId, now)
|
|
if (!l.laeuft) return { ok: false, grund: 'kein_abo' }
|
|
if (!ENTHALTEN_IN[key].includes(l.produkt)) return { ok: false, grund: 'falsches_abo' }
|
|
if (l.gekuendigt) return { ok: false, grund: 'gekuendigt' }
|
|
if (l.status === 'trialing') {
|
|
const ende = zeitpunkt(l.trialEnd)
|
|
if (ende > now.getTime()) return { ok: false, grund: 'beginnt_spaeter', ab: new Date(ende).toISOString() }
|
|
}
|
|
if (l.status !== 'active') return { ok: false, grund: 'nicht_aktiv' }
|
|
return { ok: true }
|
|
}
|
|
|
|
/** Laeuft ein Abo, das diese Funktion enthaelt? Gekuendigt und past_due zaehlen noch (fuer Abgleichsjobs). */
|
|
export async function aboLaeuftFuer(customerId, key) {
|
|
pruefeSchluessel(key)
|
|
const l = await lies(customerId, new Date())
|
|
return l.laeuft && ENTHALTEN_IN[key].includes(l.produkt)
|
|
}
|
|
|
|
/**
|
|
* Gehoert dieser Kunde zum Abo-Modell (sieht er die neue Ansicht)? Fuer Jobs und Dienste ohne Cookie (Tagespruefung der Analyse,
|
|
* Team-Aktivierung): liest das Kundendokument (kein Dokument: false) und fragt neueAnsichtFuerKunde({ customerId, email }). Nie
|
|
* neueAnsichtFuerSession: eine Sitzung braucht dort auch appwriteUserId, die ein Job nicht hat. Lesefehler werden weitergereicht.
|
|
*/
|
|
export async function aboModellFuerKunde(customerId) {
|
|
if (typeof customerId !== 'string' || customerId === '') return false
|
|
// Modus off: sofort false, ohne das Kundendokument zu lesen (E-Fix M2). Alt-Kunden bekommen so keine zusätzliche Lesung und keinen neuen Fehlerpfad
|
|
// (ein transienter Lesefehler liess die Beenden-Mail beim Widerruf des Wechsels und die Team-Aktivierung scheitern).
|
|
if (neueAnsichtModus() === 'off') return false
|
|
let kunde
|
|
try {
|
|
kunde = await getDocument(config.collections.customers, customerId)
|
|
} catch (err) {
|
|
if (err.status === 404) return false
|
|
throw err
|
|
}
|
|
return neueAnsichtFuerKunde({ customerId, email: kunde.email })
|
|
}
|