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
+182
@@ -573,6 +573,184 @@ async function pollChatBadge() {
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- Dashboard --- */
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wartungs-Abo (Startseite): Angebot + Bestellung bzw. Stundenkonto
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ABO_FEATURES = [
|
||||
['Betrieb & 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 & 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'),
|
||||
|
||||
Reference in new issue
Block a user