Abo: individuelle Angebote (z. B. erste 2 Monate je 25 EUR) mit Stripe-Coupon
- Admin-Tab "Abo-Angebote" und Button "Angebot" in der Kundenliste: Preis, Monate, Gueltigkeit, Hinweis fuer den Kunden, interne Notiz, Vorschau vor dem Anlegen - Je Angebot sofort ein Stripe-Coupon (amount_off, repeating ueber die Angebotsmonate, max. 1 Einloesung); danach automatisch wieder Normalpreis - Kunde ohne Abo: Angebot im Portal, Angebotspreis in der Bestelluebersicht, Coupon im Checkout; Angebot schaltet die Bestellung auch ausserhalb der Pilotliste frei; Bestellung prueft, dass das angezeigte Angebot noch gilt - Laufendes Abo: Coupon sofort am Abo fuer die naechsten Abbuchungen - Testkonten: Testrabatt im Checkout, Angebot per Webhook zusaetzlich am Abo - Angebot in Abo-Metadaten, Vertragsbestaetigung, Admin-Mail und Abo-Karte - Zurueckziehen loescht den Coupon; abgelaufene Angebote nicht mehr bestellbar - Stundenkontingent nach invoice.subtotal: auch voll rabattierte oder per Guthaben bezahlte Monate bringen die Stunde, die Probezeit nicht - setup-abo-collections.mjs: aboOffers, subscriptions.offerId/offerText, Attribut-Abfrage mit Limit (sonst Haenger ab 26 Attributen) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
48b13f4488
commit
bb2f46e191
11 files changed
+856
-30
No files matched your search
@@ -151,6 +151,7 @@
|
||||
<button class="admin-tab" data-tab="customers">Kunden</button>
|
||||
<button class="admin-tab" data-tab="chat">Chat <span id="admin-chat-badge" class="badge-pill hidden"></span></button>
|
||||
<button class="admin-tab" data-tab="credits">Gutschriften <span id="admin-credit-badge" class="badge-pill hidden"></span></button>
|
||||
<button class="admin-tab" data-tab="offers">Abo-Angebote</button>
|
||||
</div>
|
||||
|
||||
<!-- Übersicht -->
|
||||
@@ -231,6 +232,31 @@
|
||||
<div id="credit-detail" class="portal-card credit-detail hidden"></div>
|
||||
<p class="admin-note">Übernahmegutschrift: Der Kunde hat Domain, Hosting oder E-Mail beim bisherigen Anbieter schon bezahlt. Nach der Prüfung wird der doppelt bezahlte Anteil als Guthaben in Stripe gebucht und mit den nächsten Abo-Rechnungen verrechnet.</p>
|
||||
</section>
|
||||
|
||||
<!-- Abo-Angebote -->
|
||||
<section class="admin-section" id="tab-offers">
|
||||
<div class="flex-between" style="margin-bottom:12px">
|
||||
<div class="credit-filter" style="margin:0">
|
||||
<button type="button" class="active" data-offer-filter="open">Offen & aktiv</button>
|
||||
<button type="button" data-offer-filter="all">Alle</button>
|
||||
</div>
|
||||
<button type="button" class="mini-btn primary" id="offer-new">Neues Angebot</button>
|
||||
</div>
|
||||
<div id="offer-message" class="calc-result hidden"></div>
|
||||
<div id="offer-form" class="portal-card hidden" style="margin-bottom:16px"></div>
|
||||
<div class="admin-table-wrap no-scroll">
|
||||
<table class="admin-table">
|
||||
<colgroup>
|
||||
<col style="width:15%"><col style="width:19%"><col style="width:33%"><col style="width:15%"><col style="width:18%">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr><th>Angelegt</th><th>Kunde</th><th>Angebot</th><th>Gültig bis</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody id="offer-rows"><tr><td colspan="5" class="muted">Lade…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="admin-note">Ein Angebot legt sofort einen einmal einlösbaren Coupon in Stripe an. Ohne laufendes Abo wird er beim Abschluss angewendet (und schaltet die Bestellung für diesen Kunden frei), bei laufendem Abo gilt er ab der nächsten Abbuchung. Nach den Angebotsmonaten gilt automatisch wieder der reguläre Preis.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/admin.js"></script>
|
||||
|
||||
+247
@@ -243,6 +243,7 @@ async function loadCustomers(search = '') {
|
||||
<button class="mini-btn${isLead(c) ? '' : ' primary'}" data-view-as="${escapeHtml(c.id)}">Als Kunde ansehen</button>
|
||||
<button class="mini-btn" data-activity="${escapeHtml(c.id)}">Arbeiten</button>
|
||||
<button class="mini-btn" data-invoices="${escapeHtml(c.id)}"${c.invoiceNinjaLinked ? '' : ' title="Nicht mit InvoiceNinja verknüpft"'}>Rechnungen</button>
|
||||
<button class="mini-btn" data-offer-for="${escapeHtml(c.id)}" title="Individuelles Abo-Angebot anlegen">Angebot</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).join('')
|
||||
@@ -255,6 +256,8 @@ async function loadCustomers(search = '') {
|
||||
btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.activity, 'activity')))
|
||||
tbody.querySelectorAll('[data-invoices]').forEach((btn) =>
|
||||
btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.invoices, 'invoices')))
|
||||
tbody.querySelectorAll('[data-offer-for]').forEach((btn) =>
|
||||
btn.addEventListener('click', () => startOfferFor(btn.dataset.offerFor)))
|
||||
} catch (err) {
|
||||
tbody.innerHTML = `<tr><td colspan="7" class="error">${escapeHtml(err.message)}</td></tr>`
|
||||
}
|
||||
@@ -772,6 +775,236 @@ async function applyCredit(id) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- Abo-Angebote --- */
|
||||
const OFFER_STATUS = {
|
||||
offen: ['pill-warn', 'offen'],
|
||||
aktiv: ['pill-ok', 'aktiv'],
|
||||
abgelaufen: ['pill-muted', 'abgelaufen'],
|
||||
zurueckgezogen: ['pill-muted', 'zurückgezogen'],
|
||||
}
|
||||
|
||||
const offerState = { filter: 'open', customers: null, labelToId: new Map(), context: null }
|
||||
|
||||
function offerStatusPill(o) {
|
||||
const [cls, label] = OFFER_STATUS[o.status] || ['pill-muted', o.status]
|
||||
const sub = o.status === 'aktiv' && o.appliedAt ? `seit ${fmtDate(o.appliedAt)}` : o.status === 'offen' ? 'wartet auf Abschluss' : ''
|
||||
return `<span class="pill ${cls}">${label}</span>${sub ? `<br><span class="muted">${sub}</span>` : ''}`
|
||||
}
|
||||
|
||||
// Vorschau im Formular; verbindlich ist der Text, den der Server beim Anlegen erzeugt
|
||||
function offerPreviewText(price, months, regular, running) {
|
||||
const eur = (cents) => euroFromCents(cents)
|
||||
const lead = months === 1 ? (running ? 'Nächster Monat' : 'Erster Monat') : `${running ? 'Nächste' : 'Erste'} ${months} Monate`
|
||||
const amount = price === 0 ? 'kostenlos' : `${months === 1 ? '' : 'je '}${eur(price)} statt ${eur(regular)}`
|
||||
return `${lead} ${amount}, danach ${eur(regular)} pro Monat`
|
||||
}
|
||||
|
||||
function showOfferMessage(text) {
|
||||
const el = document.getElementById('offer-message')
|
||||
el.textContent = text
|
||||
el.classList.toggle('hidden', !text)
|
||||
}
|
||||
|
||||
async function loadOffers() {
|
||||
const tbody = document.getElementById('offer-rows')
|
||||
try {
|
||||
const { offers } = await api('/api/portal-admin/offers')
|
||||
const shown = offerState.filter === 'open' ? offers.filter((o) => ['offen', 'aktiv'].includes(o.status)) : offers
|
||||
if (!shown.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="muted">${offerState.filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'}</td></tr>`
|
||||
return
|
||||
}
|
||||
tbody.innerHTML = shown.map((o) => `
|
||||
<tr>
|
||||
<td>${fmtDateTime(o.createdAt)}<br><span class="muted">${escapeHtml(o.ref)}${o.createdBy ? ` · ${escapeHtml(o.createdBy.replace(/\s*<.*>$/, ''))}` : ''}</span></td>
|
||||
<td><strong>${escapeHtml(o.customerName)}</strong>${o.customerCode ? `<br><span class="muted">Kd.-Nr. ${escapeHtml(o.customerCode)}</span>` : ''}</td>
|
||||
<td>${escapeHtml(o.text)}<br><span class="muted">Ersparnis ${euroFromCents(o.savingsCents)}${o.note ? ` · „${escapeHtml(o.note)}“` : ''}</span>
|
||||
${o.internalNote ? `<br><span class="muted"><em>intern: ${escapeHtml(o.internalNote)}</em></span>` : ''}</td>
|
||||
<td style="white-space:nowrap;overflow-wrap:normal">${o.scope === 'laufend' ? '<span class="muted">laufendes Abo</span>' : fmtDate(o.validUntil)}</td>
|
||||
<td>${offerStatusPill(o)}
|
||||
<br><span class="muted">Stripe:</span> ${o.stripeCouponUrl ? `<a class="plain" href="${escapeHtml(o.stripeCouponUrl)}" target="_blank" rel="noreferrer">Coupon</a>` : '–'}${o.stripeSubscriptionUrl ? ` · <a class="plain" href="${escapeHtml(o.stripeSubscriptionUrl)}" target="_blank" rel="noreferrer">Abo</a>` : ''}
|
||||
${['offen', 'abgelaufen'].includes(o.status) ? `<div style="margin-top:6px"><button class="mini-btn danger" data-withdraw-offer="${escapeHtml(o.id)}">Zurückziehen</button></div>` : ''}</td>
|
||||
</tr>`).join('')
|
||||
tbody.querySelectorAll('[data-withdraw-offer]').forEach((btn) =>
|
||||
btn.addEventListener('click', () => withdrawOfferAction(btn.dataset.withdrawOffer, btn)))
|
||||
} catch (err) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="error">${escapeHtml(err.message)}</td></tr>`
|
||||
}
|
||||
}
|
||||
|
||||
async function withdrawOfferAction(id, btn) {
|
||||
if (!window.confirm('Angebot zurückziehen? Der Coupon wird in Stripe gelöscht, der Kunde sieht das Angebot nicht mehr.')) return
|
||||
btn.disabled = true
|
||||
try {
|
||||
await api(`/api/portal-admin/offers/${encodeURIComponent(id)}/withdraw`, { method: 'POST' })
|
||||
showOfferMessage('Angebot zurückgezogen, Coupon in Stripe gelöscht.')
|
||||
loadOffers()
|
||||
} catch (err) {
|
||||
btn.disabled = false
|
||||
alert('Zurückziehen fehlgeschlagen: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const offerCustomerLabel = (c) =>
|
||||
[c.name || '(ohne Name)', c.email, c.code ? `Kd.-Nr. ${c.code}` : ''].filter(Boolean).join(' · ')
|
||||
|
||||
async function ensureOfferCustomers() {
|
||||
if (offerState.customers) return offerState.customers
|
||||
const { customers } = await api('/api/portal-admin/customers')
|
||||
offerState.customers = customers
|
||||
offerState.labelToId = new Map(customers.map((c) => [offerCustomerLabel(c), c.id]))
|
||||
return customers
|
||||
}
|
||||
|
||||
function offerFormValues(form) {
|
||||
return {
|
||||
customerId: offerState.labelToId.get(form.querySelector('#offer-customer').value.trim()) || '',
|
||||
monthlyPriceCents: Math.round(Number(form.querySelector('#offer-price').value) * 100),
|
||||
months: Number(form.querySelector('#offer-months').value),
|
||||
validUntil: form.querySelector('#offer-valid').value,
|
||||
note: form.querySelector('#offer-note').value.trim(),
|
||||
internalNote: form.querySelector('#offer-internal').value.trim(),
|
||||
notifyCustomer: form.querySelector('#offer-notify').checked,
|
||||
}
|
||||
}
|
||||
|
||||
function renderOfferPreview(form) {
|
||||
const box = form.querySelector('#offer-preview')
|
||||
const save = form.querySelector('#offer-save')
|
||||
const ctx = offerState.context
|
||||
const v = offerFormValues(form)
|
||||
save.disabled = true
|
||||
if (!v.customerId || !ctx || ctx.customer.id !== v.customerId) {
|
||||
box.textContent = v.customerId ? 'Lade Kunde…' : 'Bitte einen Kunden aus der Liste wählen.'
|
||||
return
|
||||
}
|
||||
const regular = ctx.regularPriceCents
|
||||
const running = Boolean(ctx.subscription?.active)
|
||||
const lines = []
|
||||
let ok = true
|
||||
if (ctx.openOffer) {
|
||||
lines.push(`Es gibt schon ein offenes Angebot (${ctx.openOffer.ref}: ${ctx.openOffer.text}) – bitte zuerst zurückziehen.`)
|
||||
ok = false
|
||||
}
|
||||
if (!Number.isInteger(v.monthlyPriceCents) || v.monthlyPriceCents < 0 || v.monthlyPriceCents >= regular) {
|
||||
lines.push(`Angebotspreis bitte zwischen 0,00 € und ${euroFromCents(regular - 1)} angeben.`)
|
||||
ok = false
|
||||
}
|
||||
if (!v.validUntil && !running) {
|
||||
lines.push('Bitte „Gültig bis“ angeben.')
|
||||
ok = false
|
||||
}
|
||||
if (ok) {
|
||||
lines.push(`${offerPreviewText(v.monthlyPriceCents, v.months, regular, running)} · Ersparnis ${euroFromCents((regular - v.monthlyPriceCents) * v.months)}`)
|
||||
}
|
||||
lines.push(running
|
||||
? `Laufendes Abo: gilt ab der nächsten Abbuchung${ctx.subscription.currentPeriodEnd ? ` (${fmtDate(ctx.subscription.currentPeriodEnd)})` : ''} und wird sofort in Stripe angewendet.`
|
||||
: 'Noch kein laufendes Abo: Der Coupon wird angelegt und beim Abschluss im Kundenportal angewendet.')
|
||||
if (!running && !ctx.customer.portalAccessEnabled) lines.push('Achtung: Kunde hat keinen Portalzugang – er kann erst bestellen, wenn der Zugang freigeschaltet ist.')
|
||||
if (ctx.customer.testAccount) lines.push('Testkonto: Im Checkout bleibt der 0,50-€-Testrabatt, das Angebot wird danach zusätzlich angehängt.')
|
||||
box.innerHTML = lines.map((l) => `<div>${escapeHtml(l)}</div>`).join('')
|
||||
save.disabled = !ok
|
||||
save.textContent = running ? 'Angebot anlegen und aufs laufende Abo anwenden' : 'Angebot anlegen'
|
||||
}
|
||||
|
||||
async function loadOfferContext(form) {
|
||||
const v = offerFormValues(form)
|
||||
offerState.context = null
|
||||
renderOfferPreview(form)
|
||||
if (!v.customerId) return
|
||||
try {
|
||||
offerState.context = await api(`/api/portal-admin/offers/context/${encodeURIComponent(v.customerId)}`)
|
||||
if (!form.querySelector('#offer-valid').value) form.querySelector('#offer-valid').value = offerState.context.defaultValidUntil
|
||||
} catch (err) {
|
||||
form.querySelector('#offer-preview').textContent = err.message
|
||||
return
|
||||
}
|
||||
renderOfferPreview(form)
|
||||
}
|
||||
|
||||
async function saveOffer(form) {
|
||||
const errEl = form.querySelector('#offer-error')
|
||||
errEl.classList.add('hidden')
|
||||
const v = offerFormValues(form)
|
||||
const ctx = offerState.context
|
||||
const text = offerPreviewText(v.monthlyPriceCents, v.months, ctx.regularPriceCents, Boolean(ctx.subscription?.active))
|
||||
if (!window.confirm(`Angebot für ${ctx.customer.name || 'den Kunden'} anlegen?\n\n${text}\n\nDer Coupon wird sofort in Stripe angelegt${ctx.subscription?.active ? ' und auf das laufende Abo angewendet' : ''}.`)) return
|
||||
const save = form.querySelector('#offer-save')
|
||||
save.disabled = true
|
||||
try {
|
||||
const result = await api('/api/portal-admin/offers', { method: 'POST', body: JSON.stringify(v) })
|
||||
const o = result.offer
|
||||
form.classList.add('hidden')
|
||||
showOfferMessage([
|
||||
`Angebot ${o.ref} für ${o.customerName} angelegt: ${o.text}.`,
|
||||
`Stripe-Coupon ${o.stripeCouponId} ist eingerichtet${o.status === 'aktiv' ? ' und am laufenden Abo aktiv' : ''}.`,
|
||||
v.notifyCustomer ? (result.mailSent ? 'Die E-Mail an den Kunden ist raus.' : `E-Mail an den Kunden fehlgeschlagen (${result.mailError}).`) : '',
|
||||
].filter(Boolean).join(' '))
|
||||
loadOffers()
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message
|
||||
errEl.classList.remove('hidden')
|
||||
renderOfferPreview(form)
|
||||
}
|
||||
}
|
||||
|
||||
async function openOfferForm(customerId = '') {
|
||||
const form = document.getElementById('offer-form')
|
||||
showOfferMessage('')
|
||||
form.classList.remove('hidden')
|
||||
form.innerHTML = '<p class="muted">Lade Kunden…</p>'
|
||||
let customers
|
||||
try {
|
||||
customers = await ensureOfferCustomers()
|
||||
} catch (err) {
|
||||
form.innerHTML = `<p class="error">${escapeHtml(err.message)}</p>`
|
||||
return
|
||||
}
|
||||
const preselected = customers.find((c) => c.id === customerId)
|
||||
form.innerHTML = `
|
||||
<h3 style="margin-top:0">Neues Angebot</h3>
|
||||
<div class="calc-grid">
|
||||
<div style="grid-column:1/-1"><label for="offer-customer">Kunde</label>
|
||||
<input class="credit-input" id="offer-customer" list="offer-customer-list" autocomplete="off"
|
||||
placeholder="Name, E-Mail oder Kundennummer tippen …" value="${preselected ? escapeHtml(offerCustomerLabel(preselected)) : ''}">
|
||||
<datalist id="offer-customer-list">${customers.map((c) => `<option value="${escapeHtml(offerCustomerLabel(c))}"></option>`).join('')}</datalist></div>
|
||||
<div><label for="offer-price">Preis pro Monat im Angebot (€)</label>
|
||||
<input class="credit-input" id="offer-price" type="number" min="0" step="0.01" value="25.00"></div>
|
||||
<div><label for="offer-months">für</label>
|
||||
<select class="credit-input" id="offer-months">${Array.from({ length: 12 }, (_, i) => i + 1)
|
||||
.map((m) => `<option value="${m}"${m === 2 ? ' selected' : ''}>${m} Monat${m === 1 ? '' : 'e'}</option>`).join('')}</select></div>
|
||||
<div><label for="offer-valid">Angebot gültig bis</label>
|
||||
<input class="credit-input" id="offer-valid" type="date"></div>
|
||||
</div>
|
||||
<div class="credit-field"><label for="offer-note">Hinweis für den Kunden (optional, z. B. „Übernahme Ihres IONOS-Hostings“)</label>
|
||||
<input class="credit-input" id="offer-note" maxlength="500"></div>
|
||||
<div class="credit-field"><label for="offer-internal">Interne Notiz (optional, sieht nur das Team)</label>
|
||||
<input class="credit-input" id="offer-internal" maxlength="2000"></div>
|
||||
<div class="manual-checks" style="margin-top:8px"><label><input type="checkbox" id="offer-notify" checked> Kunden per E-Mail über das Angebot informieren</label></div>
|
||||
<div class="calc-result" id="offer-preview">Bitte einen Kunden aus der Liste wählen.</div>
|
||||
<div class="credit-actions">
|
||||
<button class="mini-btn primary" id="offer-save" disabled>Angebot anlegen</button>
|
||||
<button class="mini-btn" id="offer-cancel">Abbrechen</button>
|
||||
</div>
|
||||
<p class="error hidden" id="offer-error"></p>`
|
||||
|
||||
form.querySelector('#offer-customer').addEventListener('change', () => loadOfferContext(form))
|
||||
form.querySelector('#offer-customer').addEventListener('input', () => {
|
||||
if (offerState.labelToId.has(form.querySelector('#offer-customer').value.trim())) loadOfferContext(form)
|
||||
})
|
||||
form.querySelectorAll('#offer-price, #offer-months, #offer-valid').forEach((el) => el.addEventListener('input', () => renderOfferPreview(form)))
|
||||
form.querySelector('#offer-save').addEventListener('click', () => saveOffer(form))
|
||||
form.querySelector('#offer-cancel').addEventListener('click', () => form.classList.add('hidden'))
|
||||
if (preselected) loadOfferContext(form)
|
||||
form.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
/** Aus der Kundenliste: Tab wechseln und Formular mit dem Kunden oeffnen. */
|
||||
function startOfferFor(customerId) {
|
||||
document.querySelector('.admin-tab[data-tab="offers"]')?.click()
|
||||
openOfferForm(customerId)
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- Init --- */
|
||||
async function initAdminPage() {
|
||||
document.getElementById('logout-btn')?.addEventListener('click', async () => {
|
||||
@@ -800,6 +1033,7 @@ async function initAdminPage() {
|
||||
|
||||
let chatListLoaded = false
|
||||
let creditsLoaded = false
|
||||
let offersLoaded = false
|
||||
document.querySelectorAll('.admin-tab').forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.admin-tab').forEach((t) => t.classList.remove('active'))
|
||||
@@ -814,9 +1048,22 @@ async function initAdminPage() {
|
||||
creditsLoaded = true
|
||||
loadCredits()
|
||||
}
|
||||
if (tab.dataset.tab === 'offers' && !offersLoaded) {
|
||||
offersLoaded = true
|
||||
loadOffers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
document.querySelectorAll('[data-offer-filter]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('[data-offer-filter]').forEach((b) => b.classList.toggle('active', b === btn))
|
||||
offerState.filter = btn.dataset.offerFilter
|
||||
loadOffers()
|
||||
})
|
||||
})
|
||||
document.getElementById('offer-new')?.addEventListener('click', () => openOfferForm())
|
||||
|
||||
document.querySelectorAll('[data-credit-filter]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('[data-credit-filter]').forEach((b) => b.classList.toggle('active', b === btn))
|
||||
|
||||
+21
-4
@@ -613,14 +613,28 @@ function lateStartDate() {
|
||||
|
||||
function renderAboOffer(card, data, { ended } = {}) {
|
||||
const available = data.available
|
||||
// Individuelles Angebot (vom WEBklar-Team angelegt), z. B. erste 2 Monate je 25 €
|
||||
const offer = data.offer
|
||||
const offerPrice = offer
|
||||
? (offer.monthlyPriceCents % 100 === 0 ? `${offer.monthlyPriceCents / 100} €` : fmtEuro(offer.monthlyPriceCents / 100))
|
||||
: ''
|
||||
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 · 12 Monate Mindestlaufzeit</span></div>
|
||||
${offer
|
||||
? `<div class="abo-price"><strong>${offerPrice}</strong><span>${offer.months === 1 ? 'im ersten Monat' : `pro Monat in den ersten ${offer.months} Monaten`}, danach 50 € · 12 Monate Mindestlaufzeit</span></div>`
|
||||
: '<div class="abo-price"><strong>50 €</strong><span>pro Monat · 12 Monate Mindestlaufzeit</span></div>'}
|
||||
</div>
|
||||
${offer ? `
|
||||
<div class="abo-offer-box">
|
||||
<strong>Ihr persönliches Angebot</strong>
|
||||
<p>${escapeHtml(offer.text)}. Sie sparen ${fmtEuro(offer.savingsCents / 100)}.</p>
|
||||
${offer.note ? `<p class="abo-note">${escapeHtml(offer.note)}</p>` : ''}
|
||||
<p class="abo-note">Gültig bis ${fmtDateShort(offer.validUntil)} · wird beim Abschluss automatisch berücksichtigt</p>
|
||||
</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">
|
||||
@@ -631,7 +645,7 @@ function renderAboOffer(card, data, { ended } = {}) {
|
||||
</div>
|
||||
<div class="abo-order hidden" id="abo-order">
|
||||
<h3>Ihre Bestellung</h3>
|
||||
${data.testAccount ? '<div class="abo-alert">Testkonto: Bei Stripe wird ein Test-Rabatt verrechnet, abgebucht werden nur 0,50 € pro Monat.</div>' : ''}
|
||||
${data.testAccount ? `<div class="abo-alert">Testkonto: Bei Stripe wird ein Test-Rabatt verrechnet, abgebucht werden nur 0,50 € pro Monat.${offer ? ' Das Angebot wird nach dem Abschluss zusätzlich ans Abo gehängt.' : ''}</div>` : ''}
|
||||
<label class="abo-check"><input type="checkbox" id="abo-terms">
|
||||
<span>Ich habe die <a href="/abo-bedingungen.html" target="_blank" rel="noopener">Vertragsbedingungen mit Leistungsbeschreibung und Auftragsverarbeitungsvertrag</a> gelesen und stimme ihnen zu. Die <a href="/abo-bedingungen.html#widerruf" target="_blank" rel="noopener">Widerrufsbelehrung</a> habe ich zur Kenntnis genommen.</span></label>
|
||||
|
||||
@@ -647,7 +661,9 @@ function renderAboOffer(card, data, { ended } = {}) {
|
||||
<div class="abo-summary-box">
|
||||
<table class="abo-summary">
|
||||
<tr><td>Leistung</td><td>WEBklar Wartungs-Abo: Betrieb & Pflege Ihrer Website bzw. Web-Anwendung, 1 Domain, 1 E-Mail-Postfach (5 GB), 1 Stunde Arbeitszeit je bezahltem Monat (12 Monate nutzbar)</td></tr>
|
||||
<tr><td>Preis</td><td><strong>50,00 € pro Monat</strong> (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten</td></tr>
|
||||
<tr><td>Preis</td><td>${offer
|
||||
? `<strong>${escapeHtml(offer.text)}</strong> (individuelles Angebot; Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten`
|
||||
: '<strong>50,00 € pro Monat</strong> (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten'}</td></tr>
|
||||
<tr><td>Abrechnung</td><td>monatlich im Voraus, erstmals zum Leistungsbeginn</td></tr>
|
||||
<tr><td>Laufzeit</td><td><strong>Mindestlaufzeit 12 Monate</strong> ab Leistungsbeginn, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach unbefristet und jederzeit zum Ende des Abrechnungsmonats kündbar</td></tr>
|
||||
<tr><td>Zahlung</td><td>Kreditkarte oder SEPA-Lastschrift über Stripe</td></tr>
|
||||
@@ -689,7 +705,7 @@ function renderAboOffer(card, data, { ended } = {}) {
|
||||
try {
|
||||
const { url } = await api('/api/abo/checkout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ acceptTerms, startChoice, termsVersion: data.termsVersion }),
|
||||
body: JSON.stringify({ acceptTerms, startChoice, termsVersion: data.termsVersion, offerId: offer?.id || '' }),
|
||||
})
|
||||
window.location.href = url
|
||||
} catch (err) {
|
||||
@@ -739,6 +755,7 @@ function renderAboActive(card, data) {
|
||||
</div>
|
||||
<p class="abo-note">Jeden bezahlten Monat kommt 1 Stunde dazu. Angerechnet wird in 15-Minuten-Schritten, die ältesten Stunden zuerst.
|
||||
Aufträge schicken Sie uns einfach über den Chat.${sub.minTermEnd && !sub.cancelAtPeriodEnd ? ` Mindestlaufzeit bis ${fmtDateShort(sub.minTermEnd)}, danach monatlich kündbar.` : ''}</p>
|
||||
${sub.offerText ? `<p class="abo-note">Ihr Angebot: ${escapeHtml(sub.offerText)}.</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>
|
||||
${sub.withdrawalOpen ? `<a class="abo-withdraw" href="/widerrufen.html">Vertrag widerrufen</a><span class="abo-note">möglich bis ${fmtDateShort(sub.withdrawalUntil)}</span>` : ''}
|
||||
|
||||
@@ -157,6 +157,9 @@
|
||||
.abo-summary td:last-child { text-align: left; }
|
||||
.abo-withdraw { color: #fff; font-weight: 700; padding: 9px 16px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.6); text-decoration: none; }
|
||||
.abo-withdraw:hover { background: #fff; color: #1c1c1c; }
|
||||
/* Individuelles Angebot */
|
||||
.abo-offer-box { margin: 16px 0 4px; padding: 14px 16px; border-radius: 14px; background: rgba(74,222,128,0.10); border: 1px solid rgba(74,222,128,0.45); }
|
||||
.abo-offer-box p { margin: 4px 0 0; }
|
||||
|
||||
/* Übernahmegutschrift */
|
||||
.pill-amber { background: rgba(251,191,36,0.15); border-color: rgba(251,191,36,0.45); }
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Wartungs-Abo: legt die Collections subscriptions, hourCredits und takeoverCredits
|
||||
* (Uebernahmegutschriften) in woms-database an und ergaenzt worksheets um das Flag
|
||||
* countsToPlan ("zaehlt aufs Abo-Stundenkonto").
|
||||
* Wartungs-Abo: legt die Collections subscriptions, hourCredits, takeoverCredits
|
||||
* (Uebernahmegutschriften) und aboOffers (individuelle Angebote) in woms-database an und
|
||||
* ergaenzt worksheets um das Flag countsToPlan ("zaehlt aufs Abo-Stundenkonto").
|
||||
* Idempotent: existierende Collection/Attribute/Indexe werden uebersprungen.
|
||||
* Usage (im Container): docker exec -w /app webklar-kundenbereich-server node scripts/setup-abo-collections.mjs
|
||||
*/
|
||||
@@ -49,8 +49,10 @@ async function ensure(label, fn) {
|
||||
}
|
||||
|
||||
async function waitForAttributes(collectionId, keys, maxSec = 60) {
|
||||
// Ohne Limit liefert Appwrite nur 25 Attribute -> spaetere Attribute waeren nie "verfuegbar"
|
||||
const limit = encodeURIComponent(JSON.stringify({ method: 'limit', values: [100] }))
|
||||
for (let i = 0; i < maxSec; i += 1) {
|
||||
const { attributes } = await aw(`/databases/${databaseId}/collections/${collectionId}/attributes`)
|
||||
const { attributes } = await aw(`/databases/${databaseId}/collections/${collectionId}/attributes?queries[]=${limit}`)
|
||||
const pending = keys.filter((k) => attributes.find((a) => a.key === k)?.status !== 'available')
|
||||
if (!pending.length) return
|
||||
await new Promise((r) => setTimeout(r, 1000))
|
||||
@@ -112,6 +114,9 @@ await ensureCollection(
|
||||
{ type: 'string', key: 'consentTextVersion', size: 32 },
|
||||
// v3: Beendigungszeitpunkt (Mindestlaufzeit / Wunschtermin)
|
||||
{ type: 'datetime', key: 'cancelAt' },
|
||||
// Individuelles Angebot, mit dem das Abo abgeschlossen wurde (Nachweis der Preisabrede)
|
||||
{ type: 'string', key: 'offerId', size: 64 },
|
||||
{ type: 'string', key: 'offerText', size: 300 },
|
||||
],
|
||||
[
|
||||
{ key: 'idx_customer', type: 'key', attributes: ['customerId'] },
|
||||
@@ -173,6 +178,34 @@ await ensureCollection(
|
||||
]
|
||||
)
|
||||
|
||||
// Individuelle Abo-Angebote: Admin legt vorab fest (z. B. erste 2 Monate je 25 €), Stripe-Coupon je Angebot
|
||||
await ensureCollection(
|
||||
'aboOffers',
|
||||
'Abo-Angebote',
|
||||
[
|
||||
{ type: 'string', key: 'customerId', size: 64 },
|
||||
{ type: 'string', key: 'status', size: 20 },
|
||||
{ type: 'string', key: 'scope', size: 16 },
|
||||
{ type: 'integer', key: 'monthlyPriceCents' },
|
||||
{ type: 'integer', key: 'regularPriceCents' },
|
||||
{ type: 'integer', key: 'months' },
|
||||
{ type: 'datetime', key: 'validUntil' },
|
||||
{ type: 'string', key: 'note', size: 500 },
|
||||
{ type: 'string', key: 'internalNote', size: 2000 },
|
||||
{ type: 'string', key: 'stripeCouponId', size: 64 },
|
||||
{ type: 'string', key: 'stripeSubscriptionId', size: 64 },
|
||||
{ type: 'datetime', key: 'appliedAt' },
|
||||
{ type: 'string', key: 'createdBy', size: 255 },
|
||||
{ type: 'datetime', key: 'withdrawnAt' },
|
||||
{ type: 'string', key: 'withdrawnBy', size: 255 },
|
||||
{ type: 'datetime', key: 'notifiedAt' },
|
||||
],
|
||||
[
|
||||
{ key: 'idx_customer', type: 'key', attributes: ['customerId'] },
|
||||
{ key: 'idx_status', type: 'key', attributes: ['status'] },
|
||||
]
|
||||
)
|
||||
|
||||
// Worksheet-Flag: nur markierte Arbeitszeit wird vom Abo-Guthaben abgezogen
|
||||
await ensure('worksheets.countsToPlan', () =>
|
||||
aw(`${col('worksheets')}/attributes/boolean`, {
|
||||
|
||||
@@ -51,6 +51,7 @@ export const config = {
|
||||
subscriptions: process.env.APPWRITE_COLLECTION_SUBSCRIPTIONS || 'subscriptions',
|
||||
hourCredits: process.env.APPWRITE_COLLECTION_HOUR_CREDITS || 'hourCredits',
|
||||
takeoverCredits: process.env.APPWRITE_COLLECTION_TAKEOVER_CREDITS || 'takeoverCredits',
|
||||
aboOffers: process.env.APPWRITE_COLLECTION_ABO_OFFERS || 'aboOffers',
|
||||
},
|
||||
|
||||
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
|
||||
@@ -96,6 +97,13 @@ export const config = {
|
||||
testCouponId: 'WEBKLAR-TEST-050',
|
||||
},
|
||||
|
||||
// Individuelle Abo-Angebote (z. B. erste 2 Monate je 25 €): je Angebot ein einmal einloesbarer Stripe-Coupon
|
||||
offers: {
|
||||
maxMonths: 12,
|
||||
defaultValidityDays: 30,
|
||||
maxValidityDays: 366,
|
||||
},
|
||||
|
||||
// Uebernahmegutschrift: beim bisherigen Anbieter bereits bezahlte Zeit wird mit den Abo-Gebuehren verrechnet
|
||||
takeover: {
|
||||
// Bei jeder Textaenderung an den Bedingungen der Gutschrift hochziehen (wird mit dem Antrag gespeichert)
|
||||
|
||||
+41
-14
@@ -7,6 +7,7 @@ import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { getCustomerByEmail, getDocument, updateDocument } from '../services/appwriteAdmin.js'
|
||||
import { getLedger } from '../services/hourLedger.js'
|
||||
import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
|
||||
import { activateOfferFromCheckout, getOpenOffer, offerText, offerView } from '../services/offers.js'
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
cancelAtPeriodEnd,
|
||||
@@ -72,13 +73,16 @@ function publicSubscription(sub) {
|
||||
// Widerrufsbutton im Portal nur waehrend der Frist hervorheben
|
||||
withdrawalOpen: Boolean(active && withdrawalUntil && withdrawalUntil > new Date()),
|
||||
withdrawalUntil: withdrawalUntil ? withdrawalUntil.toISOString() : null,
|
||||
// Individuelles Angebot, mit dem das Abo laeuft (z. B. "Erste 2 Monate je 25,00 € ...")
|
||||
offerText: sub.offerText || null,
|
||||
}
|
||||
}
|
||||
|
||||
// ABO_PILOT_EMAILS: Liste erlaubter Kunden-E-Mails; '*' = alle Kunden (Regelbetrieb)
|
||||
function isPilotAllowed(customer) {
|
||||
// ABO_PILOT_EMAILS: Liste erlaubter Kunden-E-Mails; '*' = alle Kunden (Regelbetrieb).
|
||||
// Ein individuelles Angebot schaltet die Bestellung fuer diesen Kunden ebenfalls frei.
|
||||
function isPilotAllowed(customer, offer = null) {
|
||||
const list = config.abo.pilotEmails
|
||||
if (!list.length || list.includes('*')) return true
|
||||
if (offer || !list.length || list.includes('*')) return true
|
||||
return list.includes(String(customer?.email || '').toLowerCase())
|
||||
}
|
||||
|
||||
@@ -95,12 +99,16 @@ router.get('/', requireSession, async (req, res) => {
|
||||
getDocument(config.collections.customers, customerId).catch(() => null),
|
||||
])
|
||||
const active = Boolean(sub && ACTIVE_STATUSES.has(sub.status))
|
||||
const ledger = sub ? await getLedger(customerId, { active }) : null
|
||||
const [ledger, offer] = await Promise.all([
|
||||
sub ? getLedger(customerId, { active }) : null,
|
||||
active ? null : getOpenOffer(customerId).catch(() => null),
|
||||
])
|
||||
return res.json({
|
||||
available: isStripeConfigured() && isPilotAllowed(customer),
|
||||
available: isStripeConfigured() && isPilotAllowed(customer, offer),
|
||||
testAccount: config.abo.testEmails.includes(String(customer?.email || '').toLowerCase()),
|
||||
termsVersion: config.abo.termsVersion,
|
||||
subscription: publicSubscription(sub),
|
||||
offer: offer ? offerView(offer) : null,
|
||||
ledger,
|
||||
})
|
||||
} catch (err) {
|
||||
@@ -114,7 +122,7 @@ router.post('/checkout', requireSession, async (req, res) => {
|
||||
if (req.session.viewAs) {
|
||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht bestellt werden.' })
|
||||
}
|
||||
const { acceptTerms, startChoice, termsVersion } = req.body || {}
|
||||
const { acceptTerms, startChoice, termsVersion, offerId } = req.body || {}
|
||||
if (acceptTerms !== true) {
|
||||
return res.status(400).json({ error: 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu bestellen.' })
|
||||
}
|
||||
@@ -125,18 +133,27 @@ router.post('/checkout', requireSession, async (req, res) => {
|
||||
return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' })
|
||||
}
|
||||
const customer = await getDocument(config.collections.customers, getSessionCustomerId(req))
|
||||
if (!isStripeConfigured() || !isPilotAllowed(customer)) {
|
||||
// Bestellt wird genau der Preis, den die Bestelluebersicht gezeigt hat
|
||||
const offer = await getOpenOffer(customer.$id)
|
||||
if ((offer?.$id || '') !== String(offerId || '')) {
|
||||
return res.status(409).json({ error: 'Ihr Angebot hat sich geändert. Bitte laden Sie die Seite neu.' })
|
||||
}
|
||||
if (!isStripeConfigured() || !isPilotAllowed(customer, offer)) {
|
||||
return res.status(403).json({ error: 'Die Online-Bestellung ist noch nicht freigeschaltet.' })
|
||||
}
|
||||
// Nachweis der Zustimmung (§ 305 Abs. 2, § 357a Abs. 2 BGB): Zeitpunkt, Version, Wahl, IP, Browser
|
||||
const now = new Date().toISOString()
|
||||
const url = await createAboCheckout(customer, {
|
||||
termsAcceptedAt: now,
|
||||
startChoice,
|
||||
earlyStartConsentAt: startChoice === 'sofort' ? now : '',
|
||||
consentIp: req.ip,
|
||||
consentUserAgent: req.get('user-agent') || '',
|
||||
})
|
||||
const url = await createAboCheckout(
|
||||
customer,
|
||||
{
|
||||
termsAcceptedAt: now,
|
||||
startChoice,
|
||||
earlyStartConsentAt: startChoice === 'sofort' ? now : '',
|
||||
consentIp: req.ip,
|
||||
consentUserAgent: req.get('user-agent') || '',
|
||||
},
|
||||
offer ? { id: offer.$id, stripeCouponId: offer.stripeCouponId, text: offerText(offer) } : null
|
||||
)
|
||||
return res.json({ url })
|
||||
} catch (err) {
|
||||
console.error('[abo] Checkout fehlgeschlagen:', err.message)
|
||||
@@ -179,6 +196,7 @@ async function sendContractConfirmation(subDoc) {
|
||||
'',
|
||||
' Vertrag: WEBklar Wartungs-Abo',
|
||||
' Preis: 50,00 € pro Monat (gemäß § 19 UStG ohne Umsatzsteuer)',
|
||||
...(subDoc.offerText ? [` Angebot: ${subDoc.offerText} (wie vereinbart)`] : []),
|
||||
` Vertragsschluss: ${fmtDate(subDoc.startedAt)}`,
|
||||
` Leistungsbeginn: ${lateStart ? fmtDate(subDoc.trialEnd) : fmtDate(subDoc.startedAt)}`,
|
||||
...(minTermEndOf(subDoc)
|
||||
@@ -250,10 +268,19 @@ async function handleStripeEvent(event) {
|
||||
case 'checkout.session.completed': {
|
||||
if (obj.mode !== 'subscription' || !obj.subscription) return
|
||||
const sub = await syncSubscription(obj.subscription)
|
||||
const offerProblem = await activateOfferFromCheckout(obj).then(
|
||||
() => '',
|
||||
(e) => {
|
||||
console.error('[abo] Angebot konnte nicht aktiviert werden:', e.message)
|
||||
return `ACHTUNG: Angebot konnte nicht automatisch angewendet werden (${e.message}) -> Rabatt in Stripe am Abo prüfen!`
|
||||
}
|
||||
)
|
||||
await sendAdminNotice(`Neues Wartungs-Abo: ${sub?.email || obj.customer_details?.email}`, [
|
||||
`Kunde: ${obj.customer_details?.name || ''} <${obj.customer_details?.email || ''}>`,
|
||||
`Appwrite-Kunde: ${sub?.customerId || obj.client_reference_id}`,
|
||||
`Stripe-Abo: ${obj.subscription}`,
|
||||
...(obj.metadata?.offerText ? [`Angebot: ${obj.metadata.offerText}`] : []),
|
||||
...(offerProblem ? [offerProblem] : []),
|
||||
])
|
||||
await sendContractConfirmation(sub).catch((e) =>
|
||||
console.error('[abo] Vertragsbestaetigung fehlgeschlagen:', e.message)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { listDocuments, Query } from '../services/appwriteAdmin.js'
|
||||
import { adminOfferView, createOffer, listOffers, offerContext, withdrawOffer } from '../services/offers.js'
|
||||
|
||||
/**
|
||||
* Individuelle Abo-Angebote im Admin-Panel (Tab "Abo-Angebote").
|
||||
* Eingehaengt in routes/portalAdmin.js unter /api/portal-admin/offers, dort hinter requireAdminSession.
|
||||
*/
|
||||
export const offersAdminRouter = Router()
|
||||
|
||||
const adminOf = (req) => ({
|
||||
name: req.session.adminName || req.session.name,
|
||||
email: req.session.adminEmail || req.session.email,
|
||||
})
|
||||
|
||||
function sendError(res, err, fallback) {
|
||||
const status = err.status || 500
|
||||
if (status >= 500) console.error('[offers]', fallback, err.message)
|
||||
return res.status(status).json({ error: err.message || fallback })
|
||||
}
|
||||
|
||||
offersAdminRouter.get('/', async (_req, res) => {
|
||||
try {
|
||||
const [docs, customers] = await Promise.all([
|
||||
listOffers(),
|
||||
listDocuments(config.collections.customers, [Query.limit(1000)]),
|
||||
])
|
||||
const byId = new Map(customers.map((c) => [c.$id, c]))
|
||||
return res.json({ offers: docs.map((d) => adminOfferView(d, byId.get(d.customerId))) })
|
||||
} catch (err) {
|
||||
return sendError(res, err, 'Angebote konnten nicht geladen werden')
|
||||
}
|
||||
})
|
||||
|
||||
offersAdminRouter.get('/context/:customerId', async (req, res) => {
|
||||
try {
|
||||
return res.json(await offerContext(req.params.customerId))
|
||||
} catch (err) {
|
||||
return sendError(res, err, 'Kunde konnte nicht geladen werden')
|
||||
}
|
||||
})
|
||||
|
||||
offersAdminRouter.post('/', async (req, res) => {
|
||||
try {
|
||||
const { doc, customer, mail } = await createOffer(req.body || {}, adminOf(req))
|
||||
return res.status(201).json({
|
||||
offer: adminOfferView(doc, customer),
|
||||
mailSent: mail.sent,
|
||||
mailError: mail.sent ? '' : mail.reason || '',
|
||||
})
|
||||
} catch (err) {
|
||||
return sendError(res, err, 'Angebot konnte nicht angelegt werden')
|
||||
}
|
||||
})
|
||||
|
||||
offersAdminRouter.post('/:id/withdraw', async (req, res) => {
|
||||
try {
|
||||
const doc = await withdrawOffer(req.params.id, adminOf(req))
|
||||
return res.json({ success: true, status: doc.status })
|
||||
} catch (err) {
|
||||
return sendError(res, err, 'Angebot konnte nicht zurückgezogen werden')
|
||||
}
|
||||
})
|
||||
@@ -17,6 +17,7 @@ import { listAppointments, saveAppointment } from '../services/appointments.js'
|
||||
import { getCustomerActivity } from '../services/customerActivity.js'
|
||||
import { listInvoicesForClient } from '../services/invoiceNinja.js'
|
||||
import { sanitizeMessage } from './chat.js'
|
||||
import { offersAdminRouter } from './offers.js'
|
||||
import { takeoverAdminRouter } from './takeoverCredits.js'
|
||||
|
||||
const router = Router()
|
||||
@@ -25,6 +26,8 @@ router.use(requireAdminSession)
|
||||
|
||||
// Uebernahmegutschriften: Pruefung und Entscheidung (Tab "Gutschriften")
|
||||
router.use('/takeover-credits', takeoverAdminRouter)
|
||||
// Individuelle Abo-Angebote (Tab "Abo-Angebote")
|
||||
router.use('/offers', offersAdminRouter)
|
||||
|
||||
function norm(value) {
|
||||
return String(value || '').toLowerCase()
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { config } from '../config.js'
|
||||
import { createDocument, deleteDocument, getDocument, listDocuments, Query, updateDocument } from './appwriteAdmin.js'
|
||||
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
|
||||
import { ACTIVE_STATUSES, getCurrentSubscription, getStripe, isStripeConfigured } from './stripeBilling.js'
|
||||
import { shortRef } from './takeoverCredits.js'
|
||||
|
||||
/**
|
||||
* Individuelle Abo-Angebote.
|
||||
*
|
||||
* Der Admin vereinbart mit einem Kunden einen Sonderpreis fuer die ersten Monate (z. B. 2 Monate
|
||||
* je 25 € statt 50 €) und legt ihn vorab im Admin-Panel an. Jedes Angebot bekommt sofort einen
|
||||
* eigenen, einmal einloesbaren Stripe-Coupon (amount_off, duration "repeating" ueber die
|
||||
* Angebotsmonate); danach gilt automatisch wieder der regulaere Preis.
|
||||
* - Kunde ohne Abo: Portal zeigt das Angebot, die Bestelluebersicht nennt den Angebotspreis und
|
||||
* der Checkout wendet den Coupon an. Testkonten bekommen im Checkout weiter ihren Testrabatt,
|
||||
* das Angebot wird nach dem Abschluss zusaetzlich ans Abo gehaengt (Webhook).
|
||||
* - Kunde mit laufendem Abo: Der Coupon gilt sofort fuer die naechsten Abbuchungen.
|
||||
*/
|
||||
|
||||
const COLLECTION = () => config.collections.aboOffers
|
||||
|
||||
export const OFFER_STATUS = { open: 'offen', active: 'aktiv', withdrawn: 'zurueckgezogen' }
|
||||
|
||||
const FOOTER = 'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · Telefon 0176 23726355 · support@webklar.com'
|
||||
|
||||
function httpError(status, message) {
|
||||
const err = new Error(message)
|
||||
err.status = status
|
||||
return err
|
||||
}
|
||||
|
||||
const euro = (cents) =>
|
||||
(Number(cents || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }).replace(/ /g, ' ')
|
||||
|
||||
const fmtDate = (iso) =>
|
||||
iso
|
||||
? new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
: '–'
|
||||
|
||||
const clean = (value, max) => String(value ?? '').trim().slice(0, max)
|
||||
|
||||
const todayBerlin = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
|
||||
|
||||
function addDaysYmd(ymd, days) {
|
||||
const d = new Date(`${ymd}T00:00:00Z`)
|
||||
d.setUTCDate(d.getUTCDate() + days)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const adminLabel = (admin) =>
|
||||
admin?.name ? `${admin.name}${admin.email ? ` <${admin.email}>` : ''}` : admin?.email || 'Admin'
|
||||
|
||||
const isTestEmail = (email) => config.abo.testEmails.includes(String(email || '').toLowerCase())
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Texte (eine Quelle fuer Portal, Bestelluebersicht, Checkout, Mails und Stripe)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** "Erste 2 Monate je 25,00 € statt 50,00 €, danach 50,00 € pro Monat" */
|
||||
export function offerText({ monthlyPriceCents: price, regularPriceCents: regular, months, scope }) {
|
||||
const running = scope === 'laufend'
|
||||
const lead = months === 1
|
||||
? (running ? 'Nächster Monat' : 'Erster Monat')
|
||||
: `${running ? 'Nächste' : 'Erste'} ${months} Monate`
|
||||
const amount = price === 0 ? 'kostenlos' : `${months === 1 ? '' : 'je '}${euro(price)} statt ${euro(regular)}`
|
||||
return `${lead} ${amount}, danach ${euro(regular)} pro Monat`
|
||||
}
|
||||
|
||||
/** Bezeichnung des Rabatts auf Stripe-Checkout und -Rechnungen (max. 40 Zeichen) */
|
||||
function couponName({ monthlyPriceCents: price, months }) {
|
||||
const amount = price === 0 ? 'kostenlos' : `${months === 1 ? '' : 'je '}${euro(price)}`
|
||||
return `Angebot: ${months === 1 ? '1 Monat' : `${months} Monate`} ${amount}`.slice(0, 40)
|
||||
}
|
||||
|
||||
const savingsOf = (doc) => (doc.regularPriceCents - doc.monthlyPriceCents) * doc.months
|
||||
|
||||
// validUntil = Kalendertag (UTC-Mitternacht gespeichert), gueltig bis einschliesslich dieses Tages in Berlin
|
||||
const isExpired = (doc) => Boolean(doc.validUntil) && todayBerlin() > String(doc.validUntil).slice(0, 10)
|
||||
|
||||
export function offerView(doc) {
|
||||
return {
|
||||
id: doc.$id,
|
||||
ref: shortRef(doc.$id),
|
||||
createdAt: doc.$createdAt,
|
||||
status: doc.status === OFFER_STATUS.open && isExpired(doc) ? 'abgelaufen' : doc.status,
|
||||
scope: doc.scope,
|
||||
months: doc.months,
|
||||
monthlyPriceCents: doc.monthlyPriceCents,
|
||||
regularPriceCents: doc.regularPriceCents,
|
||||
savingsCents: savingsOf(doc),
|
||||
text: offerText(doc),
|
||||
validUntil: doc.validUntil || null,
|
||||
note: doc.note || '',
|
||||
}
|
||||
}
|
||||
|
||||
function stripeDashboardUrl(kind, id) {
|
||||
if (!id) return ''
|
||||
const test = /^(sk|rk)_test_/.test(config.stripe.secretKey) ? 'test/' : ''
|
||||
return `https://dashboard.stripe.com/${test}${kind}/${id}`
|
||||
}
|
||||
|
||||
export function adminOfferView(doc, customer) {
|
||||
return {
|
||||
...offerView(doc),
|
||||
customerId: doc.customerId,
|
||||
customerName: customer?.name || customer?.companyName || '(Kunde nicht gefunden)',
|
||||
customerCode: customer?.code || '',
|
||||
internalNote: doc.internalNote || '',
|
||||
createdBy: doc.createdBy || '',
|
||||
notifiedAt: doc.notifiedAt || null,
|
||||
appliedAt: doc.appliedAt || null,
|
||||
withdrawnAt: doc.withdrawnAt || null,
|
||||
withdrawnBy: doc.withdrawnBy || '',
|
||||
stripeCouponId: doc.stripeCouponId || '',
|
||||
stripeCouponUrl: stripeDashboardUrl('coupons', doc.stripeCouponId),
|
||||
stripeSubscriptionId: doc.stripeSubscriptionId || '',
|
||||
stripeSubscriptionUrl: stripeDashboardUrl('subscriptions', doc.stripeSubscriptionId),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lesen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function listOffers() {
|
||||
return listDocuments(COLLECTION(), [Query.orderDesc('$createdAt'), Query.limit(1000)])
|
||||
}
|
||||
|
||||
/** Offenes, noch gueltiges Angebot eines Kunden (hoechstens eines, siehe createOffer). */
|
||||
export async function getOpenOffer(customerId) {
|
||||
if (!customerId) return null
|
||||
const docs = await listDocuments(COLLECTION(), [
|
||||
Query.equal('customerId', customerId),
|
||||
Query.equal('status', OFFER_STATUS.open),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(20),
|
||||
])
|
||||
return docs.find((d) => !isExpired(d)) || null
|
||||
}
|
||||
|
||||
/** Fuer das Angebotsformular: Abo-Stand, offenes Angebot, Portalzugang, Testkonto. */
|
||||
export async function offerContext(customerId) {
|
||||
const customer = await getDocument(config.collections.customers, customerId).catch(() => null)
|
||||
if (!customer) throw httpError(404, 'Kunde nicht gefunden')
|
||||
const [sub, open] = await Promise.all([getCurrentSubscription(customerId), getOpenOffer(customerId)])
|
||||
return {
|
||||
customer: {
|
||||
id: customer.$id,
|
||||
name: customer.name || customer.companyName || '',
|
||||
code: customer.code || '',
|
||||
email: customer.email || '',
|
||||
portalAccessEnabled: Boolean(customer.portalAccessEnabled),
|
||||
testAccount: isTestEmail(customer.email),
|
||||
},
|
||||
subscription: sub
|
||||
? { status: sub.status, active: ACTIVE_STATUSES.has(sub.status), currentPeriodEnd: sub.currentPeriodEnd || null }
|
||||
: null,
|
||||
openOffer: open ? offerView(open) : null,
|
||||
regularPriceCents: config.stripe.priceAmountCents,
|
||||
maxMonths: config.offers.maxMonths,
|
||||
defaultValidUntil: addDaysYmd(todayBerlin(), config.offers.defaultValidityDays),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stripe
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function couponIdOf(discount) {
|
||||
if (!discount || typeof discount === 'string') return null
|
||||
// Seit API 2025-03 steckt der Coupon unter discount.source.coupon
|
||||
const c = discount.source?.coupon ?? discount.coupon
|
||||
return typeof c === 'string' ? c : c?.id || null
|
||||
}
|
||||
|
||||
/** Coupon zusaetzlich zu vorhandenen Rabatten ans Abo haengen (idempotent). */
|
||||
async function attachCouponToSubscription(subscriptionId, couponId, metadata) {
|
||||
const stripe = getStripe()
|
||||
const sub = await stripe.subscriptions.retrieve(subscriptionId, { expand: ['discounts'] })
|
||||
const existing = (sub.discounts || []).filter(Boolean)
|
||||
if (existing.some((d) => couponIdOf(d) === couponId)) return sub
|
||||
return stripe.subscriptions.update(subscriptionId, {
|
||||
discounts: [...existing.map((d) => ({ discount: typeof d === 'string' ? d : d.id })), { coupon: couponId }],
|
||||
// Leeres metadata-Objekt wuerde bei Stripe ALLE Schluessel loeschen (auch webklarCustomerId)
|
||||
...(metadata ? { metadata } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin: anlegen und zurueckziehen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function validateOfferInput(body) {
|
||||
const months = Number(body?.months)
|
||||
if (!Number.isInteger(months) || months < 1 || months > config.offers.maxMonths) {
|
||||
throw httpError(400, `Bitte 1 bis ${config.offers.maxMonths} Angebotsmonate wählen.`)
|
||||
}
|
||||
const regularPriceCents = config.stripe.priceAmountCents
|
||||
const monthlyPriceCents = Number(body?.monthlyPriceCents)
|
||||
if (!Number.isInteger(monthlyPriceCents) || monthlyPriceCents < 0 || monthlyPriceCents >= regularPriceCents) {
|
||||
throw httpError(400, `Der Angebotspreis muss zwischen 0,00 € und ${euro(regularPriceCents - 1)} liegen.`)
|
||||
}
|
||||
const today = todayBerlin()
|
||||
const validUntil = /^\d{4}-\d{2}-\d{2}$/.test(String(body?.validUntil || ''))
|
||||
? body.validUntil
|
||||
: addDaysYmd(today, config.offers.defaultValidityDays)
|
||||
if (validUntil < today || validUntil > addDaysYmd(today, config.offers.maxValidityDays)) {
|
||||
throw httpError(400, 'Bitte ein Gültigkeitsdatum ab heute und höchstens ein Jahr im Voraus wählen.')
|
||||
}
|
||||
return {
|
||||
months,
|
||||
monthlyPriceCents,
|
||||
regularPriceCents,
|
||||
validUntil: `${validUntil}T00:00:00.000Z`,
|
||||
note: clean(body?.note, 500),
|
||||
internalNote: clean(body?.internalNote, 2000),
|
||||
notifyCustomer: body?.notifyCustomer !== false,
|
||||
}
|
||||
}
|
||||
|
||||
async function mailOffer(doc, customer, sub) {
|
||||
const running = doc.scope === 'laufend'
|
||||
const lines = [
|
||||
`Guten Tag ${customer.name || ''}`.trim() + ',',
|
||||
'',
|
||||
running
|
||||
? `wie besprochen gilt für Ihr WEBklar Wartungs-Abo ab der nächsten Abbuchung${sub?.currentPeriodEnd ? ` am ${fmtDate(sub.currentPeriodEnd)}` : ''}:`
|
||||
: 'wie besprochen haben wir Ihnen im Kundenportal ein persönliches Angebot für das WEBklar Wartungs-Abo hinterlegt:',
|
||||
'',
|
||||
` Angebot: ${offerText(doc)}`,
|
||||
` Ihre Ersparnis: ${euro(savingsOf(doc))}`,
|
||||
...(running ? [] : [` Gültig bis: ${fmtDate(doc.validUntil)}`]),
|
||||
...(doc.note ? [` Hinweis: ${doc.note}`] : []),
|
||||
'',
|
||||
...(running
|
||||
? ['Der Rabatt wird bei den Abbuchungen automatisch berücksichtigt und erscheint auf Ihren Rechnungen.']
|
||||
: [
|
||||
`Sie können das Abo zu diesem Preis bis zum ${fmtDate(doc.validUntil)} im Kundenportal abschließen:`,
|
||||
`${config.publicBaseUrl}/dashboard.html`,
|
||||
'',
|
||||
'Es gelten unsere Vertragsbedingungen (Mindestlaufzeit 12 Monate, danach monatlich kündbar; gemäß § 19 UStG',
|
||||
`ohne Umsatzsteuer): ${config.publicBaseUrl}/abo-bedingungen.html`,
|
||||
]),
|
||||
'',
|
||||
'Viele Grüße',
|
||||
'Ihr WEBklar-Team',
|
||||
'',
|
||||
FOOTER,
|
||||
]
|
||||
await sendCustomerMail(
|
||||
customer.email,
|
||||
running ? 'Ihr Angebot für das WEBklar Wartungs-Abo ist eingerichtet' : 'Ihr persönliches Angebot für das WEBklar Wartungs-Abo',
|
||||
lines
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Angebot anlegen: Dokument -> Stripe-Coupon -> (bei laufendem Abo) sofort anwenden -> Mail.
|
||||
* Schlaegt ein Stripe-Schritt fehl, wird alles Angelegte wieder entfernt.
|
||||
*/
|
||||
export async function createOffer(body, admin) {
|
||||
if (!isStripeConfigured()) throw httpError(503, 'Stripe ist nicht eingerichtet.')
|
||||
const input = validateOfferInput(body)
|
||||
const customer = await getDocument(config.collections.customers, String(body?.customerId || '')).catch(() => null)
|
||||
if (!customer) throw httpError(404, 'Kunde nicht gefunden')
|
||||
if (await getOpenOffer(customer.$id)) {
|
||||
throw httpError(409, 'Für diesen Kunden gibt es schon ein offenes Angebot. Bitte das alte zuerst zurückziehen.')
|
||||
}
|
||||
const sub = await getCurrentSubscription(customer.$id)
|
||||
const running = Boolean(sub && ACTIVE_STATUSES.has(sub.status))
|
||||
|
||||
const id = randomUUID()
|
||||
const { notifyCustomer, ...fields } = input
|
||||
let doc = await createDocument(
|
||||
COLLECTION(),
|
||||
{
|
||||
...fields,
|
||||
customerId: customer.$id,
|
||||
status: OFFER_STATUS.open,
|
||||
scope: running ? 'laufend' : 'neu',
|
||||
stripeCouponId: `WEBKLAR-ANGEBOT-${shortRef(id)}`,
|
||||
createdBy: adminLabel(admin),
|
||||
},
|
||||
id
|
||||
)
|
||||
|
||||
const stripe = getStripe()
|
||||
try {
|
||||
await stripe.coupons.create({
|
||||
id: doc.stripeCouponId,
|
||||
name: couponName(doc),
|
||||
amount_off: doc.regularPriceCents - doc.monthlyPriceCents,
|
||||
currency: 'eur',
|
||||
duration: 'repeating',
|
||||
duration_in_months: doc.months,
|
||||
max_redemptions: 1,
|
||||
metadata: { webklar: 'abo-angebot', webklarOfferId: id, webklarCustomerId: customer.$id },
|
||||
})
|
||||
} catch (err) {
|
||||
await deleteDocument(COLLECTION(), id).catch(() => {})
|
||||
throw httpError(502, `Stripe hat den Coupon abgelehnt: ${err.message}`)
|
||||
}
|
||||
|
||||
if (running) {
|
||||
try {
|
||||
await attachCouponToSubscription(sub.stripeSubscriptionId, doc.stripeCouponId, {
|
||||
offerId: id,
|
||||
offerText: offerText(doc).slice(0, 300),
|
||||
})
|
||||
} catch (err) {
|
||||
await stripe.coupons.del(doc.stripeCouponId).catch(() => {})
|
||||
await deleteDocument(COLLECTION(), id).catch(() => {})
|
||||
throw httpError(502, `Stripe: Angebot konnte nicht auf das laufende Abo angewendet werden (${err.message})`)
|
||||
}
|
||||
doc = await updateDocument(COLLECTION(), id, {
|
||||
status: OFFER_STATUS.active,
|
||||
appliedAt: new Date().toISOString(),
|
||||
stripeSubscriptionId: sub.stripeSubscriptionId,
|
||||
})
|
||||
}
|
||||
|
||||
let mail = { sent: false, reason: 'nicht gewünscht' }
|
||||
if (notifyCustomer) {
|
||||
if (!customer.email) {
|
||||
mail = { sent: false, reason: 'Kunde hat keine E-Mail-Adresse' }
|
||||
} else {
|
||||
mail = await mailOffer(doc, customer, sub).then(
|
||||
() => ({ sent: true }),
|
||||
(e) => ({ sent: false, reason: e.message })
|
||||
)
|
||||
if (mail.sent) doc = await updateDocument(COLLECTION(), id, { notifiedAt: new Date().toISOString() })
|
||||
}
|
||||
}
|
||||
return { doc, customer, mail }
|
||||
}
|
||||
|
||||
export async function withdrawOffer(id, admin) {
|
||||
const doc = await getDocument(COLLECTION(), id)
|
||||
if (doc.status !== OFFER_STATUS.open) {
|
||||
throw httpError(409, 'Nur offene Angebote können zurückgezogen werden. Ein aktives Angebot bitte in Stripe am Abo entfernen.')
|
||||
}
|
||||
if (doc.stripeCouponId && isStripeConfigured()) {
|
||||
await getStripe()
|
||||
.coupons.del(doc.stripeCouponId)
|
||||
.catch((err) => {
|
||||
if (err.code !== 'resource_missing') throw httpError(502, `Stripe: Coupon konnte nicht gelöscht werden (${err.message})`)
|
||||
})
|
||||
}
|
||||
return updateDocument(COLLECTION(), id, {
|
||||
status: OFFER_STATUS.withdrawn,
|
||||
withdrawnAt: new Date().toISOString(),
|
||||
withdrawnBy: adminLabel(admin),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Checkout abgeschlossen (Stripe-Webhook)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Angebot aus den Checkout-Metadaten als eingeloest markieren (Testkonto: Coupon jetzt anhaengen). */
|
||||
export async function activateOfferFromCheckout(session) {
|
||||
const offerId = session.metadata?.offerId
|
||||
const subscriptionId = typeof session.subscription === 'string' ? session.subscription : session.subscription?.id
|
||||
if (!offerId || !subscriptionId) return null
|
||||
const doc = await getDocument(COLLECTION(), offerId).catch(() => null)
|
||||
if (!doc || doc.status === OFFER_STATUS.active) return doc
|
||||
if (doc.status !== OFFER_STATUS.open) {
|
||||
await sendAdminNotice(`Angebot ${shortRef(offerId)} war beim Abschluss nicht mehr offen`, [
|
||||
`Abo ${subscriptionId} wurde mit dem Angebot „${offerText(doc)}“ abgeschlossen, das Angebot ist aber „${doc.status}“.`,
|
||||
'Bitte in Stripe prüfen, ob der Rabatt am Abo hängt, und ggf. korrigieren.',
|
||||
])
|
||||
return null
|
||||
}
|
||||
if (session.metadata?.offerDeferred === '1') {
|
||||
// Testkonto: Im Checkout lief der Testrabatt, das Angebot kommt jetzt zusaetzlich ans Abo
|
||||
await attachCouponToSubscription(subscriptionId, doc.stripeCouponId)
|
||||
}
|
||||
return updateDocument(COLLECTION(), offerId, {
|
||||
status: OFFER_STATUS.active,
|
||||
appliedAt: new Date().toISOString(),
|
||||
stripeSubscriptionId: subscriptionId,
|
||||
})
|
||||
}
|
||||
@@ -175,6 +175,8 @@ export async function syncSubscription(subscriptionId) {
|
||||
consentIp: meta.consentIp || null,
|
||||
consentUserAgent: meta.consentUserAgent || null,
|
||||
consentTextVersion: meta.consentTextVersion || null,
|
||||
offerId: meta.offerId || null,
|
||||
offerText: meta.offerText || null,
|
||||
})
|
||||
return doc
|
||||
}
|
||||
@@ -243,9 +245,10 @@ export async function grantCreditForInvoice(invoice) {
|
||||
const ref = subscriptionIdOfInvoice(invoice)
|
||||
const subscriptionId = typeof ref === 'string' ? ref : ref?.id
|
||||
if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' }
|
||||
// total statt amount_paid: Eine Monatsgebuehr, die ein Guthaben (Uebernahmegutschrift) deckt,
|
||||
// ist bezahlt, obwohl nichts abgebucht wurde. Nur Probezeit-Rechnungen (0 €) zaehlen nicht.
|
||||
if (!(invoice.total > 0)) return { skipped: 'Betrag 0' }
|
||||
// subtotal (vor Rabatt und Guthaben) statt amount_paid: Auch ein Monat, den ein Angebot oder eine
|
||||
// Uebernahmegutschrift ganz deckt, ist ein bezahlter Leistungsmonat. Nur Probezeit-Rechnungen
|
||||
// haben eine Zwischensumme von 0 € und bringen keine Stunde.
|
||||
if (!(invoice.subtotal > 0)) return { skipped: 'Betrag 0' }
|
||||
|
||||
const sub = await syncSubscription(subscriptionId)
|
||||
if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' }
|
||||
@@ -327,8 +330,10 @@ async function ensureTestCoupon() {
|
||||
* abgegeben ("Zahlungspflichtig bestellen"); hier hinterlegt er die Zahlungsart.
|
||||
* startChoice: 'sofort' (Leistung und erste Abbuchung sofort) oder 'nach_frist'
|
||||
* (15 Tage Probezeit ohne Leistung und ohne Abbuchung, Start nach der Widerrufsfrist).
|
||||
* offer: individuelles Angebot { id, stripeCouponId, text } -> Coupon im Checkout; bei Testkonten
|
||||
* bleibt der Testrabatt im Checkout und das Angebot wird nach dem Abschluss angehaengt (offerDeferred).
|
||||
*/
|
||||
export async function createAboCheckout(customer, consent) {
|
||||
export async function createAboCheckout(customer, consent, offer = null) {
|
||||
const current = await getCurrentSubscription(customer.$id)
|
||||
if (current && ACTIVE_STATUSES.has(current.status)) {
|
||||
const err = new Error('Sie haben das Wartungs-Abo bereits abgeschlossen.')
|
||||
@@ -354,7 +359,13 @@ export async function createAboCheckout(customer, consent) {
|
||||
earlyStartConsentAt: consent.earlyStartConsentAt || '',
|
||||
consentIp: String(consent.consentIp || '').slice(0, 64),
|
||||
consentUserAgent: String(consent.consentUserAgent || '').slice(0, 300),
|
||||
...(offer
|
||||
? { offerId: offer.id, offerText: offer.text.slice(0, 300), ...(testCoupon ? { offerDeferred: '1' } : {}) }
|
||||
: {}),
|
||||
}
|
||||
// Checkout erlaubt nur einen Rabatt: Testkonto -> Testrabatt, sonst der Angebots-Coupon
|
||||
const coupon = testCoupon || offer?.stripeCouponId || null
|
||||
const offerPrice = offer ? ` Preis wie vereinbart: ${offer.text}.` : ''
|
||||
const params = {
|
||||
mode: 'subscription',
|
||||
customer: stripeCustomerId,
|
||||
@@ -370,12 +381,14 @@ export async function createAboCheckout(customer, consent) {
|
||||
...(lateStart ? { trial_period_days: config.abo.lateStartTrialDays } : {}),
|
||||
},
|
||||
metadata: meta,
|
||||
...(testCoupon ? { discounts: [{ coupon: testCoupon }] } : {}),
|
||||
...(coupon ? { discounts: [{ coupon }] } : {}),
|
||||
custom_text: {
|
||||
submit: {
|
||||
message: lateStart
|
||||
? 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit dem Abschluss kommt der Vertrag zustande. Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab. Danach 50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar.'
|
||||
: 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit der ersten Zahlung kommt der Vertrag zustande. 50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar.',
|
||||
message: offer
|
||||
? `Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit ${lateStart ? 'dem Abschluss' : 'der ersten Zahlung'} kommt der Vertrag zustande.${lateStart ? ' Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab.' : ''}${offerPrice} Mindestlaufzeit 12 Monate, danach monatlich kündbar.`
|
||||
: lateStart
|
||||
? 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit dem Abschluss kommt der Vertrag zustande. Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab. Danach 50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar.'
|
||||
: 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit der ersten Zahlung kommt der Vertrag zustande. 50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar.',
|
||||
},
|
||||
},
|
||||
success_url: `${config.publicBaseUrl}/dashboard.html?abo=success`,
|
||||
|
||||
Reference in new issue
Block a user