diff --git a/server/routes/shop.js b/server/routes/shop.js index bc5811f..f05603d 100644 --- a/server/routes/shop.js +++ b/server/routes/shop.js @@ -8,6 +8,8 @@ import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.j import { createWriteLimiter } from '../services/writeLimiter.js' import { anfrageAnsicht, ensureShopConfig, leseShopConfig, stelleAnfrage, zieheZurueck } from '../services/shop/anfrage.js' import { shopFuerKunde } from '../services/shop/schalter.js' +import { kundenartWirksam } from '../services/kundenart.js' +import { neueAnsicht } from '../services/kundenansicht/schalter.js' import { kontoAnlegen, kontoStatus, onboardingLink, statusAusKonto } from '../services/shop/connect.js' import { bestellungKunde, bestellungLesen, csv as bestellungenCsv, erstatte, listeBestellungen, setzeStatus } from '../services/shop/bestellungen.js' import { listeWiderrufe, markiereBearbeitet } from '../services/shop/widerrufe.js' @@ -210,12 +212,25 @@ router.get('/status', requireSession, route(async (req, res) => { res.json(antwort) })) +/** Kundenart des Kundendokuments (leer oder unbekannt = privat) - nur der Server entscheidet, nie der Client; ein unbekannter Kunde ist privat. */ +async function kundenartDesKunden(customerId) { + try { + return kundenartWirksam(await getDocument(config.collections.customers, customerId)) + } catch (err) { + if (err?.status === 404) return 'privat' + throw err + } +} + router.post('/anfrage', requireSession, nurKunde, limitAnfrage, route(async (req, res) => { + const customerId = getSessionCustomerId(req) const anfrage = await stelleAnfrage({ - customerId: getSessionCustomerId(req), + customerId, projectId: projectIdOf(req), eingabe: req.body?.anfrage, person: person(req), + // Abo-Modell (neue Ansicht): Unternehmer aus der Kundenart, privat nur_betrieb; sonst unveraendert (undefined) + kundenart: neueAnsicht(req) ? await kundenartDesKunden(customerId) : undefined, }) res.json({ anfrage }) })) diff --git a/server/services/shop/anfrage.js b/server/services/shop/anfrage.js index 81412ac..788f406 100644 --- a/server/services/shop/anfrage.js +++ b/server/services/shop/anfrage.js @@ -4,6 +4,7 @@ import { withLocks } from '../keyedLock.js' import { sendAdminNotice, sendCustomerMail } from '../mailer.js' import { assertOwnProject, customerOf, ensureFeatureDoc, getFeatureDoc } from '../siteFeatures.js' import { shopFuerKunde } from './schalter.js' +import { NUR_BETRIEB_TEXT } from '../kundenart.js' /** * Webshop-Anfrage (Spezifikation 2026-10-05-webshop-design.md, Abschnitt 4 "Auf Anfrage"). Der Kunde stellt eine Anfrage, das Team @@ -163,15 +164,21 @@ async function pruefeZugang({ customerId, projectId, person, neu }) { } /** - * Anfrage stellen. Reihenfolge: Projekt (403/409 archiviert), Schalter (409), Eingabe (400 mit `fehler`), dann unter Sperre - * je Projekt: Funktion muss `off` sein (409), Anfrage-Status '' | abgelehnt | zurueckgezogen (409). Speichert JSON, Status `offen` - * und anfrageAt, loescht eine fruehere Entscheidung und schickt T21 ans Team (ein Fehler dabei wird nur geloggt). + * Anfrage stellen. Reihenfolge: Projekt (403/409 archiviert), Schalter (409), Kundenart (409 nur_betrieb, nur im Abo-Modell), Eingabe + * (400 mit `fehler`), dann unter Sperre je Projekt: Funktion muss `off` sein (409), Anfrage-Status '' | abgelehnt | zurueckgezogen (409). + * Speichert JSON, Status `offen` und anfrageAt, loescht eine fruehere Entscheidung und schickt T21 ans Team (ein Fehler dabei wird nur geloggt). + * kundenart (nur mit neuer Ansicht, kundenartWirksam des Kundendokuments; ohne Angabe wie bisher): nur Betriebe ('unternehmen') duerfen + * anfragen, jeder andere Wert ist 409 nur_betrieb. Fuer Betriebe setzt der Server `unternehmer` auf true (ein Wert des Clients zaehlt + * nicht); die gespeicherte Anfrage traegt zusaetzlich kundenart. * @returns {Promise} anfrageAnsicht des neuen Standes */ -export async function stelleAnfrage({ customerId, projectId, eingabe, person = {}, now = new Date() }) { +export async function stelleAnfrage({ customerId, projectId, eingabe, person = {}, now = new Date(), kundenart }) { const project = await pruefeZugang({ customerId, projectId, person, neu: true }) - const { anfrage, fehler } = normalisiereAnfrage(eingabe) + const abo = kundenart !== undefined + if (abo && kundenart !== 'unternehmen') throw stop(409, NUR_BETRIEB_TEXT, 'nur_betrieb') + const { anfrage: geprueft, fehler } = normalisiereAnfrage(abo && istObjekt(eingabe) ? { ...eingabe, unternehmer: true } : eingabe) if (fehler) throw Object.assign(stop(400, Object.values(fehler)[0]), { fehler }) + const anfrage = abo ? { ...geprueft, kundenart } : geprueft const gespeichert = await withLocks([`shop-anfrage:${projectId}`], async () => { const feature = await ensureFeatureDoc(customerId, projectId, 'shop') diff --git a/test/aboAnfragen.test.js b/test/aboAnfragen.test.js new file mode 100644 index 0000000..9f74ad4 --- /dev/null +++ b/test/aboAnfragen.test.js @@ -0,0 +1,115 @@ +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 { mailSink } from './support/mailSink.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' + +// Anfragen im Abo-Modell (P5 Task 12): die Shop-Anfrage nimmt Unternehmer aus der Kundenart (nur Betriebe), der Buchungskalender bleibt offen +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + shopConfig: schemaKeys('shopConfig'), + ukEinstellungen: schemaKeys('ukEinstellungen'), + featureConsents: schemaKeys('featureConsents'), + }, + datetimes: { ukEinstellungen: ['rechnungAuftragAt', 'anfrageAt', 'anfrageEntschiedenAt', 'icalFehlerMailAt'] }, +}) +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 { customerCookie, customers, seedFixtures } = await import('./support/fixtures.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { NUR_BETRIEB_TEXT } = await import('../server/services/kundenart.js') +const { default: shopRoutes, resetShopRoutesForTests } = await import('../server/routes/shop.js') +const { default: ukRoutes, resetUnterkunftRoutesForTests } = await import('../server/routes/unterkunft.js') + +const app = await startTestApp({ '/api/shop': shopRoutes, '/api/unterkunft': ukRoutes }) +const sink = mailSink() +setMailTransportForTests(sink) +const vorher = { + neu: { mode: config.kundenansichtNeu.mode, pilotEmails: [...config.kundenansichtNeu.pilotEmails] }, + shop: { mode: config.shop.mode, pilotEmails: [...config.shop.pilotEmails] }, + unterkunft: { mode: config.unterkunft.mode, pilotEmails: [...config.unterkunft.pilotEmails] }, +} +after(() => { + Object.assign(config.kundenansichtNeu, vorher.neu) + Object.assign(config.shop, vorher.shop) + Object.assign(config.unterkunft, vorher.unterkunft) + setMailTransportForTests(null) + return Promise.all([app.close(), aw.close(), st.close()]) +}) + +const c1 = customerCookie('c1') +const anfrage = (extra = {}) => ({ warenart: 'waren', beschreibung: 'Backwaren aus eigener Herstellung', artikelzahl: 'bis20', versand: false, abholung: true, keineAusgeschlossenen: true, ...extra }) +const senden = (a, cookie = c1) => post('/api/shop/anfrage', { projectId: 'p1', anfrage: a }, { cookie }) +// 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', 'shopConfig', 'ukEinstellungen', 'featureConsents', 'websiteProjects') + seedFixtures(aw) + aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', archived: false }) + config.kundenansichtNeu.mode = 'pilot' + config.kundenansichtNeu.pilotEmails = ['k@example.invalid', 'test@example.invalid'] + config.shop.mode = config.unterkunft.mode = 'on' + resetShopRoutesForTests() + resetUnterkunftRoutesForTests() + sink.sent.length = 0 +}) + +test('privat (Kundenart leer): 409 nur_betrieb, auch mit gefälschtem unternehmer: true; nichts geschrieben, keine Mail', async () => { + const r = await senden(anfrage({ unternehmer: true })) + assert.deepEqual([r.status, r.body.code, r.body.error], [409, 'nur_betrieb', NUR_BETRIEB_TEXT]) + assert.deepEqual([aw.docs('shopConfig').size, aw.docs('portalFeatures').size, sink.sent.length], [0, 0, 0]) + for (const kundenart of ['', 'kaputt', 'Unternehmen', 'privat']) { + aw.seed('customers', { ...customers.c1, kundenart }) + const x = await senden(anfrage({ unternehmer: true, kundenart: 'unternehmen' })) + assert.deepEqual([x.status, x.body.code], [409, 'nur_betrieb'], kundenart) + } +}) + +test('Betrieb: unternehmer kommt vom Server, Anfrage speichert die Kundenart', async () => { + aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' }) + const r = await senden(anfrage()) + assert.deepEqual([r.status, r.body.anfrage.status], [200, 'offen']) + assert.deepEqual([r.body.anfrage.anfrage.unternehmer, r.body.anfrage.anfrage.kundenart], [true, 'unternehmen']) + assert.equal(sink.sent.length, 1, 'T21 ans Team') + await leere('shopConfig', 'portalFeatures') + const falsch = await senden(anfrage({ unternehmer: false })) // der Client-Wert zählt nicht + assert.deepEqual([falsch.status, falsch.body.anfrage.anfrage.unternehmer], [200, true]) + await leere('shopConfig', 'portalFeatures') + const ohne = await senden(anfrage({ keineAusgeschlossenen: false })) + assert.deepEqual([ohne.status, Object.keys(ohne.body.fehler)], [400, ['keineAusgeschlossenen']]) +}) + +test('alte Ansicht unverändert: unternehmer bleibt Pflicht, privat darf anfragen', async () => { + config.kundenansichtNeu.mode = 'off' + const r = await senden(anfrage()) + assert.deepEqual([r.status, Object.keys(r.body.fehler)], [400, ['unternehmer']]) + const ok = await senden(anfrage({ unternehmer: true })) + assert.equal(ok.status, 200) + assert.equal(Object.hasOwn(ok.body.anfrage.anfrage, 'kundenart'), false, 'ohne neue Ansicht keine Kundenart in der Anfrage') +}) + +test('Reihenfolge: Projekt und Schalter vor der Kundenart, die Kundenart vor der Eingabe', async () => { + assert.equal((await post('/api/shop/anfrage', { projectId: 'pfremd', anfrage: anfrage() }, { cookie: c1 })).status, 403) + config.shop.mode = 'off' + assert.equal((await senden(anfrage())).body.code, 'nicht_verfuegbar') + config.shop.mode = 'on' + const leer = await senden({}) // ungültige Eingabe, aber privat: nur_betrieb kommt zuerst + assert.deepEqual([leer.status, leer.body.code], [409, 'nur_betrieb']) + aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' }) + assert.equal((await senden({})).status, 400) +}) + +test('Buchungskalender: privat darf in der neuen Ansicht anfragen (nurBetrieb false)', async () => { + const r = await post('/api/unterkunft/anfrage-funktion', { projectId: 'p1', art: 'ferienwohnung', einheiten: 2, portale: ['booking'] }, { cookie: c1 }) + assert.equal(r.status, 200, JSON.stringify(r.body)) + assert.equal(r.body.anfrage.status, 'offen') +})