Abo-Angebote: für Hosting- oder Wartungs-Abo
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7ffe95f24a
commit
e5cc2f3c82
6 files changed
+303
-45
No files matched your search
+19
-7
@@ -788,6 +788,9 @@ const OFFER_STATUS = {
|
||||
|
||||
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) {
|
||||
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' : ''
|
||||
@@ -873,7 +876,7 @@ async function loadOffers() {
|
||||
<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.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>` : ''}
|
||||
${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>
|
||||
@@ -937,6 +940,7 @@ async function ensureOfferCustomers() {
|
||||
function offerFormValues(form) {
|
||||
return {
|
||||
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),
|
||||
months: Number(form.querySelector('#offer-months').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.'
|
||||
return
|
||||
}
|
||||
const regular = ctx.regularPriceCents
|
||||
const running = Boolean(ctx.subscription?.active)
|
||||
const regular = ctx.products[v.productKey].regularPriceCents
|
||||
// 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 = []
|
||||
let ok = true
|
||||
if (ctx.openOffer) {
|
||||
lines.push(`Es gibt schon ein offenes Angebot (${ctx.openOffer.ref}: ${ctx.openOffer.text}) – bitte zuerst zurückziehen.`)
|
||||
if (openOffer) {
|
||||
lines.push(`Es gibt schon ein offenes ${OFFER_PRODUCT_LABEL[v.productKey]}-Angebot (${openOffer.ref}: ${openOffer.text}) – bitte zuerst zurückziehen.`)
|
||||
ok = false
|
||||
}
|
||||
if (!Number.isInteger(v.monthlyPriceCents) || v.monthlyPriceCents < 0 || v.monthlyPriceCents >= regular) {
|
||||
@@ -1016,8 +1022,8 @@ async function saveOffer(form) {
|
||||
errEl.classList.add('hidden')
|
||||
const v = offerFormValues(form)
|
||||
const ctx = offerState.context
|
||||
const running = Boolean(ctx.subscription?.active)
|
||||
const text = offerPreviewText(v.monthlyPriceCents, v.months, ctx.regularPriceCents, running)
|
||||
const running = Boolean(ctx.subscription?.active && ctx.subscription.productKey === v.productKey)
|
||||
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
|
||||
const save = form.querySelector('#offer-save')
|
||||
save.disabled = true
|
||||
@@ -1065,6 +1071,11 @@ async function openOfferForm(customerId = '') {
|
||||
<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)) : ''}">
|
||||
<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>
|
||||
<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>
|
||||
@@ -1090,6 +1101,7 @@ async function openOfferForm(customerId = '') {
|
||||
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.querySelector('#offer-product').addEventListener('change', () => renderOfferPreview(form))
|
||||
form.querySelector('#offer-save').addEventListener('click', () => saveOffer(form))
|
||||
form.querySelector('#offer-cancel').addEventListener('click', () => form.classList.add('hidden'))
|
||||
if (preselected) loadOfferContext(form)
|
||||
|
||||
@@ -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;">✓ 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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user