Kundenansicht P6: Team-Aktivierung mit Abo-Prüfung und Abrechnung (V10), Projektstart ohne Abo nur mit Warnung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
408f7d0cc2
commit
de8b0c0fca
5 files changed
+285
-22
No files matched your search
@@ -0,0 +1,22 @@
|
||||
import { listSubscriptionsForCustomer } from './stripeBilling.js'
|
||||
|
||||
/**
|
||||
* Aufbauphase (E9, N28; Kundenansicht neu, P6): Hatte der Kunde noch nie ein Abo, weil seine Website beim Projektstart noch im Bau ist?
|
||||
* Gemeinsame Quelle fuer die Team-Aktivierung "im Projekt" (siteFeatures.adminActivate) und die beiden Tagesabgleiche (Funktionen des
|
||||
* Abo-Modells, Oeffnungszeiten live): Solange es wahr ist, bleibt eine im Projekt eingebaute Funktion in Ruhe; mit dem ersten Abo
|
||||
* gilt die Abo-Regel auch fuer 'projekt'.
|
||||
*/
|
||||
|
||||
/** Stati abgebrochener Bestellungen (Checkout nie abgeschlossen): sie zaehlen nicht als Abo. */
|
||||
const ABGEBROCHEN = new Set(['incomplete', 'incomplete_expired'])
|
||||
|
||||
/**
|
||||
* true = der Kunde hatte noch nie ein Abo: kein Abo-Dokument mit einem Status ausser incomplete und incomplete_expired (jeder andere
|
||||
* Status zaehlt, auch ein beendetes Abo). Lesefehler werden weitergereicht (fail-closed: der Aufrufer behandelt den Kunden dann nicht
|
||||
* als Aufbauphase). Eine leere oder keine String-Kunden-ID ist ein TypeError.
|
||||
*/
|
||||
export async function ohneAboBisher(customerId) {
|
||||
if (typeof customerId !== 'string' || customerId === '') throw new TypeError('ohneAboBisher: Kunden-ID erwartet')
|
||||
const abos = await listSubscriptionsForCustomer(customerId)
|
||||
return !abos.some((abo) => !ABGEBROCHEN.has(abo.status))
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { speichereZustimmung, zustimmungFuerSitzung } from './featureConsents.js
|
||||
import { aboLage, laeuft } from './aboStatus.js'
|
||||
import { berlinDateTime } from './berlinTime.js'
|
||||
import { loeschTag } from './termine/frist.js'
|
||||
import { ABRECHNUNGEN, istAboModell } from './aboModell.js'
|
||||
import { ohneAboBisher } from './aboAufbauphase.js'
|
||||
import { ABRECHNUNGS_ZEILEN, createFeatureTicket } from './ticketAdmin.js'
|
||||
|
||||
export const FEATURE_KEYS = featureKeys()
|
||||
@@ -650,6 +652,23 @@ function textformNachweisTexte(textform, fassung, key) {
|
||||
return [satz, ...items.map(({ id, text }) => ({ id, text }))]
|
||||
}
|
||||
|
||||
/** Zustimmung des Kunden in Textform als Nachweis speichern (ohne Stripe-Sitzung, IP und Browser; Konto = Mitarbeiter; Fassung = textformFassung(key)). */
|
||||
async function speichereTextformNachweis({ customerId, projectId, key, textform, by, now }) {
|
||||
const fassung = textformFassung(key)
|
||||
await speichereZustimmung({
|
||||
customerId,
|
||||
projectId,
|
||||
featureKey: key,
|
||||
stripeSessionId: '',
|
||||
fassungen: { leistungsbeschreibung: fassung, avv: fassung },
|
||||
texte: textformNachweisTexte(textform, fassung, key),
|
||||
ip: '',
|
||||
userAgent: '',
|
||||
konto: `Mitarbeiter: ${by || 'Mitarbeiter'}`,
|
||||
at: now,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Freischaltung ohne Zahlung und ohne Ticket (unlockedByPurchase bleibt false). Ausnahme: ein Terminplaner, den ein Kauf
|
||||
* freigeschaltet hat (unlockedByPurchase true), wird nur bei laufendem Wartungs-Abo aktiviert (409 sonst, Lesefehler => Fehler).
|
||||
@@ -657,28 +676,51 @@ function textformNachweisTexte(textform, fassung, key) {
|
||||
* textform (nur Terminplaner, Webshop und Buchungskalender, schon geprueft: { date, channel }): Zustimmung des Kunden in Textform. Der Nachweis
|
||||
* (featureConsents, ohne Stripe-Sitzung, IP und Browser; Konto = Mitarbeiter; Fassung = textformFassung(key)) wird VOR der Freischaltung
|
||||
* gespeichert: scheitert er, bleibt die Funktion aus. Der Aufrufer sendet danach K16 mit den Unterlagen (meldeFreigeschaltet).
|
||||
*
|
||||
* abrechnung (Kundenansicht neu, P6, V10): null = das heutige Verhalten, direkt active, kein Ticket. 'abo' | 'projekt' (nur fuer Kunden des
|
||||
* Abo-Modells, das entscheidet der Aufrufer): das Team schaltet im Abo-Modell ein. Ein Dokument aus Kauf (Bestand) bleibt wie bei null.
|
||||
* Sonst muss ein Abo die Funktion decken (aboDeckt): 409 abo_fehlt mit grund und wechsel, bevor etwas geschrieben wird; auch fuer 'projekt'
|
||||
* (E21), mit einer Ausnahme (E9, Aufbauphase): 'projekt' bei einem Kunden, der noch nie ein Abo hatte (ohneAboBisher), laeuft durch, und
|
||||
* rueckmeldung.warnung = { code: 'abo_fehlt', grund: 'kein_abo' } sagt es dem Aufrufer (rueckmeldung ist ein Objekt des Aufrufers). Ein vom
|
||||
* Team abgeschaltetes Dokument (disabledReason team) wird wie heute direkt active; sonst legt einschalten (funktionEinschalten.js) Zustand
|
||||
* setup und das Einrichtungs-Ticket an (unlockedBy und einrichtungAbrechnung setzt allein einschalten). Der Textform-Nachweis laeuft dann
|
||||
* als nachweis vor dem Zustand; K38 (Meldung) ersetzt die K16 des Aufrufers.
|
||||
*/
|
||||
export async function adminActivate(customerId, projectId, key, { by, adminNote, textform = null, now = new Date() } = {}) {
|
||||
export async function adminActivate(customerId, projectId, key, { by, adminNote, textform = null, abrechnung = null, rueckmeldung = null, now = new Date() } = {}) {
|
||||
assertKey(key)
|
||||
if (textform && !TEXTFORM_KEYS.includes(key)) throw httpError(400, 'Zustimmung in Textform gibt es nur für den Terminplaner, den Webshop und den Buchungskalender')
|
||||
if (abrechnung !== null && !ABRECHNUNGEN.includes(abrechnung)) throw httpError(400, 'Unbekannte Abrechnung')
|
||||
await assertOwnProject(customerId, projectId)
|
||||
|
||||
let direkt = abrechnung === null
|
||||
if (!direkt) {
|
||||
// Das Dokument wird nur gelesen: ensureFeatureDoc laeuft im Abo-Modell erst innerhalb von einschalten, damit eine Ablehnung kein leeres Dokument hinterlaesst
|
||||
const vorab = await getFeatureDoc(customerId, projectId, key)
|
||||
if (vorab && unlockedByVon(vorab) === 'kauf') {
|
||||
direkt = true // Bestand: wie ohne Abrechnung, einschliesslich der Abo-Pruefung des Terminplaners unten
|
||||
} else {
|
||||
// Dynamischer Import: funktionEinschalten.js importiert dieses Modul
|
||||
const { einschalten, pruefeAboDeckung } = await import('./funktionEinschalten.js')
|
||||
try {
|
||||
await pruefeAboDeckung(customerId, key, now)
|
||||
} catch (err) {
|
||||
const aufbauphase = abrechnung === 'projekt' && err?.code === 'abo_fehlt' && err.body?.grund === 'kein_abo' && (await ohneAboBisher(customerId))
|
||||
if (!aufbauphase) throw err
|
||||
if (rueckmeldung && typeof rueckmeldung === 'object') rueckmeldung.warnung = { code: 'abo_fehlt', grund: 'kein_abo' }
|
||||
}
|
||||
if (vorab?.state === 'disabled' && vorab.disabledReason === 'team') {
|
||||
direkt = true // vom Team abgeschaltet: das Team schaltet wie heute direkt wieder ein
|
||||
} else {
|
||||
const nachweis = textform ? () => speichereTextformNachweis({ customerId, projectId, key, textform, by, now }) : null
|
||||
const { doc } = await einschalten({ customerId, projectId, key, by: 'team', abrechnung, staff: { name: by, adminNote }, nachweis, now })
|
||||
return doc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const doc = await ensureFeatureDoc(customerId, projectId, key)
|
||||
if (key === 'termine' && doc.unlockedByPurchase === true) await assertAboLaeuftFuerAktivierung(customerId)
|
||||
if (textform) {
|
||||
const fassung = textformFassung(key)
|
||||
await speichereZustimmung({
|
||||
customerId,
|
||||
projectId,
|
||||
featureKey: key,
|
||||
stripeSessionId: '',
|
||||
fassungen: { leistungsbeschreibung: fassung, avv: fassung },
|
||||
texte: textformNachweisTexte(textform, fassung, key),
|
||||
ip: '',
|
||||
userAgent: '',
|
||||
konto: `Mitarbeiter: ${by || 'Mitarbeiter'}`,
|
||||
at: now,
|
||||
})
|
||||
}
|
||||
if (textform) await speichereTextformNachweis({ customerId, projectId, key, textform, by, now })
|
||||
return activate(doc.$id, { by, adminNote, ...(key === 'zeiten' ? { extra: ZEITEN_AKTIVIERUNG_FELDER } : {}) })
|
||||
}
|
||||
|
||||
@@ -948,6 +990,26 @@ async function ersetzeGeloeschtesTicket(doc, note) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticket nachlegen (a) fuer ein Dokument des Abo-Modells (istAboModell, setup ohne ticketId; Oeffnungszeiten live hat ticketNachHolen):
|
||||
* es gibt keine Zahlung bei Stripe, die Heilung von einschalten (funktionEinschalten.js, by team) legt nur das Ticket an, mit der am
|
||||
* Dokument gespeicherten Abrechnung. Kein Stripe-Aufruf. Die Notiz des Teams wird danach am Dokument vermerkt.
|
||||
*/
|
||||
async function ticketNachAbo(doc, note) {
|
||||
// Dynamischer Import: funktionEinschalten.js importiert dieses Modul
|
||||
const { einschalten } = await import('./funktionEinschalten.js')
|
||||
const { doc: geheilt } = await einschalten({
|
||||
customerId: doc.customerId,
|
||||
projectId: doc.projectId,
|
||||
key: doc.featureKey,
|
||||
by: 'team',
|
||||
abrechnung: doc.einrichtungAbrechnung,
|
||||
staff: { name: 'Mitarbeiter' },
|
||||
})
|
||||
const updated = note ? await updateDocument(config.collections.portalFeatures, geheilt.$id, { adminNote: note }) : geheilt
|
||||
return { doc: updated, created: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* "Ticket nachlegen": nur fuer eine Funktion in Einrichtung ('setup'; sonst 409, ohne Dokument 409).
|
||||
* (a) ticketId leer: Zahlung bei Stripe pruefen (nicht bezahlt 409, Stripe nicht erreichbar 503) und den Weg des Webhooks gehen,
|
||||
@@ -966,7 +1028,11 @@ export async function ticketNachlegen({ customerId, projectId, key, by, adminNot
|
||||
return withLocks([`feature-ticket:${vorab.$id}`], async () => {
|
||||
const doc = await frischesDokument(vorab, key)
|
||||
if (doc.state !== 'setup') throw httpError(409, TICKET_NUR_SETUP)
|
||||
if (!doc.ticketId) return key === 'zeiten' && unlockedByVon(doc) !== 'kauf' ? ticketNachHolen(doc, note) : ticketNachZahlung(doc, note)
|
||||
if (!doc.ticketId) {
|
||||
if (key === 'zeiten' && unlockedByVon(doc) !== 'kauf') return ticketNachHolen(doc, note)
|
||||
if (key !== 'zeiten' && istAboModell(doc)) return ticketNachAbo(doc, note)
|
||||
return ticketNachZahlung(doc, note)
|
||||
}
|
||||
if (await ticketBesteht(doc.ticketId)) return { doc, created: false }
|
||||
return ersetzeGeloeschtesTicket(doc, note)
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user