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

+19 -7
View File
@@ -788,6 +788,9 @@ const OFFER_STATUS = {
const offerState = { filter: 'open', customers: null, labelToId: new Map(), context: null, offers: new Map() } const offerState = { filter: 'open', customers: null, labelToId: new Map(), context: null, offers: new Map() }
// Katalogname wie im Formular ("Hosting-Abo"/"Wartungs-Abo")
const OFFER_PRODUCT_LABEL = { hosting: 'Hosting-Abo', wartung: 'Wartungs-Abo' }
function offerStatusPill(o) { function offerStatusPill(o) {
const [cls, label] = OFFER_STATUS[o.status] || ['pill-muted', o.status] 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' : '' const sub = o.status === 'aktiv' && o.appliedAt ? `seit ${fmtDate(o.appliedAt)}` : o.status === 'offen' ? 'wartet auf Abschluss' : ''
@@ -873,7 +876,7 @@ async function loadOffers() {
<tr> <tr>
<td>${fmtDateTime(o.createdAt)}<br><span class="muted">${escapeHtml(o.ref)}${o.createdBy ? ` · ${escapeHtml(o.createdBy.replace(/\s*<.*>$/, ''))}` : ''}</span></td> <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.customerEmail ? `<br><span class="muted">${escapeHtml(o.customerEmail).replace('@', '@<wbr>')}</span>` : ''}${o.customerCode ? `<br><span class="muted">Kd.-Nr. ${escapeHtml(o.customerCode)}</span>` : ''}</td> <td><strong>${escapeHtml(o.customerName)}</strong>${o.customerEmail ? `<br><span class="muted">${escapeHtml(o.customerEmail).replace('@', '@<wbr>')}</span>` : ''}${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> <td><span class="muted">${OFFER_PRODUCT_LABEL[o.productKey] || o.productKey}</span><br>${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>` : ''} ${discount ? `<br><span class="muted">${escapeHtml(discount)}</span>` : ''}
${o.internalNote ? `<br><span class="muted"><em>intern: ${escapeHtml(o.internalNote)}</em></span>` : ''}</td> ${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 style="white-space:nowrap;overflow-wrap:normal">${o.scope === 'laufend' ? '<span class="muted">laufendes Abo</span>' : fmtDate(o.validUntil)}</td>
@@ -937,6 +940,7 @@ async function ensureOfferCustomers() {
function offerFormValues(form) { function offerFormValues(form) {
return { return {
customerId: offerState.labelToId.get(form.querySelector('#offer-customer').value.trim()) || '', customerId: offerState.labelToId.get(form.querySelector('#offer-customer').value.trim()) || '',
productKey: form.querySelector('#offer-product').value,
monthlyPriceCents: Math.round(Number(form.querySelector('#offer-price').value) * 100), monthlyPriceCents: Math.round(Number(form.querySelector('#offer-price').value) * 100),
months: Number(form.querySelector('#offer-months').value), months: Number(form.querySelector('#offer-months').value),
validUntil: form.querySelector('#offer-valid').value, validUntil: form.querySelector('#offer-valid').value,
@@ -956,12 +960,14 @@ function renderOfferPreview(form) {
box.textContent = v.customerId ? 'Lade Kunde…' : 'Bitte einen Kunden aus der Liste wählen.' box.textContent = v.customerId ? 'Lade Kunde…' : 'Bitte einen Kunden aus der Liste wählen.'
return return
} }
const regular = ctx.regularPriceCents const regular = ctx.products[v.productKey].regularPriceCents
const running = Boolean(ctx.subscription?.active) // Sofort anwendbar ist ein Angebot nur, wenn das laufende Abo dasselbe Produkt betrifft (F32)
const running = Boolean(ctx.subscription?.active && ctx.subscription.productKey === v.productKey)
const openOffer = ctx.openOffers[v.productKey]
const lines = [] const lines = []
let ok = true let ok = true
if (ctx.openOffer) { if (openOffer) {
lines.push(`Es gibt schon ein offenes Angebot (${ctx.openOffer.ref}: ${ctx.openOffer.text}) – bitte zuerst zurückziehen.`) lines.push(`Es gibt schon ein offenes ${OFFER_PRODUCT_LABEL[v.productKey]}-Angebot (${openOffer.ref}: ${openOffer.text}) – bitte zuerst zurückziehen.`)
ok = false ok = false
} }
if (!Number.isInteger(v.monthlyPriceCents) || v.monthlyPriceCents < 0 || v.monthlyPriceCents >= regular) { if (!Number.isInteger(v.monthlyPriceCents) || v.monthlyPriceCents < 0 || v.monthlyPriceCents >= regular) {
@@ -1016,8 +1022,8 @@ async function saveOffer(form) {
errEl.classList.add('hidden') errEl.classList.add('hidden')
const v = offerFormValues(form) const v = offerFormValues(form)
const ctx = offerState.context const ctx = offerState.context
const running = Boolean(ctx.subscription?.active) const running = Boolean(ctx.subscription?.active && ctx.subscription.productKey === v.productKey)
const text = offerPreviewText(v.monthlyPriceCents, v.months, ctx.regularPriceCents, running) const text = offerPreviewText(v.monthlyPriceCents, v.months, ctx.products[v.productKey].regularPriceCents, running)
if (!window.confirm(`Angebot für ${ctx.customer.name || 'den Kunden'} anlegen?\n\n${text}${running ? '' : `\nGültig bis ${fmtYmd(v.validUntil)}`}\n\nDer Coupon wird sofort in Stripe angelegt${running ? ' und auf das laufende Abo angewendet' : ''}.`)) return if (!window.confirm(`Angebot für ${ctx.customer.name || 'den Kunden'} anlegen?\n\n${text}${running ? '' : `\nGültig bis ${fmtYmd(v.validUntil)}`}\n\nDer Coupon wird sofort in Stripe angelegt${running ? ' und auf das laufende Abo angewendet' : ''}.`)) return
const save = form.querySelector('#offer-save') const save = form.querySelector('#offer-save')
save.disabled = true save.disabled = true
@@ -1065,6 +1071,11 @@ async function openOfferForm(customerId = '') {
<input class="credit-input" id="offer-customer" list="offer-customer-list" autocomplete="off" <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)) : ''}"> 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> <datalist id="offer-customer-list">${customers.map((c) => `<option value="${escapeHtml(offerCustomerLabel(c))}"></option>`).join('')}</datalist></div>
<div><label for="offer-product">Produkt</label>
<select class="credit-input" id="offer-product">
<option value="wartung" selected>Wartungs-Abo</option>
<option value="hosting">Hosting-Abo</option>
</select></div>
<div><label for="offer-price">Preis pro Monat im Angebot (€)</label> <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> <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> <div><label for="offer-months">für</label>
@@ -1090,6 +1101,7 @@ async function openOfferForm(customerId = '') {
if (offerState.labelToId.has(form.querySelector('#offer-customer').value.trim())) loadOfferContext(form) 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.querySelectorAll('#offer-price, #offer-months, #offer-valid').forEach((el) => el.addEventListener('input', () => renderOfferPreview(form)))
form.querySelector('#offer-product').addEventListener('change', () => renderOfferPreview(form))
form.querySelector('#offer-save').addEventListener('click', () => saveOffer(form)) form.querySelector('#offer-save').addEventListener('click', () => saveOffer(form))
form.querySelector('#offer-cancel').addEventListener('click', () => form.classList.add('hidden')) form.querySelector('#offer-cancel').addEventListener('click', () => form.classList.add('hidden'))
if (preselected) loadOfferContext(form) if (preselected) loadOfferContext(form)
@@ -4,16 +4,16 @@
<meta charset="utf-8"> <meta charset="utf-8">
<!-- <!--
K05 · Persönliches Angebot (Kunde ohne Abo) 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> Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail aus den Kundendaten) · BCC intern 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 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="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" 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]--> <!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style> <style>
a{color:#111111;} a{color:#111111;}
@@ -34,7 +34,7 @@ a{color:#111111;}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"> <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" 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 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: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> <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 --> <!-- WENN hinweis -->
@@ -4,16 +4,16 @@
<meta charset="utf-8"> <meta charset="utf-8">
<!-- <!--
K06 · Angebot für ein laufendes Abo 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> Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail aus den Kundendaten) · BCC intern 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 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="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" 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]--> <!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style> <style>
a{color:#111111;} a{color:#111111;}
@@ -34,7 +34,7 @@ a{color:#111111;}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"> <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" 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 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> <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 --> <!-- WENN hinweis -->
+19 -14
View File
@@ -178,13 +178,16 @@ router.get('/', requireSession, async (req, res) => {
events = await readEvents() events = await readEvents()
} }
const active = Boolean(sub && ACTIVE_STATUSES.has(sub.status)) 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 // Arbeitszeit gibt es nur im Wartungs-Abo
sub && productKeyOf(sub) === 'wartung' ? getLedger(customerId, { active }) : null, 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({ return res.json({
available: isStripeConfigured() && isPilotAllowed(customer, offer), available: isStripeConfigured() && isPilotAllowed(customer, hostingOffer || wartungOffer),
testAccount: config.abo.testEmails.includes(String(customer?.email || '').toLowerCase()), testAccount: config.abo.testEmails.includes(String(customer?.email || '').toLowerCase()),
termsVersion: config.abo.termsVersion, termsVersion: config.abo.termsVersion,
// Leistungsbeginn bei "Nach Ablauf der Widerrufsfrist" für die Bestellseite // Leistungsbeginn bei "Nach Ablauf der Widerrufsfrist" für die Bestellseite
@@ -196,8 +199,7 @@ router.get('/', requireSession, async (req, res) => {
unitPriceCents: config.mailStorage.amountCents, unitPriceCents: config.mailStorage.amountCents,
maxUnits: config.mailStorage.maxUnits, maxUnits: config.mailStorage.maxUnits,
}, },
// Angebote gibt es bis Aufgabe 11 nur fuer das Wartungs-Abo offers: { hosting: hostingOffer ? offerView(hostingOffer) : null, wartung: wartungOffer ? offerView(wartungOffer) : null },
offers: { hosting: null, wartung: offer ? offerView(offer) : null },
ledger, ledger,
subscription: publicSubscription(sub, events), 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.' }) return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' })
} }
const customer = await getDocument(config.collections.customers, getSessionCustomerId(req)) const customer = await getDocument(config.collections.customers, getSessionCustomerId(req))
// Bestellt wird genau der Preis, den die Bestelluebersicht gezeigt hat. Angebote gibt es bis // Bestellt wird genau der Preis, den die Bestelluebersicht gezeigt hat: das offene Angebot fuer
// Aufgabe 11 nur fuer das Wartungs-Abo; ein Angebot bei einer Hosting-Bestellung passt nie. // genau dieses Produkt (F32). Gehoert die mitgeschickte Angebots-ID zum jeweils anderen Produkt,
let offer = null // ist die Meldung eindeutiger als das allgemeine "hat sich geändert".
if (productKey === 'wartung') { const offer = await getOpenOffer(customer.$id, productKey)
offer = await getOpenOffer(customer.$id) if ((offer?.$id || '') !== String(offerId || '')) {
if ((offer?.$id || '') !== String(offerId || '')) { if (offerId) {
return res.status(409).json({ error: 'Ihr Angebot hat sich geändert. Bitte laden Sie die Seite neu.' }) 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 hat sich geändert. Bitte laden Sie die Seite neu.' })
return res.status(409).json({ error: 'Ihr Angebot gilt für ein anderes Abo. Bitte laden Sie die Seite neu.' })
} }
if (!isStripeConfigured() || !isPilotAllowed(customer, offer)) { if (!isStripeConfigured() || !isPilotAllowed(customer, offer)) {
return res.status(403).json({ error: 'Die Online-Bestellung ist noch nicht freigeschaltet.' }) 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 { createDocument, deleteDocument, getDocument, listDocuments, Query, updateDocument } from './appwriteAdmin.js'
import { sendAdminNotice, sendCustomerMail } from './mailer.js' import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { chargesFrom, redeemedDiscount } from './offerDiscount.js' import { chargesFrom, redeemedDiscount } from './offerDiscount.js'
import { getProduct, PRODUCT_KEYS } from './products.js'
import { ACTIVE_STATUSES, getCurrentSubscription, getStripe, isStripeConfigured } from './stripeBilling.js' import { ACTIVE_STATUSES, getCurrentSubscription, getStripe, isStripeConfigured } from './stripeBilling.js'
import { shortRef } from './takeoverCredits.js' import { shortRef } from './takeoverCredits.js'
@@ -55,6 +56,12 @@ const adminLabel = (admin) =>
const isTestEmail = (email) => config.abo.testEmails.includes(String(email || '').toLowerCase()) 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) // Texte (eine Quelle fuer Portal, Bestelluebersicht, Checkout, Mails und Stripe)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -87,6 +94,7 @@ export function offerView(doc) {
createdAt: doc.$createdAt, createdAt: doc.$createdAt,
status: doc.status === OFFER_STATUS.open && isExpired(doc) ? 'abgelaufen' : doc.status, status: doc.status === OFFER_STATUS.open && isExpired(doc) ? 'abgelaufen' : doc.status,
scope: doc.scope, scope: doc.scope,
productKey: productKeyOf(doc),
months: doc.months, months: doc.months,
monthlyPriceCents: doc.monthlyPriceCents, monthlyPriceCents: doc.monthlyPriceCents,
regularPriceCents: doc.regularPriceCents, regularPriceCents: doc.regularPriceCents,
@@ -150,23 +158,28 @@ export async function listOffers() {
return listDocuments(COLLECTION(), [Query.orderDesc('$createdAt'), Query.limit(1000)]) return listDocuments(COLLECTION(), [Query.orderDesc('$createdAt'), Query.limit(1000)])
} }
/** Offenes, noch gueltiges Angebot eines Kunden (hoechstens eines, siehe createOffer). */ /** Offenes, noch gueltiges Angebot eines Kunden fuer ein Produkt (hoechstens eines je Produkt, siehe createOffer). */
export async function getOpenOffer(customerId) { export async function getOpenOffer(customerId, productKey) {
if (!customerId) return null if (!customerId || !productKey) return null
const docs = await listDocuments(COLLECTION(), [ const docs = await listDocuments(COLLECTION(), [
Query.equal('customerId', customerId), Query.equal('customerId', customerId),
Query.equal('status', OFFER_STATUS.open), Query.equal('status', OFFER_STATUS.open),
Query.orderDesc('$createdAt'), Query.orderDesc('$createdAt'),
Query.limit(20), 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) { export async function offerContext(customerId) {
const customer = await getDocument(config.collections.customers, customerId).catch(() => null) const customer = await getDocument(config.collections.customers, customerId).catch(() => null)
if (!customer) throw httpError(404, 'Kunde nicht gefunden') 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 { return {
customer: { customer: {
id: customer.$id, id: customer.$id,
@@ -179,16 +192,22 @@ export async function offerContext(customerId) {
subscription: sub subscription: sub
? { ? {
status: sub.status, 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, currentPeriodEnd: sub.currentPeriodEnd || null,
// Laufendes Abo: [0] = erste rabattierte Abbuchung, [N] = Rabattende bei N Angebotsmonaten // Laufendes Abo: [0] = erste rabattierte Abbuchung, [N] = Rabattende bei N Angebotsmonaten
upcomingCharges: ACTIVE_STATUSES.has(sub.status) upcomingCharges: running ? chargesFrom(sub.trialEnd || sub.startedAt, new Date(), config.offers.maxMonths + 1) : [],
? chargesFrom(sub.trialEnd || sub.startedAt, new Date(), config.offers.maxMonths + 1)
: [],
} }
: null, : null,
openOffer: open ? offerView(open) : null, openOffers: {
regularPriceCents: config.stripe.priceAmountCents, 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, maxMonths: config.offers.maxMonths,
defaultValidUntil: addDaysYmd(todayBerlin(), config.offers.defaultValidityDays), defaultValidUntil: addDaysYmd(todayBerlin(), config.offers.defaultValidityDays),
} }
@@ -223,11 +242,15 @@ async function attachCouponToSubscription(subscriptionId, couponId, metadata) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function validateOfferInput(body) { 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) const months = Number(body?.months)
if (!Number.isInteger(months) || months < 1 || months > config.offers.maxMonths) { if (!Number.isInteger(months) || months < 1 || months > config.offers.maxMonths) {
throw httpError(400, `Bitte 1 bis ${config.offers.maxMonths} Angebotsmonate wählen.`) 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) const monthlyPriceCents = Number(body?.monthlyPriceCents)
if (!Number.isInteger(monthlyPriceCents) || monthlyPriceCents < 0 || monthlyPriceCents >= regularPriceCents) { if (!Number.isInteger(monthlyPriceCents) || monthlyPriceCents < 0 || monthlyPriceCents >= regularPriceCents) {
throw httpError(400, `Der Angebotspreis muss zwischen 0,00 € und ${euro(regularPriceCents - 1)} liegen.`) 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.') throw httpError(400, 'Bitte ein Gültigkeitsdatum ab heute und höchstens ein Jahr im Voraus wählen.')
} }
return { return {
productKey,
months, months,
monthlyPriceCents, monthlyPriceCents,
regularPriceCents, regularPriceCents,
@@ -259,6 +283,7 @@ async function mailOffer(doc, customer, nextChargeAt = null) {
const running = doc.scope === 'laufend' const running = doc.scope === 'laufend'
const values = { const values = {
name: customer.name || '', name: customer.name || '',
produkt: productLabel(productKeyOf(doc)),
angebot_text: offerText(doc), angebot_text: offerText(doc),
ersparnis: euro(savingsOf(doc)), ersparnis: euro(savingsOf(doc)),
hinweis: doc.note || '', hinweis: doc.note || '',
@@ -293,11 +318,12 @@ export async function createOffer(body, admin) {
const input = validateOfferInput(body) const input = validateOfferInput(body)
const customer = await getDocument(config.collections.customers, String(body?.customerId || '')).catch(() => null) const customer = await getDocument(config.collections.customers, String(body?.customerId || '')).catch(() => null)
if (!customer) throw httpError(404, 'Kunde nicht gefunden') if (!customer) throw httpError(404, 'Kunde nicht gefunden')
if (await getOpenOffer(customer.$id)) { if (await getOpenOffer(customer.$id, input.productKey)) {
throw httpError(409, 'Für diesen Kunden gibt es schon ein offenes Angebot. Bitte das alte zuerst zurückziehen.') 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 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 id = randomUUID()
const { notifyCustomer, ...fields } = input const { notifyCustomer, ...fields } = input
@@ -428,6 +454,20 @@ export async function activateOfferFromCheckout(session) {
}) })
return null 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') { if (session.metadata?.offerDeferred === '1') {
// Testkonto: Im Checkout lief der Testrabatt, das Angebot kommt jetzt zusaetzlich ans Abo // Testkonto: Im Checkout lief der Testrabatt, das Angebot kommt jetzt zusaetzlich ans Abo
await attachCouponToSubscription(subscriptionId, doc.stripeCouponId) await attachCouponToSubscription(subscriptionId, doc.stripeCouponId)
+201
View File
@@ -0,0 +1,201 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { mailSink } from './support/mailSink.js'
// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber testApp/fixtures/Routen) geladen wird
const aw = await startAppwriteMock({
schema: {
subscriptions: schemaKeys('subscriptions'),
aboOffers: schemaKeys('aboOffers'),
contractEvents: schemaKeys('contractEvents'),
hourCredits: schemaKeys('hourCredits'),
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
process.env.ABO_TEST_EMAILS = 'test@example.invalid'
const { config } = await import('../server/config.js')
const { startTestApp, get } = await import('./support/testApp.js')
const { hostingDoc, customerCookie, seedFixtures, stripeSubscription } = await import('./support/fixtures.js')
const { default: aboRoutes } = await import('../server/routes/abo.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { validateOfferInput, getOpenOffer, createOffer, offerContext, activateOfferFromCheckout } =
await import('../server/services/offers.js')
const app = await startTestApp({ '/api/abo': aboRoutes })
const mails = mailSink()
setMailTransportForTests(mails)
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
seedFixtures(aw)
const ADMIN = { name: 'Admin', email: 'admin@example.invalid' }
// ---------------------------------------------------------------------------
// Step 1 (Brief): validateOfferInput + getOpenOffer
// ---------------------------------------------------------------------------
test('Angebot Hosting: Normalpreis 15 €, Preis darunter', () => {
assert.equal(validateOfferInput({ productKey: 'hosting', months: 2, monthlyPriceCents: 1000 }).regularPriceCents, 1500)
assert.throws(() => validateOfferInput({ productKey: 'hosting', months: 2, monthlyPriceCents: 1500 }), (e) => e.status === 400)
assert.throws(() => validateOfferInput({ months: 2, monthlyPriceCents: 1000 }), (e) => e.status === 400)
})
test('Offenes Angebot nur fürs passende Produkt; alte Angebote gelten als Wartung', async () => {
aw.seed('aboOffers', { $id: 'o_alt', customerId: 'c1', status: 'offen', validUntil: '2099-01-01T00:00:00.000Z', months: 2, monthlyPriceCents: 2500 })
aw.seed('aboOffers', { $id: 'o_h', customerId: 'c1', status: 'offen', productKey: 'hosting', validUntil: '2099-01-01T00:00:00.000Z', months: 2, monthlyPriceCents: 1000 })
try {
assert.equal((await getOpenOffer('c1', 'hosting')).$id, 'o_h')
assert.equal((await getOpenOffer('c1', 'wartung')).$id, 'o_alt')
} finally {
await deleteDocument(config.collections.aboOffers, 'o_alt')
await deleteDocument(config.collections.aboOffers, 'o_h')
}
})
// ---------------------------------------------------------------------------
// createOffer: getrennte offene Angebote je Produkt (F32)
// ---------------------------------------------------------------------------
test('createOffer: offene Angebote fuer Hosting und Wartung koennen nebeneinander bestehen, Konflikt nur im selben Produkt', async () => {
// c3 hat kein Abo (Fixtures)
const hosting = await createOffer({ customerId: 'c3', productKey: 'hosting', months: 2, monthlyPriceCents: 1000, notifyCustomer: false }, ADMIN)
try {
assert.equal(hosting.doc.productKey, 'hosting')
assert.equal(hosting.doc.regularPriceCents, 1500)
assert.equal(hosting.doc.status, 'offen')
await assert.rejects(
createOffer({ customerId: 'c3', productKey: 'hosting', months: 1, monthlyPriceCents: 500, notifyCustomer: false }, ADMIN),
(e) => e.status === 409
)
const wartung = await createOffer({ customerId: 'c3', productKey: 'wartung', months: 1, monthlyPriceCents: 2000, notifyCustomer: false }, ADMIN)
try {
assert.equal(wartung.doc.productKey, 'wartung')
assert.equal((await getOpenOffer('c3', 'hosting')).$id, hosting.doc.$id)
assert.equal((await getOpenOffer('c3', 'wartung')).$id, wartung.doc.$id)
} finally {
await deleteDocument(config.collections.aboOffers, wartung.doc.$id)
}
} finally {
await deleteDocument(config.collections.aboOffers, hosting.doc.$id)
}
})
test('createOffer: nur bei passendem Produkt wird das Angebot sofort aufs laufende Abo angewendet', async () => {
// c1 hat ein laufendes Hosting-Abo (sub_h)
st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'hosting' }))
const matching = await createOffer({ customerId: 'c1', productKey: 'hosting', months: 2, monthlyPriceCents: 1000, notifyCustomer: false }, ADMIN)
try {
assert.equal(matching.doc.scope, 'laufend')
assert.equal(matching.doc.status, 'aktiv')
assert.equal(matching.doc.stripeSubscriptionId, 'sub_h')
} finally {
await deleteDocument(config.collections.aboOffers, matching.doc.$id)
}
const other = await createOffer({ customerId: 'c1', productKey: 'wartung', months: 2, monthlyPriceCents: 2000, notifyCustomer: false }, ADMIN)
try {
// c1 hat kein Wartungs-Abo: bleibt offen, wird nicht angewendet
assert.equal(other.doc.scope, 'neu')
assert.equal(other.doc.status, 'offen')
} finally {
await deleteDocument(config.collections.aboOffers, other.doc.$id)
}
})
// ---------------------------------------------------------------------------
// offerContext: Produkte mit Normalpreisen, productKey am Abo, offene Angebote je Produkt
// ---------------------------------------------------------------------------
test('offerContext: products mit Normalpreisen, subscription.productKey, offene Angebote je Produkt', async () => {
const withoutSub = await offerContext('c3')
assert.deepEqual(withoutSub.products, { hosting: { regularPriceCents: 1500 }, wartung: { regularPriceCents: 5000 } })
assert.equal(withoutSub.subscription, null)
assert.deepEqual(withoutSub.openOffers, { hosting: null, wartung: null })
const withSub = await offerContext('c1')
assert.equal(withSub.subscription.productKey, 'hosting')
assert.equal(withSub.subscription.active, true)
aw.seed('aboOffers', { $id: 'ctx_h', customerId: 'c1', status: 'offen', productKey: 'hosting', validUntil: '2099-01-01T00:00:00.000Z', months: 1, monthlyPriceCents: 500 })
aw.seed('aboOffers', { $id: 'ctx_w', customerId: 'c1', status: 'offen', productKey: 'wartung', validUntil: '2099-01-01T00:00:00.000Z', months: 1, monthlyPriceCents: 1000 })
try {
const ctx = await offerContext('c1')
assert.equal(ctx.openOffers.hosting.id, 'ctx_h')
assert.equal(ctx.openOffers.wartung.id, 'ctx_w')
} finally {
await deleteDocument(config.collections.aboOffers, 'ctx_h')
await deleteDocument(config.collections.aboOffers, 'ctx_w')
}
})
// ---------------------------------------------------------------------------
// activateOfferFromCheckout: Produkt passt nicht -> nichts angewendet, T03 mit Grund (F33)
// ---------------------------------------------------------------------------
test('activateOfferFromCheckout: Produkt weicht ab -> nichts angewendet, T03 mit Grund in angebot_status', async () => {
mails.sent.length = 0
const offer = await createOffer({ customerId: 'c3', productKey: 'wartung', months: 2, monthlyPriceCents: 2000, notifyCustomer: false }, ADMIN)
try {
const session = {
subscription: 'sub_mismatch',
metadata: { offerId: offer.doc.$id, productKey: 'hosting' },
}
const result = await activateOfferFromCheckout(session)
assert.equal(result, null)
const fresh = (await import('../server/services/appwriteAdmin.js')).getDocument
const doc = await fresh(config.collections.aboOffers, offer.doc.$id)
assert.equal(doc.status, 'offen')
const t03 = mails.sent.find((m) => m.subject.includes('nicht mehr offen'))
assert.ok(t03, 'T03 wurde nicht verschickt')
assert.match(t03.html, /Wartungs-Abo/)
assert.match(t03.html, /Hosting-Abo/)
} finally {
await deleteDocument(config.collections.aboOffers, offer.doc.$id)
}
})
// ---------------------------------------------------------------------------
// K05/K06 nennen das Produkt ({{produkt}}, F33)
// ---------------------------------------------------------------------------
test('K05 nennt das bestellte Produkt', async () => {
mails.sent.length = 0
const offer = await createOffer(
{ customerId: 'c3', productKey: 'hosting', months: 2, monthlyPriceCents: 1000, notifyCustomer: true },
ADMIN
)
try {
const k05 = mails.sent.find((m) => m.to?.[0]?.address === 'k3@example.invalid')
assert.ok(k05, 'K05 wurde nicht verschickt')
assert.match(k05.html, /Hosting-Abo/)
assert.doesNotMatch(k05.html, /Wartungs-Abo/)
} finally {
await deleteDocument(config.collections.aboOffers, offer.doc.$id)
}
})
// ---------------------------------------------------------------------------
// GET /api/abo: offers.hosting und offers.wartung unabhaengig voneinander
// ---------------------------------------------------------------------------
test('GET /api/abo: offers.hosting und offers.wartung getrennt', async () => {
aw.seed('aboOffers', { $id: 'g_h', customerId: 'c3', status: 'offen', productKey: 'hosting', validUntil: '2099-01-01T00:00:00.000Z', months: 1, monthlyPriceCents: 500 })
try {
const r = await get('/api/abo', { cookie: customerCookie('c3') })
assert.equal(r.offers.hosting.id, 'g_h')
assert.equal(r.offers.wartung, null)
} finally {
await deleteDocument(config.collections.aboOffers, 'g_h')
}
})