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>
This commit is contained in:
1 parent
c3f56ba290
commit
65429aa3ac
17 files changed
+1677
-2
No files matched your search
@@ -0,0 +1,131 @@
|
||||
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 })
|
||||
}
|
||||
Reference in new issue
Block a user