Team-API, Sidebar-Zaehler und Format-Hilfen fuer Gutschriften, Angebote und E-Mail-Speicher

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-02 02:55:48 +00:00
1 parent 813c6a7e18
commit 91d526e909
13 files changed
+487 -47

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
import { berlinYmd, fmtBerlin, fmtDate, fmtYmd, formatCents, PRODUCT_LABEL } from './format'
// Abo-Angebote: Texte, Rabattende und Formularpruefung wortgleich aus dem Admin-Panel (public/admin.js 984-1317).
// "today" ist der Kalendertag in Deutschland (YYYY-MM-DD) und fuer Tests injizierbar.
export const OFFER_STATUS = {
offen: { tone: 'warn', label: 'offen' },
aktiv: { tone: 'ok', label: 'aktiv' },
abgelaufen: { tone: 'muted', label: 'abgelaufen' },
zurueckgezogen: { tone: 'muted', label: 'zurückgezogen' },
}
export const offerStatus = (status) => OFFER_STATUS[status] || { tone: 'muted', label: status }
const todayBerlin = () => berlinYmd(new Date())
export function offerPreviewText(price, months, regular, running) {
const lead = months === 1 ? (running ? 'Nächster Monat' : 'Erster Monat') : `${running ? 'Nächste' : 'Erste'} ${months} Monate`
const amount = price === 0 ? 'kostenlos' : `${months === 1 ? '' : 'je '}${formatCents(price)} statt ${formatCents(regular)}`
return `${lead} ${amount}, danach ${formatCents(regular)} pro Monat`
}
/** Kalendertag + n Monate, Monatsende wie bei Stripe (31.01. + 1 Monat = 28./29.02.) */
export 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 eingeloesten Angebots (Abschluss heute bis letzter Gueltigkeitstag) */
export function offerDiscountEstimate(months, validUntilYmd, testAccount = false, today = todayBerlin()) {
const n = months + (testAccount ? 1 : 0)
return { earliest: addMonthsYmd(today, n), latest: addMonthsYmd(validUntilYmd < today ? today : validUntilYmd, n) }
}
export function discountLine(o, today = todayBerlin()) {
if (o.status === 'offen' && o.validUntil) {
const { earliest, latest } = offerDiscountEstimate(o.months, String(o.validUntil).slice(0, 10), o.testAccount, today)
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)}`
}
export const isRunning = (ctx, productKey) => Boolean(ctx?.subscription?.active && ctx.subscription.productKey === productKey)
/**
* Pruefung und Vorschau des Formulars "Neues Angebot" (admin.js renderOfferPreview).
* -> { errors, running, saveLabel, previewLines }; Speichern nur ohne errors.
*/
export function evaluateOfferForm(ctx, f, today = todayBerlin()) {
const regular = ctx.products[f.productKey].regularPriceCents
const running = isRunning(ctx, f.productKey)
const openOffer = ctx.openOffers[f.productKey]
const errors = []
if (openOffer) errors.push(`Es gibt schon ein offenes ${PRODUCT_LABEL[f.productKey]}-Angebot (${openOffer.ref}: ${openOffer.text}) – bitte zuerst zurückziehen.`)
if (!Number.isInteger(f.monthlyPriceCents) || f.monthlyPriceCents < 0 || f.monthlyPriceCents >= regular) {
errors.push(`Angebotspreis bitte zwischen 0,00 € und ${formatCents(regular - 1)} angeben.`)
}
if (!f.validUntil && !running) errors.push('Bitte „Gültig bis“ angeben.')
const previewLines = []
if (!errors.length) {
previewLines.push(`${offerPreviewText(f.monthlyPriceCents, f.months, regular, running)} · Ersparnis ${formatCents((regular - f.monthlyPriceCents) * f.months)}`)
if (running) {
const charges = ctx.subscription.upcomingCharges || []
if (charges.length > f.months) previewLines.push(`Rabatt endet am ${fmtBerlin(charges[f.months])} (nach ${f.months === 1 ? '1 Abbuchung' : `${f.months} Abbuchungen`}).`)
} else {
previewLines.push(`Angebot gültig bis ${fmtYmd(f.validUntil)}`)
const { earliest, latest } = offerDiscountEstimate(f.months, f.validUntil, ctx.customer.testAccount, today)
const lead = `Rabatt läuft ${f.months === 1 ? '1 Monat' : `${f.months} Monate`} ab Abschluss und endet voraussichtlich`
previewLines.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.`)
}
}
previewLines.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) previewLines.push('Achtung: Kunde hat keinen Portalzugang – er kann erst bestellen, wenn der Zugang freigeschaltet ist.')
if (ctx.customer.testAccount) previewLines.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'}.`)
return { errors, running, saveLabel: running ? 'Angebot anlegen und aufs laufende Abo anwenden' : 'Angebot anlegen', previewLines }
}
export function offerConfirm(ctx, f, running) {
const text = offerPreviewText(f.monthlyPriceCents, f.months, ctx.products[f.productKey].regularPriceCents, running)
return `Angebot für ${ctx.customer.name || 'den Kunden'} anlegen?\n\n${text}${running ? '' : `\nGültig bis ${fmtYmd(f.validUntil)}`}\n\nDer Coupon wird sofort in Stripe angelegt${running ? ' und auf das laufende Abo angewendet' : ''}.`
}
export function createdMessage(result, notifyCustomer) {
const o = result.offer
const until = o.scope !== 'laufend'
? `, gültig bis ${fmtDate(o.validUntil)}`
: o.discount?.known ? `, Rabatt endet am ${fmtBerlin(o.discount.endsAt)}` : ''
return [
`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' : ''}.`,
notifyCustomer
? (result.mailSent
? `Die E-Mail an ${o.customerEmail} ist raus.`
: `E-Mail an den Kunden fehlgeschlagen (${result.mailError})${o.mailSendable ? ', sie lässt sich in der Liste erneut senden' : ''}.`)
: '',
].filter(Boolean).join(' ')
}