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:
ClaudeandClaude Opus 5.5 committed 2026-09-25 12:04:45 +00:00
1 parent c3f56ba290
commit 65429aa3ac
17 files changed
+1677 -2

No files matched your search

+182
View File
@@ -573,6 +573,184 @@ async function pollChatBadge() {
}
/* -------------------------------------------------------------- Dashboard --- */
// ---------------------------------------------------------------------------
// Wartungs-Abo (Startseite): Angebot + Bestellung bzw. Stundenkonto
// ---------------------------------------------------------------------------
const ABO_FEATURES = [
['Betrieb &amp; Pflege Ihrer Website/Anwendung', 'Server, Updates, Sicherheit, Backups, Monitoring – damit sie online bleibt'],
['1 Domain inklusive', 'Registrierung auf Ihren Namen, DNS- und Zertifikatsverwaltung'],
['1 E-Mail-Postfach', 'mit Ihrer eigenen Domain'],
['1 Stunde Arbeitszeit pro Monat', 'sammelt sich an, jede Stunde ist 12 Monate gültig'],
]
function fmtHours(min) {
const n = Math.max(0, Number(min || 0))
const h = Math.floor(n / 60)
const m = n % 60
return m ? `${h}:${String(m).padStart(2, '0')} h` : `${h} h`
}
function aboFeaturesHtml() {
return `<ul class="abo-features">${ABO_FEATURES.map(
([t, d]) => `<li>${t}<small>${d}</small></li>`
).join('')}</ul>`
}
function renderAboOffer(card, data, { ended } = {}) {
const available = data.available
card.innerHTML = `
<div class="abo-head">
<div>
<p class="abo-kicker">${ended ? 'Ihr Abo ist beendet' : 'Rundum versorgt'}</p>
<h2 class="abo-title">Wartungs-Abo</h2>
</div>
<div class="abo-price"><strong>50 €</strong><span>pro Monat · monatlich kündbar</span></div>
</div>
${aboFeaturesHtml()}
<div class="abo-actions">
<button type="button" class="abo-btn" id="abo-start" ${available ? '' : 'disabled'}>Abo abschließen</button>
<span class="abo-note">${available
? 'Keine Mindestlaufzeit. Gemäß § 19 UStG ohne Umsatzsteuer.'
: 'Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.'}</span>
</div>
<div class="abo-order hidden" id="abo-order">
<h3>Ihre Bestellung</h3>
<table class="abo-summary">
<tr><td>Webklar Wartungs-Abo<br><span class="muted">Leistungen laut <a href="/abo-bedingungen.html" target="_blank" rel="noopener">Leistungsbeschreibung</a></span></td><td>50,00 € / Monat</td></tr>
<tr><td>Laufzeit</td><td>unbestimmt, monatlich kündbar</td></tr>
<tr><td>Zahlung</td><td>monatlich im Voraus (Karte, SEPA u. a. über Stripe)</td></tr>
<tr><td><strong>Gesamt heute</strong><br><span class="muted">Kein Umsatzsteuerausweis (§ 19 UStG)</span></td><td><strong>50,00 €</strong></td></tr>
</table>
<label class="abo-check"><input type="checkbox" id="abo-terms">
<span>Ich habe die <a href="/abo-bedingungen.html" target="_blank" rel="noopener">Abo-Bedingungen inkl. Leistungsbeschreibung</a> und die <a href="/abo-bedingungen.html#widerruf" target="_blank" rel="noopener">Widerrufsbelehrung</a> gelesen und stimme ihnen zu.</span></label>
<label class="abo-check"><input type="checkbox" id="abo-early">
<span>Ich verlange ausdrücklich, dass Webklar vor Ablauf der Widerrufsfrist mit der Leistung beginnt. Mir ist bekannt, dass ich bei einem Widerruf Wertersatz für die bis dahin erbrachten Leistungen schulde.</span></label>
<p class="error hidden" id="abo-error"></p>
<div class="abo-actions">
<button type="button" class="abo-btn" id="abo-buy">Zahlungspflichtig bestellen</button>
<span class="abo-note">Danach hinterlegen Sie bei unserem Zahlungsdienstleister Stripe Ihre Zahlungsart.</span>
</div>
</div>`
const startBtn = card.querySelector('#abo-start')
const order = card.querySelector('#abo-order')
startBtn?.addEventListener('click', () => {
order.classList.remove('hidden')
startBtn.classList.add('hidden')
order.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
})
card.querySelector('#abo-buy')?.addEventListener('click', async (ev) => {
const btn = ev.currentTarget
const errEl = card.querySelector('#abo-error')
hideError(errEl)
const acceptTerms = card.querySelector('#abo-terms').checked
const earlyStart = card.querySelector('#abo-early').checked
if (!acceptTerms || !earlyStart) {
showError(errEl, 'Bitte bestätigen Sie beide Punkte, um zu bestellen.')
return
}
btn.disabled = true
btn.textContent = 'Weiter zur Zahlung…'
try {
const { url } = await api('/api/abo/checkout', {
method: 'POST',
body: JSON.stringify({ acceptTerms, earlyStart, termsVersion: data.termsVersion }),
})
window.location.href = url
} catch (err) {
showError(errEl, err.message)
btn.disabled = false
btn.textContent = 'Zahlungspflichtig bestellen'
}
})
}
function renderAboActive(card, data) {
const sub = data.subscription
const l = data.ledger || { balanceMinutes: 0, credits: [], usage: [] }
const statusLine = sub.cancelAtPeriodEnd
? `<span class="pill pill-muted">gekündigt zum ${fmtDateShort(sub.currentPeriodEnd)}</span>`
: sub.paymentProblem
? '<span class="pill pill-red">Zahlung offen</span>'
: `<span class="pill pill-ok">aktiv</span> <span class="abo-note">nächste Abbuchung ${fmtDateShort(sub.currentPeriodEnd)}</span>`
const usageRows = (l.usage || []).map((u) => `
<tr><td>${fmtDateShort(u.date)}</td>
<td>${escapeHtml(u.topic || u.details || 'Arbeitszeit')}${u.woid ? ` <span class="muted">#${escapeHtml(u.woid)}</span>` : ''}</td>
<td>−${fmtHours(u.minutes)}${u.overageMinutes ? ` <span class="pill pill-warn" title="Über das Guthaben hinaus">+${fmtHours(u.overageMinutes)} extra</span>` : ''}</td></tr>`).join('')
const creditRows = (l.credits || []).slice().reverse().map((c) => `
<tr><td>${fmtDateShort(c.grantedAt)}</td><td>Monatliche Gutschrift</td>
<td>+${fmtHours(c.minutes)} <span class="muted">${c.expired ? 'abgelaufen' : `gültig bis ${fmtDateShort(c.expiresAt)}`}</span></td></tr>`).join('')
card.innerHTML = `
<div class="abo-head">
<div>
<p class="abo-kicker">Ihr Wartungs-Abo</p>
<h2 class="abo-title">Stundenkonto</h2>
</div>
<div>${statusLine}</div>
</div>
${sub.paymentProblem ? '<div class="abo-alert">Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart, damit Ihre Website online bleibt.</div>' : ''}
<div class="abo-balance">
<div class="abo-stat"><strong>${fmtHours(l.balanceMinutes)}</strong><span>verfügbares Guthaben</span></div>
<div class="abo-stat"><strong>${fmtHours(l.usedMinutes)}</strong><span>bisher genutzt</span></div>
${l.nextExpiry ? `<div class="abo-stat"><strong>${fmtHours(l.nextExpiry.minutes)}</strong><span>verfallen am ${fmtDateShort(l.nextExpiry.at)}</span></div>` : ''}
</div>
<p class="abo-note">Jeden Monat kommt 1 Stunde dazu. Abgerechnet wird in 15-Minuten-Schritten, die ältesten Stunden zuerst.
Aufträge schicken Sie uns einfach über den Chat.</p>
<div class="abo-actions" style="margin-top:14px">
<button type="button" class="portal-btn-outline" id="abo-manage">${sub.paymentProblem ? 'Zahlungsart aktualisieren' : 'Abo verwalten · Rechnungen'}</button>
<a class="abo-note" href="/abo-bedingungen.html" target="_blank" rel="noopener">Leistungen &amp; Bedingungen</a>
</div>
${usageRows || creditRows ? `
<details class="abo-history">
<summary>Verlauf anzeigen</summary>
<table class="simple-table">
<thead><tr><th>Datum</th><th>Vorgang</th><th>Stunden</th></tr></thead>
<tbody>${usageRows}${creditRows}</tbody>
</table>
</details>` : ''}
<p class="error hidden" id="abo-error"></p>`
card.querySelector('#abo-manage')?.addEventListener('click', async (ev) => {
const btn = ev.currentTarget
btn.disabled = true
try {
const { url } = await api('/api/abo/portal', { method: 'POST' })
window.location.href = url
} catch (err) {
showError(card.querySelector('#abo-error'), err.message)
btn.disabled = false
}
})
}
async function loadAboCard({ afterCheckout = false } = {}) {
const card = document.getElementById('abo-card')
if (!card) return
try {
let data = await api('/api/abo')
// Direkt nach dem Checkout kann der Stripe-Webhook noch unterwegs sein
for (let i = 0; afterCheckout && !data.subscription?.active && i < 10; i += 1) {
card.classList.remove('hidden')
card.innerHTML = '<p class="abo-kicker">Vielen Dank!</p><h2 class="abo-title">Ihr Abo wird aktiviert…</h2><p class="abo-note">Das dauert nur einen Moment.</p>'
await new Promise((r) => setTimeout(r, 2000))
data = await api('/api/abo')
}
if (data.subscription?.active) {
renderAboActive(card, data)
} else {
renderAboOffer(card, data, { ended: Boolean(data.subscription) })
}
card.classList.remove('hidden')
} catch (err) {
console.warn('Abo konnte nicht geladen werden:', err.message)
}
}
async function initDashboardPage() {
const meta = document.getElementById('customer-meta')
const list = document.getElementById('projects')
@@ -634,6 +812,10 @@ async function initDashboardPage() {
const codeSuffix = customer.code ? ` · Kundennr. ${customer.code}` : ''
meta.textContent = (customer.name ? `${customer.name} (${customer.email})` : customer.email) + codeSuffix
const aboParam = getQueryParam('abo')
if (aboParam) window.history.replaceState(null, '', '/dashboard.html')
loadAboCard({ afterCheckout: aboParam === 'success' })
const [{ projects }, { features }] = await Promise.all([
api('/api/projects'),
api('/api/features'),