Files
ClaudeandClaude Opus 5.5 1f56b86791 Shop Review B I-4: Steuerart und Lieferzeit ohne Vorbelegung
kleinunternehmer ist ab jetzt null (nicht gewaehlt), lieferzeit leer. istVollstaendig verlangt eine
bewusste Wahl (true oder false), der Shop kann sonst nicht geoeffnet werden. Im Reiter
Einstellungen gibt es zwei Pflicht-Radiobuttons ohne Vorauswahl; Preise, Artikel und Muster
rechnen bei null wie bei Regelbesteuerung. Tests und Fixtures angepasst.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:50:54 +00:00

258 lines
12 KiB
JavaScript

import { config } from '../../config.js'
import { getDocument, listDocuments, Query } from '../appwriteAdmin.js'
import { getFeatureDoc } from '../siteFeatures.js'
import { istVollstaendig, ladeEinstellungen, nachSpeichern } from './einstellungen.js'
import { listeProdukte, produktOeffentlich } from './produkte.js'
import { rechtstexteHash, vollstaendig } from './rechtstexte.js'
/**
* Kontext eines Shops (Plan 2026-10-05-webshop.md, Task C6; Spezifikation 4.6) und seine Bereitschaft zum Verkaufen.
*
* ctx = { doc, shop, project }
* doc portalFeatures-Dokument (featureKey 'shop')
* shop geparstes shopConfig-Dokument (ladeShop) oder null (noch keines angelegt)
* project websiteProjects-Dokument oder null
* Ein ctx aus shopPerSiteKey ist nur zum Lesen: innerhalb von 30 Sekunden kommt dasselbe Objekt zurueck.
*
* shopConfig.rechtstexte ist ein JSON-Text { entwurf: {agb, widerrufsbelehrung, widerrufsformular, impressumUrl, datenschutzUrl},
* bestaetigt: null | { texte: {dieselben fuenf}, hash, at, von } }. Der Entwurf gehoert dem Haendler, die Kaeufer sehen
* immer die zuletzt bestaetigte Fassung (oeffentlicheRechtstexte). Kaputt oder alt => leerer Entwurf, nichts bestaetigt.
*/
const FUNKTION = 'shop'
const SITE_KEY_RE = /^[a-z2-7]{16}$/
const CACHE_MS = 30 * 1000
const CACHE_MAX = 10000
const TEXT_FELDER = ['agb', 'widerrufsbelehrung', 'widerrufsformular', 'impressumUrl', 'datenschutzUrl']
const cache = new Map()
/** Wird mit jedem Leeren erhoeht: eine Abfrage, die davor begann, darf ihr Ergebnis nicht mehr merken. */
let generation = 0
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
/** 404 "Dokument fehlt" (nicht: Collection oder Datenbank fehlt). */
const dokumentFehlt = (err) => err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found'
async function holeOderNull(collectionId, id) {
if (typeof id !== 'string' || id === '') return null
try {
return await getDocument(collectionId, id)
} catch (err) {
if (dokumentFehlt(err)) return null
throw err
}
}
const leereTexte = () => Object.fromEntries(TEXT_FELDER.map((k) => [k, '']))
const textFelder = (roh) => Object.fromEntries(TEXT_FELDER.map((k) => [k, istObjekt(roh) && Object.hasOwn(roh, k) && typeof roh[k] === 'string' ? roh[k] : '']))
/**
* Rechtstexte aus einem shopConfig-Dokument: { entwurf, bestaetigt }. Fehlt der JSON-Text, ist er kaputt oder hat er eine
* andere Form, ist das Ergebnis { entwurf: (leer), bestaetigt: null }. bestaetigt braucht texte (Objekt) und einen hash (Text).
*/
export function ladeRechtstexte(shopDoc) {
let roh = null
try {
roh = typeof shopDoc?.rechtstexte === 'string' && shopDoc.rechtstexte !== '' ? JSON.parse(shopDoc.rechtstexte) : null
} catch {
roh = null
}
if (!istObjekt(roh)) return { entwurf: leereTexte(), bestaetigt: null }
const b = Object.hasOwn(roh, 'bestaetigt') ? roh.bestaetigt : null
const bestaetigt =
istObjekt(b) && istObjekt(b.texte) && typeof b.hash === 'string' && b.hash !== ''
? { texte: textFelder(b.texte), hash: b.hash, at: typeof b.at === 'string' ? b.at : '', von: typeof b.von === 'string' ? b.von : '' }
: null
return { entwurf: textFelder(Object.hasOwn(roh, 'entwurf') ? roh.entwurf : null), bestaetigt }
}
function ladeStripeStatus(shopDoc) {
try {
const s = typeof shopDoc?.stripeStatus === 'string' && shopDoc.stripeStatus !== '' ? JSON.parse(shopDoc.stripeStatus) : null
return istObjekt(s) ? s : null
} catch {
return null
}
}
/** Geparster Shop aus einem shopConfig-Dokument. */
export function ladeShop(shopDoc) {
return {
id: shopDoc.$id,
customerId: shopDoc.customerId ?? '',
projectId: shopDoc.projectId ?? '',
siteKey: shopDoc.siteKey ?? '',
einstellungen: ladeEinstellungen(shopDoc),
rechtstexte: ladeRechtstexte(shopDoc),
stripeAccountId: typeof shopDoc.stripeAccountId === 'string' ? shopDoc.stripeAccountId : '',
stripeStatus: ladeStripeStatus(shopDoc),
geoeffnet: shopDoc.geoeffnet === true,
anfrageStatus: typeof shopDoc.anfrageStatus === 'string' ? shopDoc.anfrageStatus : '',
version: Number.isInteger(shopDoc.version) && shopDoc.version > 0 ? shopDoc.version : 0,
}
}
/**
* Kontext fuer das Portal: portalFeatures-Dokument des Projekts (Zustand egal), shopConfig dazu und das Projekt.
* doc null: keine Funktion; shop null: noch kein shopConfig-Dokument; project null: das Projekt gibt es nicht oder es gehoert
* einem anderen Kunden.
* @returns {Promise<{ doc: object|null, shop: object|null, project: object|null }>}
*/
export async function ladeShopFuerProjekt(customerId, projectId) {
const projekt = await holeOderNull(config.collections.websiteProjects, projectId)
const project = projekt && customerId && projekt.customerId === customerId ? projekt : null
const doc = project ? await getFeatureDoc(customerId, projectId, FUNKTION) : null
const shopDoc = doc ? await holeOderNull(config.collections.shopConfig, doc.$id) : null
return { doc, shop: shopDoc ? ladeShop(shopDoc) : null, project }
}
/**
* Kontext fuer die oeffentliche Schnittstelle: Funktion 'shop' mit diesem siteKey im Zustand active oder disabled (disabled,
* weil Kaeufer den Widerruf weiter erklaeren koennen muessen) samt shopConfig und Projekt. Treffer und Fehlschlaege werden 30 Sekunden
* je siteKey gemerkt; leereCache und ein Speichern der Einstellungen leeren den Speicher.
* @param {string} siteKey
* @param {{ now?: Date }} [opt] now: Zeitpunkt fuer den Speicher (Tests)
* @returns {Promise<{ doc: object, shop: object, project: object|null }|null>} null bei ungueltigem oder unbekanntem siteKey,
* anderem Zustand als active/disabled und fehlendem shopConfig
*/
export async function shopPerSiteKey(siteKey, { now = new Date() } = {}) {
if (typeof siteKey !== 'string' || !SITE_KEY_RE.test(siteKey)) return null
const nowMs = now instanceof Date ? now.getTime() : Number(now)
if (!Number.isFinite(nowMs)) throw new TypeError('shopPerSiteKey: now ist kein gueltiger Zeitpunkt')
const gemerkt = cache.get(siteKey)
if (gemerkt && gemerkt.bis > nowMs) return gemerkt.ctx
const stand = generation
const [doc] = await listDocuments(config.collections.portalFeatures, [
Query.equal('siteKey', siteKey),
Query.equal('featureKey', FUNKTION),
Query.equal('state', ['active', 'disabled']),
Query.limit(1),
])
let ctx = null
if (doc && ['active', 'disabled'].includes(doc.state)) {
const [shopDoc, project] = await Promise.all([
holeOderNull(config.collections.shopConfig, doc.$id),
doc.projectId ? holeOderNull(config.collections.websiteProjects, doc.projectId) : null,
])
if (shopDoc) ctx = { doc, shop: ladeShop(shopDoc), project }
}
if (stand === generation) merke(siteKey, ctx, nowMs)
return ctx
}
function merke(siteKey, ctx, nowMs) {
if (cache.size >= CACHE_MAX) {
for (const [key, eintrag] of cache) if (eintrag.bis <= nowMs) cache.delete(key)
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value)
}
cache.set(siteKey, { bis: nowMs + CACHE_MS, ctx })
}
/** Leert den Speicher von shopPerSiteKey: fuer einen siteKey oder (ohne Angabe) ganz. Laufende Abfragen merken ihr Ergebnis nicht mehr. */
export function leereCache(siteKey) {
generation++
if (siteKey === undefined) cache.clear()
else cache.delete(siteKey)
}
// Nach dem Speichern der Einstellungen (Hook mit der Shop-ID) alle Eintraege dieses Shops verwerfen
nachSpeichern((shopId) => {
generation++
for (const [key, eintrag] of cache) if (eintrag.ctx?.shop?.id === shopId) cache.delete(key)
})
// ---------------------------------------------------------------------------------------------------------------------
// Bereitschaft
/** Mindestens ein aktiver, lesbarer und lieferbarer Artikel. */
function hatArtikel(produkte, shop) {
return (Array.isArray(produkte) ? produkte : []).some((p) => {
if (p?.aktiv === false) return false
const o = produktOeffentlich(p, { einstellungen: shop.einstellungen, siteKey: shop.siteKey })
return o !== null && o.verfuegbar === true
})
}
async function produkteFuer(ctx, produkte) {
return Array.isArray(produkte) ? produkte : listeProdukte(ctx.shop.id)
}
/** Stand der Rechtstexte: 'bestaetigt' (Hash passt), 'geaendert' (frueher bestaetigt, Entwurf weicht ab) oder 'offen'. */
function rechtstexteStand(shop) {
const { entwurf, bestaetigt } = shop.rechtstexte
if (!bestaetigt) return 'offen'
return bestaetigt.hash === rechtstexteHash(entwurf, shop.einstellungen) ? 'bestaetigt' : 'geaendert'
}
/**
* Die fuenf Punkte, die vor dem Oeffnen stimmen muessen, in dieser Reihenfolge: einbau, stripe, einstellungen, rechtstexte, artikel.
* @param {{ doc: object, shop: object }} ctx
* @param {{ produkte?: object[] }} [opt] bereits geladene Artikel-Dokumente; ohne Angabe werden sie geladen
* @returns {Promise<{ bereit: boolean, punkte: Array<{ id: string, ok: boolean, text: string }> }>}
*/
export async function bereitschaft(ctx, { produkte } = {}) {
const { doc, shop } = ctx
const liste = await produkteFuer(ctx, produkte)
const stand = rechtstexteStand(shop)
const punkte = [
{
id: 'einbau',
ok: doc?.state === 'active',
text: doc?.state === 'active' ? 'Der Shop ist eingebaut.' : 'Wir bauen den Shop gerade in deine Website ein.',
},
{
id: 'stripe',
ok: shop.stripeStatus?.charges_enabled === true,
text:
shop.stripeStatus?.charges_enabled === true
? 'Dein Zahlungskonto bei Stripe ist bereit.'
: 'Bitte richte dein Zahlungskonto bei Stripe ein, damit du Zahlungen empfangen kannst.',
},
{
id: 'einstellungen',
ok: istVollstaendig(shop.einstellungen),
text: istVollstaendig(shop.einstellungen)
? 'Deine Shop-Angaben sind vollständig.'
: 'Bitte vervollständige deine Shop-Angaben (Shopname, Anbieter mit Anschrift und E-Mail, Steuerart, Lieferzeit).',
},
{
id: 'rechtstexte',
ok: stand === 'bestaetigt',
text:
stand === 'bestaetigt'
? 'Deine Rechtstexte sind bestätigt.'
: stand === 'geaendert'
? 'Bitte bestätige deine geänderten Rechtstexte.'
: vollstaendig(shop.rechtstexte.entwurf)
? 'Bitte bestätige deine Rechtstexte.'
: 'Bitte ergänze deine Rechtstexte (AGB, Widerrufsbelehrung, Widerrufsformular, Impressum und Datenschutz) und bestätige sie.',
},
{
id: 'artikel',
ok: hatArtikel(liste, shop),
text: hatArtikel(liste, shop) ? 'Du hast mindestens einen lieferbaren Artikel.' : 'Bitte lege mindestens einen aktiven, lieferbaren Artikel an.',
},
]
return { bereit: punkte.every((p) => p.ok), punkte }
}
/**
* Verkauft der Shop gerade? Geoeffnet vom Haendler, Funktion active, Stripe bereit, Angaben vollstaendig, mindestens ein lieferbarer
* Artikel und die Rechtstexte irgendwann bestaetigt (auch wenn der Entwurf danach geaendert wurde: der Shop bleibt mit der zuletzt
* bestaetigten Fassung offen, Spezifikation 4.6).
* @returns {Promise<boolean>}
*/
export async function istGeoeffnet(ctx, { produkte } = {}) {
const { doc, shop } = ctx
if (shop.geoeffnet !== true || doc?.state !== 'active' || shop.stripeStatus?.charges_enabled !== true) return false
if (!istVollstaendig(shop.einstellungen) || !shop.rechtstexte.bestaetigt) return false
return hatArtikel(await produkteFuer(ctx, produkte), shop)
}
/** Die den Kaeufern gezeigten Rechtstexte (zuletzt bestaetigte Fassung) oder null, wenn noch nie bestaetigt. */
export function oeffentlicheRechtstexte(ctx) {
return ctx?.shop?.rechtstexte?.bestaetigt?.texte ?? null
}