Files
Webklar-Kundenbereich/server/services/offers.js
T
ClaudeandClaude Opus 5.5 4593065f79 Kundenbereich: neues Design mit Seitenleiste (Design "Kundenbereich Neu")
- Kopfzeile mit Firmenname, Kundennr., Initialen-Avatar und Abmelden
- Seitenleiste: Navigation Projekte/Fortschritt/Rechnungen/Chat mit Zaehlern
  (offene Rechnungen, ungelesene Nachrichten), "Dein Ansprechpartner"
  (Mitarbeiter des juengsten zugewiesenen Tickets, sonst WEBklar-Team) und
  Wartungs-Abo-Karte (Angebot bzw. Status, Stunden, naechste Abbuchung,
  Abo verwalten, Uebernahmegutschrift, Widerruf, Mindestlaufzeit, Kuendigen)
- Projektkarte: Vorschau mit "Website oeffnen", Status, Vorschau-Adresse,
  Live-Domain, letzte Aenderung, Schnellzugriffe, Projektdetails
- Abo-Bestellung und Stundenkonto/Verlauf sowie Uebernahmegutschrift als
  eigene Ansichten; Direktlinks #abo/#gutschrift (auch ueber den Login)
- Pflichtlinks (Kuendigungsbutton, Widerruf, Bedingungen, Datenschutz,
  Impressum) als Fusszeile; Systemschriften statt externer Fonts
- Mobil: Navigation im 2x2-Raster, kein horizontales Scrollen
- API: /api/contact, updatedAt in /api/projects; Mail-Links auf Ansichten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 21:03:14 +00:00

386 lines
16 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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#abo`,
'',
'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,
})
}