Abo: Uebernahmegutschrift fuer beim Altanbieter bezahlte Leistungen
- Kunde (Dashboard, nur mit laufendem Abo): Antrag mit Leistung, Anbieter, Domain, Rechnungsbetrag/-zeitraum, Grund, Rechnungsnachweis (PDF/JPG/PNG) und E-Mail fuer die Antwort; Eingangsbestaetigung per Mail, Status und Antwort auf Rueckfragen im Portal - Admin-Mail mit Link admin.html?gutschrift=<id> (Login leitet zurueck) - Admin-Tab "Gutschriften": Kunde und Vertrag, Nachweise, Verlauf, automatische Pruefungen (Duplikate per Datei-Hash/Domain, Abo, Zeitraum, DNS-Abgleich Nameserver/MX mit Anbieter), Pflicht-Checkliste, Rechner (tagesgenau anteilig), Genehmigen/Rueckfrage/Ablehnen mit Kundenmail - Genehmigte Gutschrift als Stripe-Kundenguthaben (idempotent, Nachbuchen bei Stripe-Stoerung) - Nachweise in upload-data/ (gitignored), Loeschung abgelehnter nach 6 Monaten - Stundenkontingent auch fuer Monate, die ein Guthaben komplett deckt (invoice.total statt amount_paid) - Datenschutzerklaerung Abschnitt 7 ergaenzt, PDF neu erzeugt - Collection takeoverCredits in setup-abo-collections.mjs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dbabbf1c0b
commit
48b13f4488
14 files changed
+2040
-9
No files matched your search
+262
-2
@@ -282,6 +282,12 @@ async function postLoginRedirect() {
|
||||
window.location.href = await resolvePostLoginTarget()
|
||||
}
|
||||
|
||||
// Links aus Admin-Mails (z. B. /admin.html?gutschrift=<id>) nach dem Login wieder aufnehmen
|
||||
function adminLoginTarget() {
|
||||
const next = getQueryParam('next')
|
||||
return /^\/admin\.html(\?[A-Za-z0-9=&_-]*)?$/.test(next) ? next : '/admin.html'
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- Login-Page --- */
|
||||
async function initLoginPage() {
|
||||
const form = document.getElementById('login-form')
|
||||
@@ -328,7 +334,7 @@ async function initLoginPage() {
|
||||
try {
|
||||
const me = await api('/api/auth/me')
|
||||
if (me.authenticated && me.role === 'admin' && !me.viewAs) {
|
||||
window.location.href = '/admin.html'
|
||||
window.location.href = adminLoginTarget()
|
||||
return
|
||||
}
|
||||
if (me.authenticated && me.customer) {
|
||||
@@ -356,7 +362,7 @@ async function initLoginPage() {
|
||||
}),
|
||||
})
|
||||
if (result.role === 'admin') {
|
||||
window.location.href = '/admin.html'
|
||||
window.location.href = adminLoginTarget()
|
||||
return
|
||||
}
|
||||
await postLoginRedirect()
|
||||
@@ -616,6 +622,7 @@ function renderAboOffer(card, data, { ended } = {}) {
|
||||
<div class="abo-price"><strong>50 €</strong><span>pro Monat · 12 Monate Mindestlaufzeit</span></div>
|
||||
</div>
|
||||
${aboFeaturesHtml()}
|
||||
${available ? '<p class="abo-note" style="margin:-6px 0 16px">Schon bei einem anderen Anbieter für Domain, Hosting oder E-Mail bezahlt? Nach dem Abschluss können Sie eine Übernahmegutschrift beantragen – wir rechnen die doppelt bezahlte Zeit anteilig an.</p>' : ''}
|
||||
<div class="abo-actions">
|
||||
<button type="button" class="abo-btn" id="abo-start" ${available ? '' : 'disabled'}>Abo abschließen</button>
|
||||
<span class="abo-note">${available
|
||||
@@ -779,11 +786,263 @@ async function loadAboCard({ afterCheckout = false } = {}) {
|
||||
renderAboOffer(card, data, { ended: Boolean(data.subscription) })
|
||||
}
|
||||
card.classList.remove('hidden')
|
||||
loadTakeoverCard({ abo: data })
|
||||
} catch (err) {
|
||||
console.warn('Abo konnte nicht geladen werden:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Übernahmegutschrift: Antrag stellen, Stand sehen, Rückfragen beantworten
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const takeoverState = { abo: null, customer: null }
|
||||
|
||||
const TAKEOVER_STATUS = {
|
||||
offen: ['pill-warn', 'In Prüfung'],
|
||||
rueckfrage: ['pill-amber', 'Rückfrage'],
|
||||
genehmigt: ['pill-ok', 'Genehmigt'],
|
||||
abgelehnt: ['pill-muted', 'Nicht genehmigt'],
|
||||
}
|
||||
|
||||
// Bei Textänderungen config.takeover.conditionsVersion auf dem Server hochziehen
|
||||
const TAKEOVER_CONDITIONS = [
|
||||
'Die Übernahmegutschrift gibt es für Leistungen, die das Wartungs-Abo ersetzt (Domain, Hosting bzw. Betrieb der Website, E-Mail-Postfach) und die Sie bei Ihrem bisherigen Anbieter bereits im Voraus bezahlt haben.',
|
||||
'Wir schreiben den Anteil der Rechnung gut, der auf die Zeit ab Leistungsbeginn Ihres Abos bis zum Ende des bezahlten Zeitraums entfällt, tagesgenau berechnet.',
|
||||
'Nachweis ist die Rechnung des bisherigen Anbieters, ausgestellt auf Sie, mit Leistung, Zeitraum und Betrag. Wir können weitere Nachweise anfordern, etwa die Kündigungsbestätigung. Erstattet Ihr bisheriger Anbieter Beträge für diesen Zeitraum, teilen Sie uns das bitte mit.',
|
||||
'Die Gutschrift wird mit Ihren nächsten Monatsgebühren verrechnet. Sie wird nicht ausgezahlt und ist nicht übertragbar; ein bei Vertragsende noch nicht verrechneter Rest entfällt.',
|
||||
'Die Übernahmegutschrift ist eine freiwillige Leistung von WEBklar. Über Ihren Antrag entscheiden wir nach Prüfung und antworten Ihnen per E-Mail.',
|
||||
]
|
||||
|
||||
const TAKEOVER_MONTHS = [[1, '1 Monat'], [3, '3 Monate'], [6, '6 Monate'], [12, '12 Monate (Jahresrechnung)'], [24, '24 Monate'], [36, '36 Monate']]
|
||||
|
||||
function takeoverStatusPill(status) {
|
||||
const [cls, label] = TAKEOVER_STATUS[status] || ['pill-muted', status]
|
||||
return `<span class="pill ${cls}">${label}</span>`
|
||||
}
|
||||
|
||||
function renderTakeoverItem(r) {
|
||||
const facts = [
|
||||
`eingereicht am ${fmtDateShort(r.createdAt)}`,
|
||||
`bisher bei ${escapeHtml(r.provider)}${r.domain ? ` (${escapeHtml(r.domain)})` : ''}`,
|
||||
`${fmtEuro(r.invoiceAmountCents / 100)} für ${r.billingMonths === 1 ? '1 Monat' : `${r.billingMonths} Monate`}, bezahlt bis ${fmtDateShort(r.paidUntil)}`,
|
||||
]
|
||||
let body
|
||||
if (r.status === 'genehmigt') {
|
||||
body = `<p>Gutschrift: <strong>${fmtEuro(r.amountCents / 100)}</strong> – wird mit Ihren nächsten Monatsgebühren verrechnet.</p>
|
||||
${r.decisionNote ? `<p class="abo-note" style="white-space:pre-wrap">${escapeHtml(r.decisionNote)}</p>` : ''}`
|
||||
} else if (r.status === 'abgelehnt') {
|
||||
body = `<p class="abo-note" style="white-space:pre-wrap">${escapeHtml(r.decisionNote)}</p>`
|
||||
} else if (r.status === 'rueckfrage') {
|
||||
body = `
|
||||
<div class="tk-question"><strong>Unsere Rückfrage:</strong><br>${escapeHtml(r.question)}</div>
|
||||
<form class="tk-reply" data-reply-for="${escapeAttr(r.id)}" novalidate>
|
||||
<textarea name="message" maxlength="4000" placeholder="Ihre Antwort…"></textarea>
|
||||
<input type="file" name="files" accept="application/pdf,image/jpeg,image/png" multiple aria-label="Weitere Nachweise">
|
||||
<p class="error hidden"></p>
|
||||
<div class="abo-actions"><button type="submit" class="abo-btn"${chatState.viewAs ? ' disabled' : ''}>Antwort senden</button></div>
|
||||
</form>`
|
||||
} else {
|
||||
body = '<p class="abo-note">Wir prüfen Ihre Angaben und antworten Ihnen per E-Mail.</p>'
|
||||
}
|
||||
return `<div class="tk-item">
|
||||
<div class="tk-item-head"><strong>Antrag ${escapeHtml(r.ref)} · ${escapeHtml(r.services)}</strong>${takeoverStatusPill(r.status)}</div>
|
||||
<div class="abo-note">${facts.join(' · ')}</div>
|
||||
${body}
|
||||
</div>`
|
||||
}
|
||||
|
||||
function takeoverFormHtml(limits) {
|
||||
const email = takeoverState.customer?.email || ''
|
||||
const maxMb = Math.round(limits.maxTotalBytes / 1024 / 1024)
|
||||
return `
|
||||
<form id="tk-form" class="tk-form hidden" novalidate>
|
||||
<fieldset class="tk-field">
|
||||
<legend>Welche Leistung haben Sie bisher woanders bezahlt?</legend>
|
||||
<div class="tk-services">
|
||||
<label><input type="checkbox" name="services" value="domain"> Domain</label>
|
||||
<label><input type="checkbox" name="services" value="hosting"> Hosting / Website-Betrieb</label>
|
||||
<label><input type="checkbox" name="services" value="email"> E-Mail-Postfach</label>
|
||||
<label><input type="checkbox" name="services" value="sonstiges"> Sonstiges</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="tk-row">
|
||||
<div class="tk-field"><label for="tk-provider">Bisheriger Anbieter</label>
|
||||
<input id="tk-provider" maxlength="200" placeholder="z. B. IONOS, STRATO"></div>
|
||||
<div class="tk-field"><label for="tk-domain">Domain <small>(falls vorhanden)</small></label>
|
||||
<input id="tk-domain" maxlength="255" placeholder="meine-firma.de" autocapitalize="off" spellcheck="false"></div>
|
||||
</div>
|
||||
<div class="tk-row">
|
||||
<div class="tk-field"><label for="tk-amount">Rechnungsbetrag <small>(brutto, in €)</small></label>
|
||||
<input id="tk-amount" type="number" min="0.01" step="0.01" inputmode="decimal" placeholder="119,88"></div>
|
||||
<div class="tk-field"><label for="tk-months">Die Rechnung gilt für</label>
|
||||
<select id="tk-months"><option value="">bitte wählen</option>
|
||||
${TAKEOVER_MONTHS.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select></div>
|
||||
<div class="tk-field"><label for="tk-paid-until">Bezahlt bis <small>(letzter Tag)</small></label>
|
||||
<input id="tk-paid-until" type="date"></div>
|
||||
</div>
|
||||
<div class="tk-field"><label for="tk-message">Grund / Nachricht an uns</label>
|
||||
<textarea id="tk-message" maxlength="4000" placeholder="z. B.: Domain und Postfach liegen noch bis Ende März bei IONOS, der Vertrag dort ist gekündigt."></textarea></div>
|
||||
<div class="tk-field"><label for="tk-files">Rechnungsnachweis <small>(PDF, JPG oder PNG · bis zu ${limits.maxFiles} Dateien · zusammen max. ${maxMb} MB)</small></label>
|
||||
<input id="tk-files" type="file" accept="application/pdf,image/jpeg,image/png" multiple></div>
|
||||
<p class="abo-note" style="margin-top:-6px">Tipp: Laden Sie auch die Kündigungsbestätigung Ihres bisherigen Anbieters hoch – daraus sehen wir, wann der Vertrag dort endet.</p>
|
||||
<div class="tk-field"><label for="tk-email">E-Mail-Adresse für unsere Antwort</label>
|
||||
<input id="tk-email" type="email" maxlength="255" autocomplete="email" value="${escapeAttr(email)}"></div>
|
||||
<details class="tk-conditions">
|
||||
<summary>Bedingungen der Übernahmegutschrift</summary>
|
||||
<ol>${TAKEOVER_CONDITIONS.map((c) => `<li>${c}</li>`).join('')}</ol>
|
||||
</details>
|
||||
<label class="abo-check"><input type="checkbox" id="tk-consent">
|
||||
<span>Ich versichere, dass meine Angaben stimmen und die Rechnung auf mich bzw. mein Unternehmen ausgestellt ist. Die Bedingungen der Übernahmegutschrift habe ich gelesen. Angaben und Nachweise verwendet WEBklar zur Prüfung und Abwicklung des Antrags (<a href="/datenschutz.html#uebernahmegutschrift" target="_blank" rel="noopener">Datenschutz</a>).</span></label>
|
||||
<p class="error hidden" id="tk-error"></p>
|
||||
<div class="abo-actions">
|
||||
<button type="submit" class="abo-btn" id="tk-submit">Antrag absenden</button>
|
||||
<button type="button" class="portal-btn-outline" id="tk-cancel">Abbrechen</button>
|
||||
</div>
|
||||
</form>`
|
||||
}
|
||||
|
||||
function readFileAsBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result).split(',')[1] || '')
|
||||
reader.onerror = () => reject(new Error(`„${file.name}“ konnte nicht gelesen werden.`))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
async function collectTakeoverFiles(input, limits, { required }) {
|
||||
const files = [...(input?.files || [])]
|
||||
const mb = (bytes) => Math.round(bytes / 1024 / 1024)
|
||||
if (required && !files.length) throw new Error('Bitte laden Sie die Rechnung Ihres bisherigen Anbieters hoch.')
|
||||
if (files.length > limits.maxFiles) throw new Error(`Bitte höchstens ${limits.maxFiles} Dateien auf einmal hochladen.`)
|
||||
const tooBig = files.find((f) => f.size > limits.maxFileBytes)
|
||||
if (tooBig) throw new Error(`„${tooBig.name}“ ist größer als ${mb(limits.maxFileBytes)} MB.`)
|
||||
if (files.reduce((s, f) => s + f.size, 0) > limits.maxTotalBytes) {
|
||||
throw new Error(`Die Dateien sind zusammen größer als ${mb(limits.maxTotalBytes)} MB.`)
|
||||
}
|
||||
return Promise.all(files.map(async (f) => ({ name: f.name, data: await readFileAsBase64(f) })))
|
||||
}
|
||||
|
||||
async function submitTakeoverForm(form, limits) {
|
||||
const errEl = form.querySelector('#tk-error')
|
||||
const btn = form.querySelector('#tk-submit')
|
||||
hideError(errEl)
|
||||
const value = (id) => form.querySelector(id).value.trim()
|
||||
const payload = {
|
||||
services: [...form.querySelectorAll('input[name="services"]:checked')].map((i) => i.value),
|
||||
provider: value('#tk-provider'),
|
||||
domain: value('#tk-domain'),
|
||||
invoiceAmount: value('#tk-amount'),
|
||||
billingMonths: Number(value('#tk-months')),
|
||||
paidUntil: value('#tk-paid-until'),
|
||||
message: value('#tk-message'),
|
||||
email: value('#tk-email'),
|
||||
consent: form.querySelector('#tk-consent').checked,
|
||||
}
|
||||
try {
|
||||
if (!payload.services.length) throw new Error('Bitte wählen Sie, welche Leistung Sie bisher woanders bezahlt haben.')
|
||||
if (!payload.provider) throw new Error('Bitte nennen Sie Ihren bisherigen Anbieter.')
|
||||
if (!payload.invoiceAmount || !payload.billingMonths || !payload.paidUntil) {
|
||||
throw new Error('Bitte geben Sie Rechnungsbetrag, Zeitraum und „Bezahlt bis“ an.')
|
||||
}
|
||||
if (!payload.consent) throw new Error('Bitte bestätigen Sie Ihre Angaben und die Bedingungen.')
|
||||
btn.disabled = true
|
||||
btn.textContent = 'Wird gesendet…'
|
||||
payload.files = await collectTakeoverFiles(form.querySelector('#tk-files'), limits, { required: true })
|
||||
await api('/api/abo/takeover-credits', { method: 'POST', body: JSON.stringify(payload) })
|
||||
await loadTakeoverCard({
|
||||
notice: `Vielen Dank! Ihr Antrag ist eingegangen. Die Eingangsbestätigung haben wir an ${payload.email} geschickt; unsere Antwort kommt ebenfalls per E-Mail.`,
|
||||
})
|
||||
} catch (err) {
|
||||
showError(errEl, err.message)
|
||||
btn.disabled = false
|
||||
btn.textContent = 'Antrag absenden'
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTakeoverReply(form, limits) {
|
||||
const errEl = form.querySelector('.error')
|
||||
const btn = form.querySelector('button[type="submit"]')
|
||||
hideError(errEl)
|
||||
btn.disabled = true
|
||||
try {
|
||||
const message = form.querySelector('textarea').value.trim()
|
||||
const files = await collectTakeoverFiles(form.querySelector('input[type="file"]'), limits, { required: false })
|
||||
if (!message && !files.length) throw new Error('Bitte schreiben Sie eine Antwort oder laden Sie einen Nachweis hoch.')
|
||||
await api(`/api/abo/takeover-credits/${encodeURIComponent(form.dataset.replyFor)}/reply`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message, files }),
|
||||
})
|
||||
await loadTakeoverCard({ notice: 'Danke für Ihre Antwort! Wir prüfen Ihren Antrag weiter und melden uns per E-Mail.' })
|
||||
} catch (err) {
|
||||
showError(errEl, err.message)
|
||||
btn.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTakeoverCard({ abo, notice = '' } = {}) {
|
||||
const card = document.getElementById('takeover-card')
|
||||
if (!card) return
|
||||
if (abo) takeoverState.abo = abo
|
||||
const active = Boolean(takeoverState.abo?.subscription?.active)
|
||||
if (!takeoverState.abo?.subscription) {
|
||||
card.classList.add('hidden')
|
||||
return
|
||||
}
|
||||
let data
|
||||
try {
|
||||
data = await api('/api/abo/takeover-credits')
|
||||
} catch (err) {
|
||||
console.warn('Übernahmegutschrift konnte nicht geladen werden:', err.message)
|
||||
return
|
||||
}
|
||||
const requests = data.requests || []
|
||||
if (!active && !requests.length) {
|
||||
card.classList.add('hidden')
|
||||
return
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="abo-head">
|
||||
<div>
|
||||
<p class="abo-kicker">Wechsel ohne doppelte Kosten</p>
|
||||
<h2 class="abo-title">Übernahmegutschrift</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="abo-note tk-intro">Sie haben Domain, Hosting oder E-Mail bei Ihrem bisherigen Anbieter schon im Voraus bezahlt? Den Anteil, den Sie ab Beginn Ihres Abos doppelt zahlen, schreiben wir Ihnen gut und verrechnen ihn mit Ihren nächsten Monatsgebühren. Schicken Sie uns dazu einfach die Rechnung.</p>
|
||||
${notice ? `<div class="tk-notice">${escapeHtml(notice)}</div>` : ''}
|
||||
${active ? `
|
||||
<div class="abo-actions">
|
||||
<button type="button" class="abo-btn" id="tk-start"${chatState.viewAs ? ' disabled' : ''}>Gutschrift beantragen</button>
|
||||
${chatState.viewAs ? '<span class="abo-note">Im Ansichtsmodus kann kein Antrag gestellt werden.</span>' : ''}
|
||||
</div>
|
||||
${takeoverFormHtml(data.limits)}` : ''}
|
||||
${requests.length ? `<div class="tk-list">${requests.map(renderTakeoverItem).join('')}</div>` : ''}`
|
||||
card.classList.remove('hidden')
|
||||
|
||||
const form = card.querySelector('#tk-form')
|
||||
const startBtn = card.querySelector('#tk-start')
|
||||
startBtn?.addEventListener('click', () => {
|
||||
form.classList.remove('hidden')
|
||||
startBtn.classList.add('hidden')
|
||||
form.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
})
|
||||
card.querySelector('#tk-cancel')?.addEventListener('click', () => {
|
||||
form.classList.add('hidden')
|
||||
startBtn?.classList.remove('hidden')
|
||||
})
|
||||
form?.addEventListener('submit', (ev) => {
|
||||
ev.preventDefault()
|
||||
submitTakeoverForm(form, data.limits)
|
||||
})
|
||||
card.querySelectorAll('form[data-reply-for]').forEach((f) =>
|
||||
f.addEventListener('submit', (ev) => {
|
||||
ev.preventDefault()
|
||||
submitTakeoverReply(f, data.limits)
|
||||
}))
|
||||
}
|
||||
|
||||
async function initDashboardPage() {
|
||||
const meta = document.getElementById('customer-meta')
|
||||
const list = document.getElementById('projects')
|
||||
@@ -840,6 +1099,7 @@ async function initDashboardPage() {
|
||||
|
||||
const customer = me.customer
|
||||
chatState.viewAs = Boolean(me.viewAs)
|
||||
takeoverState.customer = customer
|
||||
renderViewAsBanner(me)
|
||||
|
||||
const codeSuffix = customer.code ? ` · Kundennr. ${customer.code}` : ''
|
||||
|
||||
Reference in new issue
Block a user