Vertragsereignisse: Nachweise, Widerrufsfristen, Speicher-Aufgaben
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7e869280f3
commit
7799d7c4de
4 files changed
+318
No files matched your search
@@ -119,6 +119,11 @@ await ensureCollection(
|
|||||||
{ type: 'string', key: 'offerText', size: 300 },
|
{ type: 'string', key: 'offerText', size: 300 },
|
||||||
// Plan 1: Produkt des Abos (wartung | hosting)
|
// Plan 1: Produkt des Abos (wartung | hosting)
|
||||||
{ type: 'string', key: 'productKey', size: 16 },
|
{ type: 'string', key: 'productKey', size: 16 },
|
||||||
|
// Plan 2 (Hosting-Abo + Zusatzspeicher): Wechseltag (Beginn der neuen Mindestlaufzeit) und bezahlter Speicherstand
|
||||||
|
{ type: 'datetime', key: 'termStartAt' },
|
||||||
|
{ type: 'integer', key: 'storageUnits' },
|
||||||
|
{ type: 'integer', key: 'storagePaidUnits' },
|
||||||
|
{ type: 'datetime', key: 'storagePaidPeriodEnd' },
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
{ key: 'idx_customer', type: 'key', attributes: ['customerId'] },
|
{ key: 'idx_customer', type: 'key', attributes: ['customerId'] },
|
||||||
@@ -186,6 +191,8 @@ await ensureCollection(
|
|||||||
'Abo-Angebote',
|
'Abo-Angebote',
|
||||||
[
|
[
|
||||||
{ type: 'string', key: 'customerId', size: 64 },
|
{ type: 'string', key: 'customerId', size: 64 },
|
||||||
|
// Plan 2: Angebot gilt fuer dieses Produkt (wartung | hosting)
|
||||||
|
{ type: 'string', key: 'productKey', size: 16 },
|
||||||
{ type: 'string', key: 'status', size: 20 },
|
{ type: 'string', key: 'status', size: 20 },
|
||||||
{ type: 'string', key: 'scope', size: 16 },
|
{ type: 'string', key: 'scope', size: 16 },
|
||||||
{ type: 'integer', key: 'monthlyPriceCents' },
|
{ type: 'integer', key: 'monthlyPriceCents' },
|
||||||
@@ -208,6 +215,35 @@ await ensureCollection(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Vertragsereignisse: ein Dokument je Bestellung/Aenderung (Neuabschluss, Wechsel, Zusatzspeicher,
|
||||||
|
// Kuendigung, Widerruf) mit den Nachweisen der Zustimmung, der Widerrufsfrist und - bei Zusatzspeicher -
|
||||||
|
// dem Stand der Aufgabe fuers Team.
|
||||||
|
await ensureCollection(
|
||||||
|
'contractEvents',
|
||||||
|
'Vertragsereignisse',
|
||||||
|
[
|
||||||
|
{ type: 'string', key: 'customerId', size: 64 },
|
||||||
|
{ type: 'string', key: 'subscriptionId', size: 64 },
|
||||||
|
{ type: 'string', key: 'type', size: 16 },
|
||||||
|
{ type: 'string', key: 'productKey', size: 16 },
|
||||||
|
{ type: 'string', key: 'details', size: 2000 },
|
||||||
|
{ type: 'string', key: 'termsVersion', size: 32 },
|
||||||
|
{ type: 'datetime', key: 'consentAt' },
|
||||||
|
{ type: 'string', key: 'consentIp', size: 64 },
|
||||||
|
{ type: 'string', key: 'consentUserAgent', size: 300 },
|
||||||
|
{ type: 'datetime', key: 'earlyStartConsentAt' },
|
||||||
|
{ type: 'string', key: 'status', size: 16 },
|
||||||
|
{ type: 'datetime', key: 'dueAt' },
|
||||||
|
{ type: 'datetime', key: 'doneAt' },
|
||||||
|
{ type: 'string', key: 'doneBy', size: 255 },
|
||||||
|
{ type: 'datetime', key: 'confirmationSentAt' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ key: 'idx_customer', type: 'key', attributes: ['customerId'] },
|
||||||
|
{ key: 'idx_type_status', type: 'key', attributes: ['type', 'status'] },
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
// Worksheet-Flag: nur markierte Arbeitszeit wird vom Abo-Guthaben abgezogen
|
// Worksheet-Flag: nur markierte Arbeitszeit wird vom Abo-Guthaben abgezogen
|
||||||
await ensure('worksheets.countsToPlan', () =>
|
await ensure('worksheets.countsToPlan', () =>
|
||||||
aw(`${col('worksheets')}/attributes/boolean`, {
|
aw(`${col('worksheets')}/attributes/boolean`, {
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export const config = {
|
|||||||
hourCredits: process.env.APPWRITE_COLLECTION_HOUR_CREDITS || 'hourCredits',
|
hourCredits: process.env.APPWRITE_COLLECTION_HOUR_CREDITS || 'hourCredits',
|
||||||
takeoverCredits: process.env.APPWRITE_COLLECTION_TAKEOVER_CREDITS || 'takeoverCredits',
|
takeoverCredits: process.env.APPWRITE_COLLECTION_TAKEOVER_CREDITS || 'takeoverCredits',
|
||||||
aboOffers: process.env.APPWRITE_COLLECTION_ABO_OFFERS || 'aboOffers',
|
aboOffers: process.env.APPWRITE_COLLECTION_ABO_OFFERS || 'aboOffers',
|
||||||
|
contractEvents: process.env.APPWRITE_COLLECTION_CONTRACT_EVENTS || 'contractEvents',
|
||||||
},
|
},
|
||||||
|
|
||||||
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
|
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { config } from '../config.js'
|
||||||
|
import { createDocument, getDocument, ID, listDocuments, Query, updateDocument } from './appwriteAdmin.js'
|
||||||
|
import { withdrawalDeadline } from './deadlines.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertragsereignisse: ein Dokument je Bestellung/Aenderung (Neuabschluss, Wechsel, Zusatzspeicher,
|
||||||
|
* Kuendigung, Widerruf). Jedes Ereignis traegt seinen eigenen Nachweis (Bedingungen-Version,
|
||||||
|
* Zeitpunkt der Zustimmung, IP, User-Agent), seine eigene Widerrufsfrist (§ 355 BGB) und - bei
|
||||||
|
* Zusatzspeicher - den Stand der Aufgabe fuers Team (Postfachgroesse bei Konsolehost anpassen).
|
||||||
|
*
|
||||||
|
* Eine Speicher-Verringerung (Minus) hat kein Pflicht-Haekchen und damit keine Zustimmungsdaten
|
||||||
|
* (Rechtsrunde 28.09.2026): recordContractEvent wird dafuer mit consent: null aufgerufen und
|
||||||
|
* speichert dann keine consentIp/consentUserAgent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const COLLECTION = () => config.collections.contractEvents
|
||||||
|
|
||||||
|
function parseJson(value, fallback) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value || '') ?? fallback
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ereignistypen, zu denen ueberhaupt eine Widerrufsfrist gehoert (Kuendigung/Widerruf nicht)
|
||||||
|
const WITHDRAWABLE_TYPES = new Set(['abschluss', 'wechsel', 'speicher'])
|
||||||
|
// Ereignis ohne (weitere) Wirkung: widerrufen (Kunde) oder gescheitert (Wechsel ohne Zahlung) -
|
||||||
|
// in beiden Faellen ist keine Frist mehr offen
|
||||||
|
const CLOSED_STATUSES = new Set(['widerrufen', 'gescheitert'])
|
||||||
|
|
||||||
|
function isWithdrawable(event) {
|
||||||
|
if (!WITHDRAWABLE_TYPES.has(event.type) || CLOSED_STATUSES.has(event.status)) return false
|
||||||
|
// Zusatzspeicher: nur eine Erhoehung (Plus) hat ein Widerrufsrecht, eine Verringerung (Minus) nicht
|
||||||
|
if (event.type === 'speicher') return event.details?.toUnits > event.details?.fromUnits
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ereignis anlegen (Bestellung/Aenderung). consent hat die Form { termsVersion, consentAt,
|
||||||
|
* consentIp, consentUserAgent, earlyStartConsentAt? } oder ist null (Speicher-Minus, siehe oben).
|
||||||
|
* details wird als JSON gespeichert. Mit id: erneutes Speichern derselben id ist kein Fehler
|
||||||
|
* (Appwrite meldet 409), sondern liefert das schon vorhandene Dokument zurueck (z. B. wenn ein
|
||||||
|
* Stripe-Webhook zweimal ankommt).
|
||||||
|
*/
|
||||||
|
export async function recordContractEvent({
|
||||||
|
customerId,
|
||||||
|
subscriptionId,
|
||||||
|
type,
|
||||||
|
productKey,
|
||||||
|
details = {},
|
||||||
|
consent = null,
|
||||||
|
status = 'erledigt',
|
||||||
|
dueAt = null,
|
||||||
|
id = null,
|
||||||
|
}) {
|
||||||
|
const documentId = id || ID.unique()
|
||||||
|
const data = {
|
||||||
|
customerId,
|
||||||
|
subscriptionId,
|
||||||
|
type,
|
||||||
|
productKey,
|
||||||
|
details: JSON.stringify(details),
|
||||||
|
status,
|
||||||
|
dueAt,
|
||||||
|
termsVersion: consent?.termsVersion || null,
|
||||||
|
consentAt: consent?.consentAt || null,
|
||||||
|
consentIp: consent?.consentIp || null,
|
||||||
|
consentUserAgent: consent?.consentUserAgent || null,
|
||||||
|
earlyStartConsentAt: consent?.earlyStartConsentAt || null,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await createDocument(COLLECTION(), data, documentId)
|
||||||
|
} catch (err) {
|
||||||
|
// Schon gespeichert (gleiche id, z. B. Webhook-Wiederholung): das vorhandene Dokument gilt als Nachweis
|
||||||
|
if (err.status === 409) return getDocument(COLLECTION(), documentId)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ereignisse eines Kunden, neueste zuerst, mit geparstem details. */
|
||||||
|
export async function listContractEvents(customerId) {
|
||||||
|
const docs = await listDocuments(COLLECTION(), [
|
||||||
|
Query.equal('customerId', customerId),
|
||||||
|
Query.orderDesc('$createdAt'),
|
||||||
|
Query.limit(1000),
|
||||||
|
])
|
||||||
|
return docs.map((doc) => ({ ...doc, details: parseJson(doc.details, {}) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offene Widerrufsfristen je Bestellung (Neuabschluss, Wechsel, Speicher-Plus). events kommen aus
|
||||||
|
* listContractEvents (details schon geparst). Die Frist beginnt mit dem Vertragsschluss bei Stripe
|
||||||
|
* (details.contractAt); solange dieses Feld fehlt (Altbestand vor Aufgabe 7), zaehlt ersatzweise
|
||||||
|
* der Zeitpunkt der Zustimmung im Portal (consentAt).
|
||||||
|
*/
|
||||||
|
export function openWithdrawals(events, now = new Date()) {
|
||||||
|
return events
|
||||||
|
.filter(isWithdrawable)
|
||||||
|
.map((event) => ({
|
||||||
|
type: event.type,
|
||||||
|
eventId: event.$id,
|
||||||
|
until: withdrawalDeadline(event.details?.contractAt ?? event.consentAt).endsAt,
|
||||||
|
}))
|
||||||
|
.filter((entry) => now <= entry.until)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offene Zusatzspeicher-Aufgaben fuers Team, faelligste zuerst. */
|
||||||
|
export async function listOpenStorageTasks() {
|
||||||
|
return listDocuments(COLLECTION(), [
|
||||||
|
Query.equal('type', 'speicher'),
|
||||||
|
Query.equal('status', 'offen'),
|
||||||
|
Query.orderAsc('dueAt'),
|
||||||
|
Query.limit(1000),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zusatzspeicher-Aufgabe erledigt (Team hat die Postfachgroesse angepasst). */
|
||||||
|
export async function completeContractEvent(id, staffEmail) {
|
||||||
|
return updateDocument(COLLECTION(), id, {
|
||||||
|
status: 'erledigt',
|
||||||
|
doneAt: new Date().toISOString(),
|
||||||
|
doneBy: staffEmail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ereignis aendern (z. B. status, doneAt/doneBy oder weitere Felder). patch.details wird mit den
|
||||||
|
* schon gespeicherten details zusammengefuehrt statt sie zu ersetzen, damit z. B. eine spaeter
|
||||||
|
* bekannte paymentUrl oder ein Ablehnungsgrund ergaenzt werden kann, ohne fromUnits/toUnits (oder
|
||||||
|
* andere schon gespeicherte Angaben) zu verlieren.
|
||||||
|
*/
|
||||||
|
export async function updateContractEvent(id, patch = {}) {
|
||||||
|
const { details, ...rest } = patch
|
||||||
|
const data = { ...rest }
|
||||||
|
if (details !== undefined) {
|
||||||
|
const existing = await getDocument(COLLECTION(), id)
|
||||||
|
data.details = JSON.stringify({ ...parseJson(existing.details, {}), ...details })
|
||||||
|
}
|
||||||
|
return updateDocument(COLLECTION(), id, data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { test, after } 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'
|
||||||
|
|
||||||
|
const aw = await startAppwriteMock({
|
||||||
|
schema: { contractEvents: schemaKeys('contractEvents'), subscriptions: schemaKeys('subscriptions') },
|
||||||
|
})
|
||||||
|
const st = await startFakeStripe()
|
||||||
|
after(() => Promise.all([aw.close(), st.close()]))
|
||||||
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||||
|
const {
|
||||||
|
recordContractEvent,
|
||||||
|
listContractEvents,
|
||||||
|
openWithdrawals,
|
||||||
|
listOpenStorageTasks,
|
||||||
|
completeContractEvent,
|
||||||
|
updateContractEvent,
|
||||||
|
} = await import('../server/services/contractEvents.js')
|
||||||
|
|
||||||
|
test('Ereignis speichern und lesen (details als JSON)', async () => {
|
||||||
|
await recordContractEvent({
|
||||||
|
customerId: 'c1',
|
||||||
|
subscriptionId: 'sub_1',
|
||||||
|
type: 'speicher',
|
||||||
|
productKey: 'hosting',
|
||||||
|
details: { fromUnits: 0, toUnits: 2 },
|
||||||
|
consent: {
|
||||||
|
termsVersion: '2026-10-05-v4',
|
||||||
|
consentAt: '2026-10-05T10:00:00.000Z',
|
||||||
|
consentIp: '1.2.3.4',
|
||||||
|
consentUserAgent: 'UA',
|
||||||
|
},
|
||||||
|
status: 'offen',
|
||||||
|
dueAt: '2026-10-05T10:00:00.000Z',
|
||||||
|
})
|
||||||
|
const [e] = await listContractEvents('c1')
|
||||||
|
assert.deepEqual(e.details, { fromUnits: 0, toUnits: 2 })
|
||||||
|
assert.equal((await listOpenStorageTasks()).length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Widerrufsfristen je Bestellung', () => {
|
||||||
|
const ev = (type, consentAt, details = {}, status = 'erledigt') => ({ $id: type, type, consentAt, details, status })
|
||||||
|
const now = new Date('2026-10-10T12:00:00Z')
|
||||||
|
const open = openWithdrawals(
|
||||||
|
[
|
||||||
|
ev('abschluss', '2026-09-01T10:00:00Z'),
|
||||||
|
ev('wechsel', '2026-10-05T10:00:00Z'),
|
||||||
|
ev('speicher', '2026-10-06T10:00:00Z', { fromUnits: 2, toUnits: 1 }),
|
||||||
|
ev('speicher', '2026-10-07T10:00:00Z', { fromUnits: 0, toUnits: 1 }),
|
||||||
|
],
|
||||||
|
now
|
||||||
|
)
|
||||||
|
assert.deepEqual(open.map((o) => o.type), ['wechsel', 'speicher'])
|
||||||
|
assert.equal(openWithdrawals([ev('wechsel', '2026-10-05T10:00:00Z', {}, 'widerrufen')], now).length, 0)
|
||||||
|
// F02: ein Wechsel ohne Zahlung (gescheitert) hat wie ein widerrufener kein Widerrufsrecht mehr
|
||||||
|
assert.equal(openWithdrawals([ev('wechsel', '2026-10-05T10:00:00Z', {}, 'gescheitert')], now).length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// F04: die Frist zaehlt ab dem Vertragsschluss bei Stripe (details.contractAt), nicht ab dem fruehereren
|
||||||
|
// Portal-Klick (consentAt). Waere hier consentAt massgeblich, waere die Frist laengst abgelaufen.
|
||||||
|
test('Widerrufsfrist zaehlt ab dem Vertragsschluss (details.contractAt), nicht ab dem Portal-Klick', () => {
|
||||||
|
const now = new Date('2026-10-10T12:00:00Z')
|
||||||
|
const ev = {
|
||||||
|
$id: 'e1',
|
||||||
|
type: 'abschluss',
|
||||||
|
status: 'erledigt',
|
||||||
|
consentAt: '2026-08-01T10:00:00Z',
|
||||||
|
details: { contractAt: '2026-10-08T10:00:00Z' },
|
||||||
|
}
|
||||||
|
assert.deepEqual(openWithdrawals([ev], now).map((o) => o.eventId), ['e1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Aufgabe erledigen', async () => {
|
||||||
|
const [t] = await listOpenStorageTasks()
|
||||||
|
const done = await completeContractEvent(t.$id, 'kenso@webklar.com')
|
||||||
|
assert.equal(done.status, 'erledigt')
|
||||||
|
assert.equal(done.doneBy, 'kenso@webklar.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
// F01: Status setzen und details zusammenfuehren (nicht ersetzen) in einem Aufruf
|
||||||
|
test('Ereignis aktualisieren: Status setzen, details zusammenfuehren', async () => {
|
||||||
|
const created = await recordContractEvent({
|
||||||
|
customerId: 'c2',
|
||||||
|
subscriptionId: 'sub_2',
|
||||||
|
type: 'wechsel',
|
||||||
|
productKey: 'hosting',
|
||||||
|
details: { von: 'wartung', zu: 'hosting' },
|
||||||
|
status: 'offen',
|
||||||
|
})
|
||||||
|
const updated = await updateContractEvent(created.$id, {
|
||||||
|
status: 'gescheitert',
|
||||||
|
details: { grund: 'Zahlung fehlgeschlagen' },
|
||||||
|
})
|
||||||
|
assert.equal(updated.status, 'gescheitert')
|
||||||
|
assert.deepEqual(JSON.parse(updated.details), { von: 'wartung', zu: 'hosting', grund: 'Zahlung fehlgeschlagen' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// F05: dieselbe id zweimal speichern (z. B. Webhook-Wiederholung) ist kein Fehler, sondern liefert
|
||||||
|
// das schon vorhandene Dokument zurueck, ohne ein zweites anzulegen
|
||||||
|
test('Ereignis mit vorhandener id (409): liefert das schon gespeicherte Dokument', async () => {
|
||||||
|
const first = await recordContractEvent({
|
||||||
|
customerId: 'c3',
|
||||||
|
subscriptionId: 'sub_3',
|
||||||
|
type: 'abschluss',
|
||||||
|
productKey: 'hosting',
|
||||||
|
details: { contractAt: '2026-10-01T09:00:00.000Z' },
|
||||||
|
id: 'abschluss-sub_3',
|
||||||
|
})
|
||||||
|
const again = await recordContractEvent({
|
||||||
|
customerId: 'c3',
|
||||||
|
subscriptionId: 'sub_3',
|
||||||
|
type: 'abschluss',
|
||||||
|
productKey: 'hosting',
|
||||||
|
details: { contractAt: '2026-10-01T09:00:00.000Z' },
|
||||||
|
id: 'abschluss-sub_3',
|
||||||
|
})
|
||||||
|
assert.equal(again.$id, 'abschluss-sub_3')
|
||||||
|
assert.equal(again.$id, first.$id)
|
||||||
|
assert.equal((await listContractEvents('c3')).length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Rechtsrunde 28.09.2026: eine Speicher-Verringerung hat kein Pflicht-Haekchen und damit keine
|
||||||
|
// Zustimmungsdaten - recordContractEvent muss mit consent: null funktionieren und darf dann keine
|
||||||
|
// consentIp/consentUserAgent speichern
|
||||||
|
test('Speicher-Verringerung ohne Zustimmung (consent: null) speichert keine Zustimmungsdaten', async () => {
|
||||||
|
const doc = await recordContractEvent({
|
||||||
|
customerId: 'c4',
|
||||||
|
subscriptionId: 'sub_4',
|
||||||
|
type: 'speicher',
|
||||||
|
productKey: 'hosting',
|
||||||
|
details: { fromUnits: 2, toUnits: 1 },
|
||||||
|
consent: null,
|
||||||
|
})
|
||||||
|
assert.equal(doc.consentIp ?? null, null)
|
||||||
|
assert.equal(doc.consentUserAgent ?? null, null)
|
||||||
|
assert.equal(doc.termsVersion ?? null, null)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user