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:
ClaudeandClaude Opus 5.5 committed 2026-09-27 11:12:39 +00:00
1 parent 81119c19bc
commit 32e441c8bc
5 files changed
+161 -18

No files matched your search

+50
View File
@@ -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)
})