Abo-Angebote: für Hosting- oder Wartungs-Abo

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5 committed 2026-09-29 10:08:59 +00:00
1 parent 7ffe95f24a
commit e5cc2f3c82
6 files changed
+303 -45

No files matched your search

@@ -4,16 +4,16 @@
<meta charset="utf-8">
<!--
K05 · Persönliches Angebot (Kunde ohne Abo)
Betreff: Ihr persönliches Angebot für das WEBklar Wartungs-Abo
Betreff: Ihr persönliches Angebot für das WEBklar {{produkt}}
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail aus den Kundendaten) · BCC intern
Platzhalter: {{name}}, {{angebot_text}}, {{ersparnis}}, {{gueltig_bis}}, {{hinweis}}
Platzhalter: {{name}}, {{produkt}}, {{angebot_text}}, {{ersparnis}}, {{gueltig_bis}}, {{hinweis}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): hinweis
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Ihr persönliches Angebot für das WEBklar Wartungs-Abo</title>
<title>Ihr persönliches Angebot für das WEBklar {{produkt}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@@ -34,7 +34,7 @@ a{color:#111111;}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#efe6fb;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#5a2a91;">Persönliches Angebot</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Ihr persönliches Angebot liegt bereit.</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>wie besprochen haben wir Ihnen im Kundenportal ein persönliches Angebot für das WEBklar Wartungs-Abo hinterlegt.</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>wie besprochen haben wir Ihnen im Kundenportal ein persönliches Angebot für das WEBklar {{produkt}} hinterlegt.</td></tr>
<tr><td class="px" style="padding:32px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="mut" style="font-size:12px;text-transform:uppercase;letter-spacing:1px;color:#6b6964;">Ihr Angebot</div><div class="ink" style="font-size:20px;line-height:28px;font-weight:bold;color:#111111;padding-top:6px;">{{angebot_text}}</div><div style="padding-top:10px;"><span style="background:#e4f2e6;color:#1d5b2c;border-radius:100px;padding:4px 10px;font-size:12px;font-weight:bold;">Sie sparen {{ersparnis}}</span></div></td></tr></table></td></tr>
<tr><td class="px" style="padding:20px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Gültig bis</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gueltig_bis}}</td></tr>
<!-- WENN hinweis -->
@@ -4,16 +4,16 @@
<meta charset="utf-8">
<!--
K06 · Angebot für ein laufendes Abo
Betreff: Ihr Angebot für das WEBklar Wartungs-Abo ist eingerichtet
Betreff: Ihr Angebot für das WEBklar {{produkt}} ist eingerichtet
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail aus den Kundendaten) · BCC intern
Platzhalter: {{name}}, {{naechste_abbuchung}}, {{angebot_text}}, {{ersparnis}}, {{hinweis}}
Platzhalter: {{name}}, {{produkt}}, {{naechste_abbuchung}}, {{angebot_text}}, {{ersparnis}}, {{hinweis}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): hinweis
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Ihr Angebot für das WEBklar Wartungs-Abo ist eingerichtet</title>
<title>Ihr Angebot für das WEBklar {{produkt}} ist eingerichtet</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@@ -34,7 +34,7 @@ a{color:#111111;}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">&#10003;&nbsp; Angebot eingerichtet</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Ihr Rabatt ist eingerichtet.</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>wie besprochen gilt für Ihr WEBklar Wartungs-Abo ab der nächsten Abbuchung am {{naechste_abbuchung}}:</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>wie besprochen gilt für Ihr WEBklar {{produkt}} ab der nächsten Abbuchung am {{naechste_abbuchung}}:</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="mut" style="font-size:12px;text-transform:uppercase;letter-spacing:1px;color:#6b6964;">Ihr Angebot</div><div class="ink" style="font-size:20px;line-height:28px;font-weight:bold;color:#111111;padding-top:6px;">{{angebot_text}}</div><div style="padding-top:10px;"><span style="background:#e4f2e6;color:#1d5b2c;border-radius:100px;padding:4px 10px;font-size:12px;font-weight:bold;">Sie sparen {{ersparnis}}</span></div></td></tr></table></td></tr>
<!-- WENN hinweis -->
+19 -14
View File
@@ -178,13 +178,16 @@ router.get('/', requireSession, async (req, res) => {
events = await readEvents()
}
const active = Boolean(sub && ACTIVE_STATUSES.has(sub.status))
const [ledger, offer] = await Promise.all([
const [ledger, hostingOffer, wartungOffer] = await Promise.all([
// Arbeitszeit gibt es nur im Wartungs-Abo
sub && productKeyOf(sub) === 'wartung' ? getLedger(customerId, { active }) : null,
active ? null : getOpenOffer(customerId).catch(() => null),
// Ein offenes Angebot zeigt das Portal nur vor der Bestellung (mit Abo ist es entweder schon
// eingeloest oder betrifft ein anderes Produkt, das ohnehin nicht bestellbar ist)
active ? null : getOpenOffer(customerId, 'hosting').catch(() => null),
active ? null : getOpenOffer(customerId, 'wartung').catch(() => null),
])
return res.json({
available: isStripeConfigured() && isPilotAllowed(customer, offer),
available: isStripeConfigured() && isPilotAllowed(customer, hostingOffer || wartungOffer),
testAccount: config.abo.testEmails.includes(String(customer?.email || '').toLowerCase()),
termsVersion: config.abo.termsVersion,
// Leistungsbeginn bei "Nach Ablauf der Widerrufsfrist" für die Bestellseite
@@ -196,8 +199,7 @@ router.get('/', requireSession, async (req, res) => {
unitPriceCents: config.mailStorage.amountCents,
maxUnits: config.mailStorage.maxUnits,
},
// Angebote gibt es bis Aufgabe 11 nur fuer das Wartungs-Abo
offers: { hosting: null, wartung: offer ? offerView(offer) : null },
offers: { hosting: hostingOffer ? offerView(hostingOffer) : null, wartung: wartungOffer ? offerView(wartungOffer) : null },
ledger,
subscription: publicSubscription(sub, events),
})
@@ -226,16 +228,19 @@ 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))
// Bestellt wird genau der Preis, den die Bestelluebersicht gezeigt hat. Angebote gibt es bis
// Aufgabe 11 nur fuer das Wartungs-Abo; ein Angebot bei einer Hosting-Bestellung passt nie.
let offer = null
if (productKey === 'wartung') {
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.' })
// Bestellt wird genau der Preis, den die Bestelluebersicht gezeigt hat: das offene Angebot fuer
// genau dieses Produkt (F32). Gehoert die mitgeschickte Angebots-ID zum jeweils anderen Produkt,
// ist die Meldung eindeutiger als das allgemeine "hat sich geändert".
const offer = await getOpenOffer(customer.$id, productKey)
if ((offer?.$id || '') !== String(offerId || '')) {
if (offerId) {
const otherKey = productKey === 'hosting' ? 'wartung' : 'hosting'
const other = await getOpenOffer(customer.$id, otherKey)
if (other?.$id === offerId) {
return res.status(409).json({ error: 'Ihr Angebot gilt für ein anderes Abo. Bitte laden Sie die Seite neu.' })
}
}
} else if (offerId) {
return res.status(409).json({ error: 'Ihr Angebot gilt für ein anderes Abo. Bitte laden Sie die Seite neu.' })
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.' })
+56 -16
View File
@@ -3,6 +3,7 @@ import { config } from '../config.js'
import { createDocument, deleteDocument, getDocument, listDocuments, Query, updateDocument } from './appwriteAdmin.js'
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { chargesFrom, redeemedDiscount } from './offerDiscount.js'
import { getProduct, PRODUCT_KEYS } from './products.js'
import { ACTIVE_STATUSES, getCurrentSubscription, getStripe, isStripeConfigured } from './stripeBilling.js'
import { shortRef } from './takeoverCredits.js'
@@ -55,6 +56,12 @@ const adminLabel = (admin) =>
const isTestEmail = (email) => config.abo.testEmails.includes(String(email || '').toLowerCase())
// Altbestand ohne productKey (vor Aufgabe 11) gilt als Wartungs-Abo/-Angebot (F32)
const productKeyOf = (doc) => doc?.productKey || 'wartung'
/** Katalogname ohne "WEBklar " (z. B. "Hosting-Abo"), fuer {{produkt}} in Mails (F33). */
const productLabel = (key) => getProduct(key).name.replace(/^WEBklar\s+/, '')
// ---------------------------------------------------------------------------
// Texte (eine Quelle fuer Portal, Bestelluebersicht, Checkout, Mails und Stripe)
// ---------------------------------------------------------------------------
@@ -87,6 +94,7 @@ export function offerView(doc) {
createdAt: doc.$createdAt,
status: doc.status === OFFER_STATUS.open && isExpired(doc) ? 'abgelaufen' : doc.status,
scope: doc.scope,
productKey: productKeyOf(doc),
months: doc.months,
monthlyPriceCents: doc.monthlyPriceCents,
regularPriceCents: doc.regularPriceCents,
@@ -150,23 +158,28 @@ 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
/** Offenes, noch gueltiges Angebot eines Kunden fuer ein Produkt (hoechstens eines je Produkt, siehe createOffer). */
export async function getOpenOffer(customerId, productKey) {
if (!customerId || !productKey) 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
return docs.find((d) => !isExpired(d) && productKeyOf(d) === productKey) || null
}
/** Fuer das Angebotsformular: Abo-Stand, offenes Angebot, Portalzugang, Testkonto. */
/** Fuer das Angebotsformular: Abo-Stand, offene Angebote je Produkt, 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)])
const [sub, hostingOpen, wartungOpen] = await Promise.all([
getCurrentSubscription(customerId),
getOpenOffer(customerId, 'hosting'),
getOpenOffer(customerId, 'wartung'),
])
const running = Boolean(sub && ACTIVE_STATUSES.has(sub.status))
return {
customer: {
id: customer.$id,
@@ -179,16 +192,22 @@ export async function offerContext(customerId) {
subscription: sub
? {
status: sub.status,
active: ACTIVE_STATUSES.has(sub.status),
active: running,
// Nur fuer dieses Produkt kann ein Angebot sofort aufs Abo angewendet werden (F32)
productKey: productKeyOf(sub),
currentPeriodEnd: sub.currentPeriodEnd || null,
// Laufendes Abo: [0] = erste rabattierte Abbuchung, [N] = Rabattende bei N Angebotsmonaten
upcomingCharges: ACTIVE_STATUSES.has(sub.status)
? chargesFrom(sub.trialEnd || sub.startedAt, new Date(), config.offers.maxMonths + 1)
: [],
upcomingCharges: running ? chargesFrom(sub.trialEnd || sub.startedAt, new Date(), config.offers.maxMonths + 1) : [],
}
: null,
openOffer: open ? offerView(open) : null,
regularPriceCents: config.stripe.priceAmountCents,
openOffers: {
hosting: hostingOpen ? offerView(hostingOpen) : null,
wartung: wartungOpen ? offerView(wartungOpen) : null,
},
products: {
hosting: { regularPriceCents: getProduct('hosting').amountCents },
wartung: { regularPriceCents: getProduct('wartung').amountCents },
},
maxMonths: config.offers.maxMonths,
defaultValidUntil: addDaysYmd(todayBerlin(), config.offers.defaultValidityDays),
}
@@ -223,11 +242,15 @@ async function attachCouponToSubscription(subscriptionId, couponId, metadata) {
// ---------------------------------------------------------------------------
export function validateOfferInput(body) {
const productKey = body?.productKey
if (!PRODUCT_KEYS.includes(productKey)) {
throw httpError(400, 'Bitte ein Produkt wählen (Hosting-Abo oder Wartungs-Abo).')
}
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 regularPriceCents = getProduct(productKey).amountCents
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.`)
@@ -240,6 +263,7 @@ export function validateOfferInput(body) {
throw httpError(400, 'Bitte ein Gültigkeitsdatum ab heute und höchstens ein Jahr im Voraus wählen.')
}
return {
productKey,
months,
monthlyPriceCents,
regularPriceCents,
@@ -259,6 +283,7 @@ async function mailOffer(doc, customer, nextChargeAt = null) {
const running = doc.scope === 'laufend'
const values = {
name: customer.name || '',
produkt: productLabel(productKeyOf(doc)),
angebot_text: offerText(doc),
ersparnis: euro(savingsOf(doc)),
hinweis: doc.note || '',
@@ -293,11 +318,12 @@ export async function createOffer(body, admin) {
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.')
if (await getOpenOffer(customer.$id, input.productKey)) {
throw httpError(409, 'Für dieses Produkt gibt es für diesen Kunden 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))
// Sofort anwendbar ist ein Angebot nur, wenn das laufende Abo dasselbe Produkt betrifft (F32)
const running = Boolean(sub && ACTIVE_STATUSES.has(sub.status) && productKeyOf(sub) === input.productKey)
const id = randomUUID()
const { notifyCustomer, ...fields } = input
@@ -428,6 +454,20 @@ export async function activateOfferFromCheckout(session) {
})
return null
}
// Angebot und Bestellung fuer unterschiedliche Produkte (F32/F33): nichts anwenden, T03 mit Grund
const metaProductKey = session.metadata?.productKey
if (metaProductKey && productKeyOf(doc) !== metaProductKey) {
await sendAdminNotice({
template: 'T03',
values: {
angebot_nr: shortRef(offerId),
stripe_abo_id: subscriptionId,
angebot_text: offerText(doc),
angebot_status: `Produkt passt nicht: Angebot für ${productLabel(productKeyOf(doc))}, bestellt wurde ${productLabel(metaProductKey)}`,
},
})
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)