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
@@ -1,6 +1,8 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../../config.js'
|
||||
import { aboMemo } from '../../services/aboStatus.js'
|
||||
import { aboModellFuerKunde } from '../../services/aboFunktionen.js'
|
||||
import { ABRECHNUNGEN } from '../../services/aboModell.js'
|
||||
import { FUNKTION_CHANNELS, validateTextform } from '../../services/analyse.js'
|
||||
import { getDocument, updateDocument } from '../../services/appwriteAdmin.js'
|
||||
import { featureConfig, featureHaken } from '../../services/featureRegistry.js'
|
||||
@@ -46,7 +48,9 @@ const adminName = (req) => req.session.adminName || req.session.name || 'Admin'
|
||||
function sendError(res, err) {
|
||||
const status = err.status >= 400 && err.status < 600 ? err.status : 500
|
||||
if (status >= 500) console.error('[funktionen-admin]', err.message)
|
||||
return res.status(status).json({ error: err.message || 'Interner Serverfehler' })
|
||||
// err.body (z. B. code, grund, wechsel der 409 abo_fehlt) geht flach in die Antwort; error bleibt der Text der Meldung
|
||||
const zusatz = err.body && typeof err.body === 'object' && !Array.isArray(err.body) ? err.body : {}
|
||||
return res.status(status).json({ ...zusatz, error: err.message || 'Interner Serverfehler' })
|
||||
}
|
||||
|
||||
/** Neuester Eintrag zuerst; aeltere Eintraege fallen vorn heraus, sobald 300 Zeichen voll sind. */
|
||||
@@ -353,9 +357,23 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
|
||||
}
|
||||
await assertOwnProject(customerId, projectId)
|
||||
const by = adminName(req)
|
||||
// Abo-Modell (Kundenansicht neu, V10): nur fuer Kunden der neuen Ansicht zaehlt abrechnung (Vorgabe 'abo'); sonst wird sie ignoriert und
|
||||
// alles laeuft wie heute. Eine ungueltige Abrechnung ist 400, bevor etwas geschrieben wird.
|
||||
const modell = action === 'activate' && (await aboModellFuerKunde(customerId))
|
||||
let abrechnung = null
|
||||
if (modell) {
|
||||
abrechnung = req.body?.abrechnung === undefined ? 'abo' : req.body.abrechnung
|
||||
if (!ABRECHNUNGEN.includes(abrechnung)) return res.status(400).json({ error: 'Unbekannte Abrechnung' })
|
||||
}
|
||||
const rueckmeldung = {} // adminActivate meldet hier die Warnung der Aufbauphase (abo_fehlt)
|
||||
let freigeschaltet = null // Terminplaner oder Webshop mit Zustimmung in Textform: K16 mit den Unterlagen nach der Sperre
|
||||
let ersatzTicket
|
||||
if (key === 'zeiten' && action === 'activate') {
|
||||
if (key === 'zeiten' && action === 'activate' && modell) {
|
||||
// Wie zeitenAktivieren ohne feature-admin: Neubau und Ticket laufen in einschalten, die Sperre wuerde dem Team jede Aktion am Projekt halten
|
||||
const bisher = await getFeatureDoc(customerId, projectId, key)
|
||||
const adminNote = composeAdminNote(bisher?.adminNote, { by, action, note })
|
||||
await adminActivate(customerId, projectId, key, { by, adminNote, abrechnung, rueckmeldung })
|
||||
} else if (key === 'zeiten' && action === 'activate') {
|
||||
await zeitenAktivieren({ customerId, projectId, by, note })
|
||||
} else if (key === 'zeiten' && action === 'disable') {
|
||||
ersatzTicket = await zeitenAbschalten({ customerId, projectId, by, note })
|
||||
@@ -365,9 +383,14 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
|
||||
const adminNote = composeAdminNote(doc?.adminNote, { by, action, note })
|
||||
if (action === 'activate') {
|
||||
const textform = await textformFuerAktivierung({ body: req.body, customerId, key, doc })
|
||||
const attachments = textform ? await unterlagenAlsAnhang(key) : []
|
||||
const aktiviert = await adminActivate(customerId, projectId, key, { by, adminNote, textform })
|
||||
if (textform) freigeschaltet = { doc: aktiviert, attachments }
|
||||
if (modell) {
|
||||
// Die Unterlagen gehen mit K38 (aboFunktionenMeldung.js), nicht mit K16
|
||||
await adminActivate(customerId, projectId, key, { by, adminNote, textform, abrechnung, rueckmeldung })
|
||||
} else {
|
||||
const attachments = textform ? await unterlagenAlsAnhang(key) : []
|
||||
const aktiviert = await adminActivate(customerId, projectId, key, { by, adminNote, textform })
|
||||
if (textform) freigeschaltet = { doc: aktiviert, attachments }
|
||||
}
|
||||
} else if (action === 'abnahme') {
|
||||
await freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote })
|
||||
} else if (action === 'ticket') {
|
||||
@@ -392,6 +415,8 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
|
||||
// Ergebnis des Ersatz-Tickets beim Abschalten der Zeiten (angelegt | aktuell | fehler), damit ein Fehler im Panel sichtbar wird
|
||||
...(ersatzTicket ? { ersatzTicket } : {}),
|
||||
...(warnung ? { warnung } : {}),
|
||||
// Aufbauphase (E9): im Projekt eingebaut, obwohl noch kein Abo laeuft; der String unterscheidet sich vom alten warnung: true (K16 ging nicht raus)
|
||||
...(rueckmeldung.warnung ? { warnung: rueckmeldung.warnung.code, grund: rueckmeldung.warnung.grund } : {}),
|
||||
})
|
||||
} catch (err) {
|
||||
sendError(res, err)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
|
||||
// Aufbauphase (E9, N28): ohneAboBisher = der Kunde hatte noch nie ein Abo. Gemeinsame Quelle für die Team-Aktivierung und die Abgleiche.
|
||||
const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions') } })
|
||||
const st = await startFakeStripe()
|
||||
after(() => Promise.all([aw.close(), st.close()]))
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { subscriptionDoc, wartungDoc, hostingDoc } = await import('./support/fixtures.js')
|
||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { ohneAboBisher } = await import('../server/services/aboAufbauphase.js')
|
||||
|
||||
// aw.docs() liefert eine Kopie: geloescht wird ueber die API
|
||||
const loescheAbos = async () => {
|
||||
for (const id of aw.docs('subscriptions').keys()) await deleteDocument('subscriptions', id)
|
||||
}
|
||||
beforeEach(loescheAbos)
|
||||
|
||||
test('ohneAboBisher: wahr ohne Abo-Dokument und bei abgebrochener Bestellung, falsch bei jedem anderen Status (auch beendet), je Kunde', async () => {
|
||||
assert.equal(await ohneAboBisher('c1'), true)
|
||||
for (const status of ['incomplete', 'incomplete_expired']) { await loescheAbos(); aw.seed('subscriptions', subscriptionDoc('sub_x', { ...wartungDoc, status })); assert.equal(await ohneAboBisher('c1'), true, status) }
|
||||
for (const status of ['active', 'trialing', 'past_due', 'canceled', 'unpaid', 'paused']) { await loescheAbos(); aw.seed('subscriptions', subscriptionDoc('sub_x', { ...wartungDoc, status })); assert.equal(await ohneAboBisher('c1'), false, status) }
|
||||
await loescheAbos(); aw.seed('subscriptions', subscriptionDoc('sub_h', hostingDoc)) // Hosting zählt als Abo; das Dokument gehört c1
|
||||
assert.deepEqual([await ohneAboBisher('c1'), await ohneAboBisher('c2')], [false, true])
|
||||
})
|
||||
test('ohneAboBisher: Lesefehler werden weitergereicht, leere oder falsche Kunden-ID ist ein TypeError', async () => {
|
||||
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 }); await assert.rejects(() => ohneAboBisher('c1'))
|
||||
for (const id of ['', undefined, null, 5]) await assert.rejects(() => ohneAboBisher(id), TypeError)
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { test, after, beforeEach, afterEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
|
||||
// Team-Aktivierung im Abo-Modell (P6 Task 7, V10): POST /api/team/funktionen/:customerId/:key mit abrechnung. Pilotkunde c1 (Wartung),
|
||||
// c2 außerhalb der neuen Ansicht. Aufbauphase (E9, N28): projekt ohne jedes bisherige Abo ergibt 200 mit warnung abo_fehlt.
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
portalFeatures: schemaKeys('portalFeatures'),
|
||||
subscriptions: schemaKeys('subscriptions'),
|
||||
contractEvents: schemaKeys('contractEvents'),
|
||||
featureConsents: schemaKeys('featureConsents'),
|
||||
},
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { startTestApp, post } = await import('./support/testApp.js')
|
||||
const { hostingDoc, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
|
||||
const { default: teamRoutes } = await import('../server/routes/team/index.js')
|
||||
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { ABRECHNUNGS_ZEILEN } = await import('../server/services/ticketAdmin.js')
|
||||
const app = await startTestApp({ '/api/team': teamRoutes })
|
||||
const mails = mailSink()
|
||||
setMailTransportForTests(mails)
|
||||
const MODUS_SHOP = { mode: config.shop.mode, pilotEmails: [...config.shop.pilotEmails] }
|
||||
const MODUS_NEU = { mode: config.kundenansichtNeu.mode, pilotEmails: [...config.kundenansichtNeu.pilotEmails] }
|
||||
after(() => {
|
||||
config.shop.mode = MODUS_SHOP.mode
|
||||
config.shop.pilotEmails = MODUS_SHOP.pilotEmails
|
||||
config.kundenansichtNeu.mode = MODUS_NEU.mode
|
||||
config.kundenansichtNeu.pilotEmails = MODUS_NEU.pilotEmails
|
||||
setMailTransportForTests(null)
|
||||
return Promise.all([app.close(), aw.close(), st.close()])
|
||||
})
|
||||
|
||||
const auth = { headers: { authorization: 'Bearer jwt-staff' } }
|
||||
const go = (cid, key, body = {}, pid = 'p1') => post(`/api/team/funktionen/${cid}/${key}`, { projectId: pid, action: 'activate', ...body }, auth)
|
||||
const feat = (key, pid = 'p1') => aw.docs('portalFeatures').get(`f_${pid}_${key}`)
|
||||
const nichts = () => assert.deepEqual([aw.docs('portalFeatures').size, aw.docs('workorders').size, aw.docs('featureConsents').size, mails.sent.length], [0, 0, 0, 0])
|
||||
// aw.docs() liefert eine Kopie: Dokumente werden ueber die API geloescht
|
||||
const leere = async (...collections) => {
|
||||
for (const col of collections) for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await leere('portalFeatures', 'workorders', 'featureConsents', 'subscriptions', 'websiteProjects')
|
||||
mails.sent.length = 0
|
||||
seedFixtures(aw)
|
||||
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
|
||||
aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc))
|
||||
for (const [id, customerId] of [['p1', 'c1'], ['p2', 'c1'], ['p3', 'c2']]) {
|
||||
aw.seed('websiteProjects', { $id: id, customerId, projectName: `Projekt ${id}`, subdomain: id, repoFullName: `JUSN/${id}`, previewMode: 'auto', previewDetected: 'static' })
|
||||
}
|
||||
config.kundenansichtNeu.mode = 'pilot'
|
||||
config.kundenansichtNeu.pilotEmails = ['k@example.invalid']
|
||||
mock.method(console, 'error', () => {})
|
||||
mock.method(console, 'warn', () => {})
|
||||
mock.method(console, 'log', () => {})
|
||||
})
|
||||
afterEach(() => mock.restoreAll())
|
||||
|
||||
test('Pilotkunde mit Wartung: setup, unlockedBy abo, Ticket mit Abo-Zeile, K38, kein T24; im Projekt: projekt', async () => {
|
||||
const r = await go('c1', 'news'); assert.equal(r.status, 200, JSON.stringify(r.body)); assert.equal(r.body.warnung, undefined) // Abo deckt: keine Warnung
|
||||
const d = feat('news'); assert.deepEqual([d.state, d.unlockedBy, d.einrichtungAbrechnung, d.unlockedByPurchase], ['setup', 'abo', 'abo', false])
|
||||
const t = aw.docs('workorders').get(d.ticketId); assert.deepEqual([t.einrichtungAbrechnung, t.details.endsWith(ABRECHNUNGS_ZEILEN.abo)], ['abo', true])
|
||||
assert.deepEqual([mails.sent.filter((m) => /^Deine Funktion wird eingerichtet/.test(m.subject)).length, mails.sent.filter((m) => m.subject.startsWith('[Abo]')).length], [1, 0])
|
||||
assert.equal((await go('c1', 'shop', { abrechnung: 'projekt' })).status, 200); const s = feat('shop'); assert.deepEqual([s.unlockedBy, s.einrichtungAbrechnung], ['projekt', 'projekt'])
|
||||
assert.ok(aw.docs('workorders').get(s.ticketId).details.endsWith(ABRECHNUNGS_ZEILEN.projekt))
|
||||
})
|
||||
test('kein deckendes Abo: 409 abo_fehlt mit Grund, nichts geschrieben (abo immer; projekt, sobald der Kunde ein Abo hat oder hatte); ungültige Abrechnung 400', async () => {
|
||||
for (const [abo, grund, wechsel] of [[hostingDoc, 'falsches_abo', true], [{ ...wartungDoc, cancelAtPeriodEnd: true }, 'gekuendigt', false], [{ ...wartungDoc, status: 'canceled' }, 'kein_abo', false]]) {
|
||||
await leere('subscriptions'); aw.seed('subscriptions', subscriptionDoc('sub_h', abo))
|
||||
for (const abrechnung of ['abo', 'projekt']) { const r = await go('c1', 'termine', { abrechnung }); assert.deepEqual([r.status, r.body.code, r.body.grund, r.body.wechsel, typeof r.body.error], [409, 'abo_fehlt', grund, wechsel, 'string'], `${grund} ${abrechnung}`) }
|
||||
nichts()
|
||||
}
|
||||
await leere('subscriptions') // noch nie ein Abo: aus dem Abo ist das Stundenkonto nötig, also hart
|
||||
const ohne = await go('c1', 'termine'); assert.deepEqual([ohne.status, ohne.body.code, ohne.body.grund], [409, 'abo_fehlt', 'kein_abo']); nichts()
|
||||
assert.equal((await go('c1', 'news', { abrechnung: 'frei' })).status, 400); nichts()
|
||||
})
|
||||
test('Projektstart ohne jedes Abo (E9, Aufbauphase): 200 mit warnung abo_fehlt, Funktion in setup mit Ticket; abgebrochene Bestellung zählt nicht; mit deckendem Abo keine Warnung', async () => {
|
||||
await leere('subscriptions'); aw.seed('subscriptions', subscriptionDoc('sub_i', { ...wartungDoc, status: 'incomplete' }))
|
||||
const r = await go('c1', 'shop', { abrechnung: 'projekt' }); assert.equal(r.status, 200, JSON.stringify(r.body)); assert.deepEqual([r.body.warnung, r.body.grund], ['abo_fehlt', 'kein_abo'])
|
||||
const d = feat('shop'); assert.deepEqual([d.state, d.unlockedBy, d.einrichtungAbrechnung], ['setup', 'projekt', 'projekt']); assert.ok(aw.docs('workorders').get(d.ticketId))
|
||||
assert.equal(mails.sent.filter((m) => /^Deine Funktion wird eingerichtet/.test(m.subject)).length, 1) // K38 mit dem Satz zur Bedingung (Task 3)
|
||||
await leere('subscriptions'); const z = await go('c1', 'news', { abrechnung: 'projekt' }, 'p2'); assert.deepEqual([z.status, z.body.warnung], [200, 'abo_fehlt'])
|
||||
aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc)); const ok = await go('c1', 'news', { abrechnung: 'projekt' }); assert.deepEqual([ok.status, ok.body.warnung, ok.body.grund], [200, undefined, undefined])
|
||||
})
|
||||
test('Kunde außerhalb der neuen Ansicht: wie heute direkt aktiv, abrechnung im Body wird ignoriert', async () => {
|
||||
const r = await go('c2', 'news', { abrechnung: 'projekt' }, 'p3'); assert.equal(r.status, 200)
|
||||
const d = feat('news', 'p3'); assert.deepEqual([d.state, d.enabled, d.ticketId, d.unlockedBy, d.einrichtungAbrechnung], ['active', true, undefined, undefined, undefined]); assert.equal(aw.docs('workorders').size, 0)
|
||||
config.kundenansichtNeu.mode = 'off'; await leere('portalFeatures'); assert.equal(feat('news'), undefined)
|
||||
assert.equal((await go('c1', 'news')).status, 200); assert.equal(feat('news').state, 'active'); config.kundenansichtNeu.mode = 'pilot'
|
||||
})
|
||||
test('Bestand und vom Team abgeschaltet: kauf wie heute, disabled/team direkt aktiv (mit Abo), sonst 409', async () => {
|
||||
aw.seed('portalFeatures', { $id: 'f_p2_termine', customerId: 'c1', projectId: 'p2', featureKey: 'termine', state: 'disabled', disabledReason: 'abo_ende', unlockedBy: 'kauf', unlockedByPurchase: true })
|
||||
await leere('subscriptions'); const alt = await go('c1', 'termine', {}, 'p2'); assert.deepEqual([alt.status, alt.body.code, /Wartungs-Abo des Kunden läuft nicht/.test(alt.body.error)], [409, undefined, true])
|
||||
aw.seed('portalFeatures', { $id: 'f_p1_shop', customerId: 'c1', projectId: 'p1', featureKey: 'shop', state: 'disabled', disabledReason: 'team', unlockedBy: 'abo', einrichtungAbrechnung: 'abo', activatedAt: '2026-10-01T09:00:00.000Z' })
|
||||
assert.equal((await go('c1', 'shop')).body.code, 'abo_fehlt'); aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc))
|
||||
assert.equal((await go('c1', 'shop')).status, 200); assert.deepEqual([feat('shop').state, aw.docs('workorders').size], ['active', 0])
|
||||
})
|
||||
test('Textform: Nachweis vor dem Zustand, K38 mit PDF-Anhängen, kein K16; Aktion ticket legt das Ticket ohne Stripe nach', async () => {
|
||||
config.shop.mode = 'on'; config.shop.pilotEmails = []
|
||||
const text = { textformDate: '2026-10-10', textformChannel: 'whatsapp' }
|
||||
aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500 }) // Nachweis scheitert: Funktion bleibt aus, kein Ticket, keine Mail
|
||||
assert.ok((await go('c1', 'shop', text)).status >= 500); assert.deepEqual([feat('shop')?.state ?? 'off', aw.docs('workorders').size, mails.sent.length], ['off', 0, 0])
|
||||
const r = await go('c1', 'shop', text); assert.equal(r.status, 200, JSON.stringify(r.body))
|
||||
const n = [...aw.docs('featureConsents').values()].find((c) => c.featureKey === 'shop'); assert.match(n.konto, /^Mitarbeiter: /)
|
||||
assert.ok(mails.sent.find((m) => /^Deine Funktion wird eingerichtet/.test(m.subject)).attachments.length >= 2); assert.equal(mails.sent.filter((m) => /ist bereit/.test(m.subject)).length, 0)
|
||||
await deleteDocument('workorders', feat('shop').ticketId); await updateDocument('portalFeatures', 'f_p1_shop', { ticketId: '' }) // Ticket weg: nachlegen heilt über einschalten
|
||||
const calls = st.calls.length; const t = await go('c1', 'shop', { action: 'ticket' }); assert.equal(t.status, 200, JSON.stringify(t.body)); assert.ok(feat('shop').ticketId); assert.equal(st.calls.length, calls)
|
||||
})
|
||||
Reference in new issue
Block a user