Files
Webklar-Kundenbereich/server/services/hourLedger.js
T
ClaudeandClaude Opus 5.5 65429aa3ac Wartungs-Abo (50 EUR/Monat) ueber Stripe mit Stundenkonto im Portal
- Bestellung auf der Portal-Startseite: Zusammenfassung, AGB- und
  Vorab-Leistungs-Zustimmung, Button 'Zahlungspflichtig bestellen',
  danach Stripe Checkout (Abo, Kleinunternehmer-Fusszeile)
- Stripe-Webhook /api/abo/webhook spiegelt Abos nach Appwrite
  (subscriptions) und schreibt je bezahlter Rechnung 60 min gut
  (hourCredits, idempotent ueber Invoice-ID, 12 Monate gueltig)
- Stundenkonto: FIFO-Verbrauch aus Worksheets mit countsToPlan,
  15-Minuten-Takt, Mehraufwand separat ausgewiesen
- Stripe-Kundenportal fuer Zahlungsart/Rechnungen/Kuendigung
- Oeffentlicher Kuendigungsbutton (§ 312k) und Widerrufsbutton
  (§ 356a) mit Eingangsbestaetigung per Mail
- Abo-Bedingungen inkl. Leistungsbeschreibung und Widerrufsbelehrung,
  als Anhang der Vertragsbestaetigung

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:04:45 +00:00

132 lines
4.7 KiB
JavaScript

import { config } from '../config.js'
import { listDocuments, Query } from './appwriteAdmin.js'
/**
* Abo-Stundenkonto eines Kunden.
*
* Gutschriften: je bezahlter Abo-Rechnung 60 min (Collection hourCredits), gueltig 12 Monate.
* Verbrauch: Worksheets mit countsToPlan=true auf Tickets des Kunden, je Eintrag
* auf 15 min aufgerundet.
* Verrechnung: FIFO - die aelteste noch gueltige Gutschrift wird zuerst verbraucht.
* Was kein Guthaben mehr findet, ist Mehraufwand (separat abzurechnen).
* Vertragsende: Restguthaben verfaellt (siehe Abo-Bedingungen Ziff. 4).
*/
function parseSheetDate(ws) {
const raw = ws.startDate || ws.endDate || ''
const m = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(raw)
if (m) return new Date(`${m[3]}-${m[2]}-${m[1]}T12:00:00Z`)
const d = new Date(raw || ws.createdAt || ws.$createdAt)
return Number.isNaN(d.getTime()) ? new Date(ws.$createdAt) : d
}
function roundUsage(minutes) {
const step = config.abo.usageRoundingMinutes
return Math.ceil(Math.max(0, Number(minutes) || 0) / step) * step
}
export async function listCredits(customerId) {
return listDocuments(config.collections.hourCredits, [
Query.equal('customerId', customerId),
Query.orderAsc('grantedAt'),
Query.limit(1000),
])
}
export async function listPlanUsage(customerId) {
const workorders = await listDocuments(config.collections.workorders, [
Query.equal('customerId', customerId),
Query.limit(5000),
])
if (!workorders.length) return []
const byId = new Map(workorders.map((w) => [w.$id, w]))
const ids = [...byId.keys()]
const sheets = []
// Appwrite begrenzt die Werte-Liste pro equal-Query -> in Bloecken abfragen
for (let i = 0; i < ids.length; i += 100) {
const chunk = await listDocuments(config.collections.worksheets, [
Query.equal('workorderId', ids.slice(i, i + 100)),
Query.equal('countsToPlan', true),
Query.limit(5000),
])
sheets.push(...chunk)
}
return sheets
.filter((ws) => Number(ws.totalTime) > 0)
.map((ws) => {
const wo = byId.get(ws.workorderId) || {}
return {
id: ws.$id,
wsid: ws.wsid || '',
woid: ws.woid || wo.woid || '',
topic: wo.topic || wo.title || '',
date: parseSheetDate(ws).toISOString(),
rawMinutes: Number(ws.totalTime) || 0,
minutes: roundUsage(ws.totalTime),
details: String(ws.details || '').slice(0, 300),
employee: ws.employeeName || ws.employeeShort || '',
}
})
.sort((a, b) => a.date.localeCompare(b.date))
}
/** Reine Berechnung (testbar ohne DB). */
export function computeLedger(credits, usage, { now = new Date(), active = true } = {}) {
const pool = credits
.map((c) => ({
id: c.$id,
minutes: Number(c.minutes) || 0,
remaining: Number(c.minutes) || 0,
grantedAt: c.grantedAt,
expiresAt: c.expiresAt,
}))
.sort((a, b) => String(a.grantedAt).localeCompare(String(b.grantedAt)))
let overageMinutes = 0
const entries = usage.map((u) => {
let need = u.minutes
const at = new Date(u.date)
for (const c of pool) {
if (!need) break
// Nur Gutschriften, die zum Arbeitszeitpunkt noch gueltig waren
if (c.remaining <= 0 || new Date(c.expiresAt) <= at) continue
const take = Math.min(need, c.remaining)
c.remaining -= take
need -= take
}
overageMinutes += need
return { ...u, coveredMinutes: u.minutes - need, overageMinutes: need }
})
const valid = pool.filter((c) => c.remaining > 0 && new Date(c.expiresAt) > now)
const expiredMinutes = pool
.filter((c) => c.remaining > 0 && new Date(c.expiresAt) <= now)
.reduce((s, c) => s + c.remaining, 0)
const balanceMinutes = active ? valid.reduce((s, c) => s + c.remaining, 0) : 0
const nextExpiry = active && valid.length
? valid.reduce((a, c) => (new Date(c.expiresAt) < new Date(a.expiresAt) ? c : a))
: null
return {
balanceMinutes,
grantedMinutes: pool.reduce((s, c) => s + c.minutes, 0),
usedMinutes: entries.reduce((s, e) => s + e.coveredMinutes, 0),
overageMinutes,
expiredMinutes,
nextExpiry: nextExpiry ? { minutes: nextExpiry.remaining, at: nextExpiry.expiresAt } : null,
credits: pool.map((c) => ({
grantedAt: c.grantedAt,
expiresAt: c.expiresAt,
minutes: c.minutes,
remaining: active && new Date(c.expiresAt) > now ? c.remaining : 0,
expired: new Date(c.expiresAt) <= now,
})),
usage: entries.reverse(),
}
}
export async function getLedger(customerId, { active = true } = {}) {
const [credits, usage] = await Promise.all([listCredits(customerId), listPlanUsage(customerId)])
return computeLedger(credits, usage, { active })
}