Abo-Angebote: Mail-Status in der Liste und E-Mail erneut senden
Bisher sah man nirgends, ob die Angebots-Mail rausging, Fehler landeten nicht im Log, und senden ließ sie sich nur beim Anlegen. - Angebotsliste: Kunden-Adresse unter dem Namen, im Status "E-Mail gesendet <Zeitpunkt>" bzw. "E-Mail nicht verschickt" / "Keine E-Mail-Adresse hinterlegt" - Knopf "E-Mail senden" / "Erneut senden" mit Rückfrage (Adresse, letzter Versand), solange das Angebot offen und gültig ist; bei laufendem Abo, solange die erste rabattierte Abbuchung noch bevorsteht (die Mail nennt sie). POST /api/portal-admin/offers/:id/notify setzt notifiedAt. - Server-Log: "[offers] Angebots-Mail <Ref> vom Mailserver angenommen: <Message-ID> 250 … queued as …" bzw. "… fehlgeschlagen: <Grund>" - Lehnt der Mailserver nur den Kunden ab (die BCC-Kopie ans Team geht trotzdem raus), gilt die Mail als nicht verschickt - sendCustomerMail gibt die nodemailer-Info zurück Tests: npm test (40), Server-Durchlauf gegen Appwrite-Mock, SMTP-Sink und Stripe-Double (28 Prüfungen), Oberfläche (18 Prüfungen). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
81119c19bc
commit
32e441c8bc
5 files changed
+158
-15
No files matched your search
+38
-4
@@ -786,7 +786,7 @@ const OFFER_STATUS = {
|
|||||||
zurueckgezogen: ['pill-muted', 'zurückgezogen'],
|
zurueckgezogen: ['pill-muted', 'zurückgezogen'],
|
||||||
}
|
}
|
||||||
|
|
||||||
const offerState = { filter: 'open', customers: null, labelToId: new Map(), context: null }
|
const offerState = { filter: 'open', customers: null, labelToId: new Map(), context: null, offers: new Map() }
|
||||||
|
|
||||||
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]
|
||||||
@@ -794,6 +794,14 @@ function offerStatusPill(o) {
|
|||||||
return `<span class="pill ${cls}">${label}</span>${sub ? `<br><span class="muted">${sub}</span>` : ''}`
|
return `<span class="pill ${cls}">${label}</span>${sub ? `<br><span class="muted">${sub}</span>` : ''}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Versandstand der Angebots-Mail; "nicht verschickt" nur, solange sie noch sinnvoll ist (mailSendable)
|
||||||
|
function offerMailStatus(o) {
|
||||||
|
if (o.notifiedAt) return `<br><span class="muted">E-Mail gesendet ${fmtDateTime(o.notifiedAt)}</span>`
|
||||||
|
if (o.mailSendable) return '<br><span style="color:#fbbf24">E-Mail nicht verschickt</span>'
|
||||||
|
if (o.status === 'offen' && !o.customerEmail) return '<br><span style="color:#fbbf24">Keine E-Mail-Adresse hinterlegt</span>'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
// Vorschau im Formular; verbindlich ist der Text, den der Server beim Anlegen erzeugt
|
// Vorschau im Formular; verbindlich ist der Text, den der Server beim Anlegen erzeugt
|
||||||
function offerPreviewText(price, months, regular, running) {
|
function offerPreviewText(price, months, regular, running) {
|
||||||
const eur = (cents) => euroFromCents(cents)
|
const eur = (cents) => euroFromCents(cents)
|
||||||
@@ -853,6 +861,7 @@ async function loadOffers() {
|
|||||||
const tbody = document.getElementById('offer-rows')
|
const tbody = document.getElementById('offer-rows')
|
||||||
try {
|
try {
|
||||||
const { offers } = await api('/api/portal-admin/offers')
|
const { offers } = await api('/api/portal-admin/offers')
|
||||||
|
offerState.offers = new Map(offers.map((o) => [o.id, o]))
|
||||||
const shown = offerState.filter === 'open' ? offers.filter((o) => ['offen', 'aktiv'].includes(o.status)) : offers
|
const shown = offerState.filter === 'open' ? offers.filter((o) => ['offen', 'aktiv'].includes(o.status)) : offers
|
||||||
if (!shown.length) {
|
if (!shown.length) {
|
||||||
tbody.innerHTML = `<tr><td colspan="5" class="muted">${offerState.filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'}</td></tr>`
|
tbody.innerHTML = `<tr><td colspan="5" class="muted">${offerState.filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'}</td></tr>`
|
||||||
@@ -863,18 +872,23 @@ async function loadOffers() {
|
|||||||
return `
|
return `
|
||||||
<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.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>${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>
|
||||||
<td>${offerStatusPill(o)}
|
<td>${offerStatusPill(o)}
|
||||||
<br><span class="muted">Stripe:</span> ${o.stripeCouponUrl ? `<a class="plain" href="${escapeHtml(o.stripeCouponUrl)}" target="_blank" rel="noreferrer">Coupon</a>` : '–'}${o.stripeSubscriptionUrl ? ` · <a class="plain" href="${escapeHtml(o.stripeSubscriptionUrl)}" target="_blank" rel="noreferrer">Abo</a>` : ''}
|
<br><span class="muted">Stripe:</span> ${o.stripeCouponUrl ? `<a class="plain" href="${escapeHtml(o.stripeCouponUrl)}" target="_blank" rel="noreferrer">Coupon</a>` : '–'}${o.stripeSubscriptionUrl ? ` · <a class="plain" href="${escapeHtml(o.stripeSubscriptionUrl)}" target="_blank" rel="noreferrer">Abo</a>` : ''}
|
||||||
${['offen', 'abgelaufen'].includes(o.status) ? `<div style="margin-top:6px"><button class="mini-btn danger" data-withdraw-offer="${escapeHtml(o.id)}">Zurückziehen</button></div>` : ''}</td>
|
${offerMailStatus(o)}
|
||||||
|
${['offen', 'abgelaufen'].includes(o.status) || o.mailSendable ? `<div class="cust-actions" style="margin-top:6px">
|
||||||
|
${o.mailSendable ? `<button class="mini-btn" data-send-offer-mail="${escapeHtml(o.id)}" title="Angebots-E-Mail an ${escapeHtml(o.customerEmail)} senden">${o.notifiedAt ? 'Erneut senden' : 'E-Mail senden'}</button>` : ''}
|
||||||
|
${['offen', 'abgelaufen'].includes(o.status) ? `<button class="mini-btn danger" data-withdraw-offer="${escapeHtml(o.id)}">Zurückziehen</button>` : ''}</div>` : ''}</td>
|
||||||
</tr>`
|
</tr>`
|
||||||
}).join('')
|
}).join('')
|
||||||
tbody.querySelectorAll('[data-withdraw-offer]').forEach((btn) =>
|
tbody.querySelectorAll('[data-withdraw-offer]').forEach((btn) =>
|
||||||
btn.addEventListener('click', () => withdrawOfferAction(btn.dataset.withdrawOffer, btn)))
|
btn.addEventListener('click', () => withdrawOfferAction(btn.dataset.withdrawOffer, btn)))
|
||||||
|
tbody.querySelectorAll('[data-send-offer-mail]').forEach((btn) =>
|
||||||
|
btn.addEventListener('click', () => sendOfferMailAction(btn.dataset.sendOfferMail, btn)))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
tbody.innerHTML = `<tr><td colspan="5" class="error">${escapeHtml(err.message)}</td></tr>`
|
tbody.innerHTML = `<tr><td colspan="5" class="error">${escapeHtml(err.message)}</td></tr>`
|
||||||
}
|
}
|
||||||
@@ -893,6 +907,22 @@ async function withdrawOfferAction(id, btn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendOfferMailAction(id, btn) {
|
||||||
|
const o = offerState.offers.get(id)
|
||||||
|
if (!o) return
|
||||||
|
const again = o.notifiedAt ? `\n\nZuletzt gesendet ${fmtDateTime(o.notifiedAt)}.` : ''
|
||||||
|
if (!window.confirm(`E-Mail mit dem Angebot an ${o.customerName} (${o.customerEmail}) ${o.notifiedAt ? 'erneut ' : ''}senden?${again}`)) return
|
||||||
|
btn.disabled = true
|
||||||
|
try {
|
||||||
|
await api(`/api/portal-admin/offers/${encodeURIComponent(id)}/notify`, { method: 'POST' })
|
||||||
|
showOfferMessage(`Die E-Mail an ${o.customerEmail} ist raus, der Mailserver hat sie angenommen. Kommt sie nicht an: Spam-Ordner prüfen, bei Gmail auch den Tab „Werbung“.`)
|
||||||
|
loadOffers()
|
||||||
|
} catch (err) {
|
||||||
|
btn.disabled = false
|
||||||
|
alert(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const offerCustomerLabel = (c) =>
|
const offerCustomerLabel = (c) =>
|
||||||
[c.name || '(ohne Name)', c.email, c.code ? `Kd.-Nr. ${c.code}` : ''].filter(Boolean).join(' · ')
|
[c.name || '(ohne Name)', c.email, c.code ? `Kd.-Nr. ${c.code}` : ''].filter(Boolean).join(' · ')
|
||||||
|
|
||||||
@@ -1001,7 +1031,11 @@ async function saveOffer(form) {
|
|||||||
showOfferMessage([
|
showOfferMessage([
|
||||||
`Angebot ${o.ref} für ${o.customerName} angelegt: ${o.text}${until}.`,
|
`Angebot ${o.ref} für ${o.customerName} angelegt: ${o.text}${until}.`,
|
||||||
`Stripe-Coupon ${o.stripeCouponId} ist eingerichtet${o.status === 'aktiv' ? ' und am laufenden Abo aktiv' : ''}.`,
|
`Stripe-Coupon ${o.stripeCouponId} ist eingerichtet${o.status === 'aktiv' ? ' und am laufenden Abo aktiv' : ''}.`,
|
||||||
v.notifyCustomer ? (result.mailSent ? 'Die E-Mail an den Kunden ist raus.' : `E-Mail an den Kunden fehlgeschlagen (${result.mailError}).`) : '',
|
v.notifyCustomer
|
||||||
|
? (result.mailSent
|
||||||
|
? `Die E-Mail an ${o.customerEmail} ist raus.`
|
||||||
|
: `E-Mail an den Kunden fehlgeschlagen (${result.mailError})${o.mailSendable ? ', sie lässt sich in der Liste erneut senden' : ''}.`)
|
||||||
|
: '',
|
||||||
].filter(Boolean).join(' '))
|
].filter(Boolean).join(' '))
|
||||||
loadOffers()
|
loadOffers()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+10
-1
@@ -1,7 +1,7 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { config } from '../config.js'
|
import { config } from '../config.js'
|
||||||
import { listDocuments, Query } from '../services/appwriteAdmin.js'
|
import { listDocuments, Query } from '../services/appwriteAdmin.js'
|
||||||
import { adminOfferView, createOffer, listOffers, offerContext, withdrawOffer } from '../services/offers.js'
|
import { adminOfferView, createOffer, listOffers, offerContext, resendOfferMail, withdrawOffer } from '../services/offers.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Individuelle Abo-Angebote im Admin-Panel (Tab "Abo-Angebote").
|
* Individuelle Abo-Angebote im Admin-Panel (Tab "Abo-Angebote").
|
||||||
@@ -67,3 +67,12 @@ offersAdminRouter.post('/:id/withdraw', async (req, res) => {
|
|||||||
return sendError(res, err, 'Angebot konnte nicht zurückgezogen werden')
|
return sendError(res, err, 'Angebot konnte nicht zurückgezogen werden')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
offersAdminRouter.post('/:id/notify', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const doc = await resendOfferMail(req.params.id)
|
||||||
|
return res.json({ sent: true, notifiedAt: doc.notifiedAt })
|
||||||
|
} catch (err) {
|
||||||
|
return sendError(res, err, 'E-Mail konnte nicht verschickt werden')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -80,11 +80,12 @@ export async function sendAdminNotice(subject, lines) {
|
|||||||
/**
|
/**
|
||||||
* Mail an Kunden. `attachments` fuer die Vertragsbestaetigung auf dauerhaftem
|
* Mail an Kunden. `attachments` fuer die Vertragsbestaetigung auf dauerhaftem
|
||||||
* Datentraeger (§ 312f BGB): Bedingungen als HTML-Anhang.
|
* Datentraeger (§ 312f BGB): Bedingungen als HTML-Anhang.
|
||||||
|
* Ergebnis: nodemailer-Info (messageId, Antwort des Mailservers, abgelehnte Empfaenger).
|
||||||
*/
|
*/
|
||||||
export async function sendCustomerMail(to, subject, lines, attachments = []) {
|
export async function sendCustomerMail(to, subject, lines, attachments = []) {
|
||||||
const mailer = getTransporter()
|
const mailer = getTransporter()
|
||||||
if (!mailer) throw new Error('SMTP nicht konfiguriert')
|
if (!mailer) throw new Error('SMTP nicht konfiguriert')
|
||||||
await mailer.sendMail({
|
return mailer.sendMail({
|
||||||
from: `"Webklar" <${config.smtp.from}>`,
|
from: `"Webklar" <${config.smtp.from}>`,
|
||||||
to,
|
to,
|
||||||
bcc: config.chatNotify.emails.join(', ') || undefined,
|
bcc: config.chatNotify.emails.join(', ') || undefined,
|
||||||
|
|||||||
@@ -107,17 +107,27 @@ function stripeDashboardUrl(kind, id) {
|
|||||||
* Testkonten bekommen den Coupon erst nach dem Abschluss (activateOfferFromCheckout), wie ein laufendes Abo.
|
* Testkonten bekommen den Coupon erst nach dem Abschluss (activateOfferFromCheckout), wie ein laufendes Abo.
|
||||||
*/
|
*/
|
||||||
export function adminOfferView(doc, customer, sub = null) {
|
export function adminOfferView(doc, customer, sub = null) {
|
||||||
|
const view = offerView(doc)
|
||||||
const testAccount = isTestEmail(customer?.email)
|
const testAccount = isTestEmail(customer?.email)
|
||||||
const attachedLater = doc.scope === 'laufend' || testAccount
|
const attachedLater = doc.scope === 'laufend' || testAccount
|
||||||
return {
|
const discount = doc.status === OFFER_STATUS.active
|
||||||
...offerView(doc),
|
|
||||||
testAccount,
|
|
||||||
discount: doc.status === OFFER_STATUS.active
|
|
||||||
? redeemedDiscount({ months: doc.months, appliedAt: doc.appliedAt, attachedLater }, sub)
|
? redeemedDiscount({ months: doc.months, appliedAt: doc.appliedAt, attachedLater }, sub)
|
||||||
: null,
|
: null
|
||||||
|
// Angebots-Mail (erneut) senden: solange das Angebot offen und gueltig ist, bei laufendem Abo solange
|
||||||
|
// die erste rabattierte Abbuchung noch bevorsteht (die Mail nennt sie als "naechste Abbuchung")
|
||||||
|
const mailSendable = Boolean(customer?.email) && (
|
||||||
|
view.status === OFFER_STATUS.open ||
|
||||||
|
(doc.status === OFFER_STATUS.active && doc.scope === 'laufend' && Boolean(discount?.known) && new Date(discount.firstChargeAt) > new Date())
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...view,
|
||||||
|
testAccount,
|
||||||
|
discount,
|
||||||
|
mailSendable,
|
||||||
customerId: doc.customerId,
|
customerId: doc.customerId,
|
||||||
customerName: customer?.name || customer?.companyName || '(Kunde nicht gefunden)',
|
customerName: customer?.name || customer?.companyName || '(Kunde nicht gefunden)',
|
||||||
customerCode: customer?.code || '',
|
customerCode: customer?.code || '',
|
||||||
|
customerEmail: customer?.email || '',
|
||||||
internalNote: doc.internalNote || '',
|
internalNote: doc.internalNote || '',
|
||||||
createdBy: doc.createdBy || '',
|
createdBy: doc.createdBy || '',
|
||||||
notifiedAt: doc.notifiedAt || null,
|
notifiedAt: doc.notifiedAt || null,
|
||||||
@@ -239,13 +249,18 @@ export function validateOfferInput(body) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mailOffer(doc, customer, sub) {
|
/**
|
||||||
|
* Angebots-Mail an den Kunden (Kopie per BCC ans Team). nextChargeAt: bei laufendem Abo die erste
|
||||||
|
* rabattierte Abbuchung. Wirft mit Grund, wenn der Mailserver die Mail oder den Kunden als Empfaenger
|
||||||
|
* nicht annimmt; Ergebnis und Fehler stehen im Server-Log.
|
||||||
|
*/
|
||||||
|
async function mailOffer(doc, customer, nextChargeAt = null) {
|
||||||
const running = doc.scope === 'laufend'
|
const running = doc.scope === 'laufend'
|
||||||
const lines = [
|
const lines = [
|
||||||
`Guten Tag ${customer.name || ''}`.trim() + ',',
|
`Guten Tag ${customer.name || ''}`.trim() + ',',
|
||||||
'',
|
'',
|
||||||
running
|
running
|
||||||
? `wie besprochen gilt für Ihr WEBklar Wartungs-Abo ab der nächsten Abbuchung${sub?.currentPeriodEnd ? ` am ${fmtDate(sub.currentPeriodEnd)}` : ''}:`
|
? `wie besprochen gilt für Ihr WEBklar Wartungs-Abo ab der nächsten Abbuchung${nextChargeAt ? ` am ${fmtDate(nextChargeAt)}` : ''}:`
|
||||||
: 'wie besprochen haben wir Ihnen im Kundenportal ein persönliches Angebot für das WEBklar Wartungs-Abo hinterlegt:',
|
: 'wie besprochen haben wir Ihnen im Kundenportal ein persönliches Angebot für das WEBklar Wartungs-Abo hinterlegt:',
|
||||||
'',
|
'',
|
||||||
` Angebot: ${offerText(doc)}`,
|
` Angebot: ${offerText(doc)}`,
|
||||||
@@ -268,11 +283,23 @@ async function mailOffer(doc, customer, sub) {
|
|||||||
'',
|
'',
|
||||||
FOOTER,
|
FOOTER,
|
||||||
]
|
]
|
||||||
await sendCustomerMail(
|
const ref = shortRef(doc.$id)
|
||||||
|
try {
|
||||||
|
const info = await sendCustomerMail(
|
||||||
customer.email,
|
customer.email,
|
||||||
running ? 'Ihr Angebot für das WEBklar Wartungs-Abo ist eingerichtet' : 'Ihr persönliches Angebot für das WEBklar Wartungs-Abo',
|
running ? 'Ihr Angebot für das WEBklar Wartungs-Abo ist eingerichtet' : 'Ihr persönliches Angebot für das WEBklar Wartungs-Abo',
|
||||||
lines
|
lines
|
||||||
)
|
)
|
||||||
|
// Lehnt der Mailserver nur den Kunden ab, geht die BCC-Kopie trotzdem raus und sendMail meldet Erfolg
|
||||||
|
const to = String(customer.email).toLowerCase()
|
||||||
|
if ((info?.rejected || []).some((r) => String(r?.address || r).toLowerCase() === to)) {
|
||||||
|
throw new Error(`Mailserver hat die Adresse abgelehnt (${info.rejectedErrors?.[0]?.response || info.response || 'ohne Antwort'})`)
|
||||||
|
}
|
||||||
|
console.log(`[offers] Angebots-Mail ${ref} vom Mailserver angenommen: ${info?.messageId || ''} ${info?.response || ''}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[offers] Angebots-Mail ${ref} fehlgeschlagen:`, err.message)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -345,7 +372,7 @@ export async function createOffer(body, admin) {
|
|||||||
if (!customer.email) {
|
if (!customer.email) {
|
||||||
mail = { sent: false, reason: 'Kunde hat keine E-Mail-Adresse' }
|
mail = { sent: false, reason: 'Kunde hat keine E-Mail-Adresse' }
|
||||||
} else {
|
} else {
|
||||||
mail = await mailOffer(doc, customer, sub).then(
|
mail = await mailOffer(doc, customer, running ? sub.currentPeriodEnd : null).then(
|
||||||
() => ({ sent: true }),
|
() => ({ sent: true }),
|
||||||
(e) => ({ sent: false, reason: e.message })
|
(e) => ({ sent: false, reason: e.message })
|
||||||
)
|
)
|
||||||
@@ -374,6 +401,28 @@ export async function withdrawOffer(id, admin) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Angebots-Mail (erneut) senden, z. B. wenn sie beim Kunden nicht angekommen ist. */
|
||||||
|
export async function resendOfferMail(id) {
|
||||||
|
const doc = await getDocument(COLLECTION(), id).catch(() => null)
|
||||||
|
if (!doc) throw httpError(404, 'Angebot nicht gefunden')
|
||||||
|
const customer = await getDocument(config.collections.customers, doc.customerId).catch(() => null)
|
||||||
|
if (!customer) throw httpError(404, 'Kunde nicht gefunden')
|
||||||
|
if (!customer.email) throw httpError(400, 'Beim Kunden ist keine E-Mail-Adresse hinterlegt.')
|
||||||
|
const sub = doc.stripeSubscriptionId
|
||||||
|
? await getDocument(config.collections.subscriptions, doc.stripeSubscriptionId).catch(() => null)
|
||||||
|
: null
|
||||||
|
const { mailSendable, discount } = adminOfferView(doc, customer, sub)
|
||||||
|
if (!mailSendable) {
|
||||||
|
throw httpError(409, 'Für dieses Angebot geht keine E-Mail mehr raus: Es ist abgelaufen, zurückgezogen oder schon eingelöst.')
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await mailOffer(doc, customer, doc.scope === 'laufend' ? discount.firstChargeAt : null)
|
||||||
|
} catch (err) {
|
||||||
|
throw httpError(502, `E-Mail nicht verschickt: ${err.message}`)
|
||||||
|
}
|
||||||
|
return updateDocument(COLLECTION(), id, { notifiedAt: new Date().toISOString() })
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Checkout abgeschlossen (Stripe-Webhook)
|
// Checkout abgeschlossen (Stripe-Webhook)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { adminOfferView } from '../server/services/offers.js'
|
||||||
|
|
||||||
|
const DAY = 86400000
|
||||||
|
const iso = (ms) => new Date(ms).toISOString()
|
||||||
|
const ymd = (ms) => iso(ms).slice(0, 10)
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
const offer = (fields = {}) => ({
|
||||||
|
$id: 'offer-1', $createdAt: iso(now - DAY), status: 'offen', scope: 'neu', months: 2,
|
||||||
|
monthlyPriceCents: 2500, regularPriceCents: 5000, customerId: 'c1',
|
||||||
|
validUntil: `${ymd(now + 10 * DAY)}T00:00:00.000Z`, notifiedAt: null, ...fields,
|
||||||
|
})
|
||||||
|
const customer = { name: 'Martin', email: 'martin@example.invalid' }
|
||||||
|
|
||||||
|
test('Offenes, gültiges Angebot: Mail kann (erneut) raus, Adresse steht in der Admin-Ansicht', () => {
|
||||||
|
const v = adminOfferView(offer(), customer)
|
||||||
|
assert.equal(v.mailSendable, true)
|
||||||
|
assert.equal(v.customerEmail, 'martin@example.invalid')
|
||||||
|
assert.equal(v.notifiedAt, null)
|
||||||
|
assert.equal(adminOfferView(offer({ notifiedAt: iso(now) }), customer).mailSendable, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Keine Mail bei abgelaufenem, zurückgezogenem oder eingelöstem Angebot und ohne Adresse', () => {
|
||||||
|
assert.equal(adminOfferView(offer({ validUntil: `${ymd(now - 2 * DAY)}T00:00:00.000Z` }), customer).mailSendable, false)
|
||||||
|
assert.equal(adminOfferView(offer({ status: 'zurueckgezogen' }), customer).mailSendable, false)
|
||||||
|
const sub = { startedAt: iso(now - DAY), trialEnd: null }
|
||||||
|
assert.equal(adminOfferView(offer({ status: 'aktiv', appliedAt: iso(now - DAY + 5000) }), customer, sub).mailSendable, false)
|
||||||
|
const v = adminOfferView(offer(), { name: 'Ohne Mail', email: '' })
|
||||||
|
assert.equal(v.mailSendable, false)
|
||||||
|
assert.equal(v.customerEmail, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Laufendes Abo: Mail nur, solange die erste rabattierte Abbuchung bevorsteht', () => {
|
||||||
|
const future = adminOfferView(
|
||||||
|
offer({ status: 'aktiv', scope: 'laufend', appliedAt: iso(now - 3600000) }),
|
||||||
|
customer,
|
||||||
|
{ startedAt: iso(now - 10 * DAY), trialEnd: null }
|
||||||
|
)
|
||||||
|
assert.equal(future.mailSendable, true)
|
||||||
|
const past = adminOfferView(
|
||||||
|
offer({ status: 'aktiv', scope: 'laufend', appliedAt: iso(now - 40 * DAY) }),
|
||||||
|
customer,
|
||||||
|
{ startedAt: iso(now - 50 * DAY), trialEnd: null }
|
||||||
|
)
|
||||||
|
assert.equal(past.mailSendable, false)
|
||||||
|
// Abo-Dokument fehlt: Termin unbekannt, also keine Mail
|
||||||
|
assert.equal(adminOfferView(offer({ status: 'aktiv', scope: 'laufend', appliedAt: iso(now) }), customer, null).mailSendable, false)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user