Abo-Angebote: Admin sieht, bis wann der Rabatt läuft
"Gültig bis" ist nur die Frist zum Annehmen. Jetzt steht auch das Rabattende da: - Formular "Neues Angebot", Kunde ohne Abo: Schätzung "Rabatt läuft N Monate ab Abschluss und endet voraussichtlich zwischen <heute + N> und <gültig bis + N>", mit Hinweis auf den späteren Start nach der Widerrufsfrist (gut 2 Wochen) - Formular, laufendes Abo: genaues Ende aus den nächsten Abbuchungen (Anker = Leistungsbeginn, Monatsende wie bei Stripe), auch in der Erfolgsmeldung - Angebotsliste: offen -> Schätzung, aktiv -> "Rabatt endet am ..." aus dem Abo-Dokument (bzw. "endete", "endet mit dem Abo am ...", "unbekannt (Abo nicht gefunden)") Rechnung in server/services/offerDiscount.js: Der Coupon deckt die N Abbuchungen ab dem Anwenden. Beim Checkout ist das der Abo-Start, bei laufendem Abo und Testkonto (Coupon per Webhook) der Zeitpunkt des Anhängens; Testkonten laufen deshalb einen Monat länger. Tests: npm test (37 Fälle, davon 8 neu). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c600118374
commit
054b04d5b6
5 files changed
+227
-12
No files matched your search
+60
-5
@@ -804,6 +804,44 @@ function offerPreviewText(price, months, regular, running) {
|
||||
|
||||
// Datumsfeld "2026-10-27" -> "27.10.2026", ohne Umweg über Date (keine Zeitzonen-Verschiebung)
|
||||
const fmtYmd = (ymd) => String(ymd || '').split('-').reverse().join('.')
|
||||
// Zeitpunkt -> Kalendertag in Deutschland (YYYY-MM-DD); Abbuchungen und Fristen zählen nach deutschem Kalender
|
||||
const berlinYmd = (value) => new Date(value).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
|
||||
const fmtBerlin = (value) => fmtYmd(berlinYmd(value))
|
||||
|
||||
// Kalendertag + n Monate, Monatsende wie bei Stripe (31.01. + 1 Monat = 28./29.02.)
|
||||
function addMonthsYmd(ymd, n) {
|
||||
const [y, m, d] = ymd.split('-').map(Number)
|
||||
const last = new Date(Date.UTC(y, m + n, 0)).getUTCDate()
|
||||
return new Date(Date.UTC(y, m - 1 + n, Math.min(d, last))).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/* Rabattende eines noch nicht eingelösten Angebots: Der Rabatt läuft N Monate ab Abschluss, der Kunde
|
||||
kann heute oder spätestens am letzten Gültigkeitstag abschließen. Mit Beginn nach der Widerrufsfrist
|
||||
starten Leistung und Rabatt gut 2 Wochen später (deadlines.js lateStartAt). */
|
||||
function offerDiscountEstimate(months, validUntilYmd, testAccount = false) {
|
||||
const today = berlinYmd(new Date())
|
||||
// Testkonto: Coupon kommt erst nach der ersten Abbuchung ans Abo (offers.js), der Rabatt endet einen Monat später
|
||||
const n = months + (testAccount ? 1 : 0)
|
||||
return { earliest: addMonthsYmd(today, n), latest: addMonthsYmd(validUntilYmd < today ? today : validUntilYmd, n) }
|
||||
}
|
||||
|
||||
// Zeile "Rabatt endet …" in der Angebotsliste
|
||||
function offerDiscountLine(o) {
|
||||
if (o.status === 'offen' && o.validUntil) {
|
||||
const { earliest, latest } = offerDiscountEstimate(o.months, String(o.validUntil).slice(0, 10), o.testAccount)
|
||||
return earliest === latest
|
||||
? `Rabatt endet voraussichtlich am ${fmtYmd(earliest)}`
|
||||
: `Rabatt endet voraussichtlich zwischen ${fmtYmd(earliest)} und ${fmtYmd(latest)}`
|
||||
}
|
||||
if (o.status !== 'aktiv' || !o.discount) return ''
|
||||
const d = o.discount
|
||||
if (!d.known) return 'Rabattende unbekannt (Abo nicht gefunden)'
|
||||
const end = d.subscriptionEndsAt || d.endsAt
|
||||
const past = new Date(end) <= new Date()
|
||||
return d.subscriptionEndsAt
|
||||
? `Rabatt ${past ? 'endete' : 'endet'} mit dem Abo am ${fmtBerlin(end)}`
|
||||
: `Rabatt ${past ? 'endete' : 'endet'} am ${fmtBerlin(end)}`
|
||||
}
|
||||
|
||||
function showOfferMessage(text) {
|
||||
const el = document.getElementById('offer-message')
|
||||
@@ -820,17 +858,21 @@ async function loadOffers() {
|
||||
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) => `
|
||||
tbody.innerHTML = shown.map((o) => {
|
||||
const discount = offerDiscountLine(o)
|
||||
return `
|
||||
<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>
|
||||
${discount ? `<br><span class="muted">${escapeHtml(discount)}</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('')
|
||||
</tr>`
|
||||
}).join('')
|
||||
tbody.querySelectorAll('[data-withdraw-offer]').forEach((btn) =>
|
||||
btn.addEventListener('click', () => withdrawOfferAction(btn.dataset.withdrawOffer, btn)))
|
||||
} catch (err) {
|
||||
@@ -902,13 +944,23 @@ function renderOfferPreview(form) {
|
||||
}
|
||||
if (ok) {
|
||||
lines.push(`${offerPreviewText(v.monthlyPriceCents, v.months, regular, running)} · Ersparnis ${euroFromCents((regular - v.monthlyPriceCents) * v.months)}`)
|
||||
if (!running) lines.push(`Angebot gültig bis ${fmtYmd(v.validUntil)}`)
|
||||
if (running) {
|
||||
const charges = ctx.subscription.upcomingCharges || []
|
||||
if (charges.length > v.months) lines.push(`Rabatt endet am ${fmtBerlin(charges[v.months])} (nach ${v.months === 1 ? '1 Abbuchung' : `${v.months} Abbuchungen`}).`)
|
||||
} else {
|
||||
lines.push(`Angebot gültig bis ${fmtYmd(v.validUntil)}`)
|
||||
const { earliest, latest } = offerDiscountEstimate(v.months, v.validUntil, ctx.customer.testAccount)
|
||||
const lead = `Rabatt läuft ${v.months === 1 ? '1 Monat' : `${v.months} Monate`} ab Abschluss und endet voraussichtlich`
|
||||
lines.push(`${earliest === latest
|
||||
? `${lead} am ${fmtYmd(earliest)}.`
|
||||
: `${lead} zwischen ${fmtYmd(earliest)} (Abschluss heute) und ${fmtYmd(latest)} (Abschluss am letzten Tag).`} Bei Beginn nach der Widerrufsfrist gut 2 Wochen später.`)
|
||||
}
|
||||
}
|
||||
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.')
|
||||
if (ctx.customer.testAccount) lines.push(`Testkonto: Im Checkout bleibt der 0,50-€-Testrabatt, das Angebot wird danach zusätzlich angehängt${running ? '' : ' und gilt deshalb erst ab der zweiten Abbuchung'}.`)
|
||||
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'
|
||||
@@ -943,8 +995,11 @@ async function saveOffer(form) {
|
||||
const result = await api('/api/portal-admin/offers', { method: 'POST', body: JSON.stringify(v) })
|
||||
const o = result.offer
|
||||
form.classList.add('hidden')
|
||||
const until = o.scope !== 'laufend'
|
||||
? `, gültig bis ${fmtDate(o.validUntil)}`
|
||||
: o.discount?.known ? `, Rabatt endet am ${fmtBerlin(o.discount.endsAt)}` : ''
|
||||
showOfferMessage([
|
||||
`Angebot ${o.ref} für ${o.customerName} angelegt: ${o.text}${o.scope === 'laufend' ? '' : `, gültig bis ${fmtDate(o.validUntil)}`}.`,
|
||||
`Angebot ${o.ref} für ${o.customerName} angelegt: ${o.text}${until}.`,
|
||||
`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(' '))
|
||||
|
||||
Reference in new issue
Block a user