From 054b04d5b68f790e486222d92a50782aa12967d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 23:01:45 +0000 Subject: [PATCH] =?UTF-8?q?Abo-Angebote:=20Admin=20sieht,=20bis=20wann=20d?= =?UTF-8?q?er=20Rabatt=20l=C3=A4uft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Gültig bis" ist nur die Frist zum Annehmen. Jetzt steht auch das Rabattende da: - Formular "Neues Angebot", Kunde ohne Abo: Schätzung "Rabatt läuft N Monate ab Abschluss und endet voraussichtlich zwischen und ", mit Hinweis auf den späteren Start nach der Widerrufsfrist (gut 2 Wochen) - Formular, laufendes Abo: genaues Ende aus den nächsten Abbuchungen (Anker = Leistungsbeginn, Monatsende wie bei Stripe), auch in der Erfolgsmeldung - Angebotsliste: offen -> Schätzung, aktiv -> "Rabatt endet am ..." aus dem Abo-Dokument (bzw. "endete", "endet mit dem Abo am ...", "unbekannt (Abo nicht gefunden)") Rechnung in server/services/offerDiscount.js: Der Coupon deckt die N Abbuchungen ab dem Anwenden. Beim Checkout ist das der Abo-Start, bei laufendem Abo und Testkonto (Coupon per Webhook) der Zeitpunkt des Anhängens; Testkonten laufen deshalb einen Monat länger. Tests: npm test (37 Fälle, davon 8 neu). Co-Authored-By: Claude Opus 5.5 --- public/admin.js | 65 +++++++++++++++++++++++-- server/routes/offers.js | 13 +++-- server/services/offerDiscount.js | 55 ++++++++++++++++++++++ server/services/offers.js | 25 ++++++++-- test/offerDiscount.test.js | 81 ++++++++++++++++++++++++++++++++ 5 files changed, 227 insertions(+), 12 deletions(-) create mode 100644 server/services/offerDiscount.js create mode 100644 test/offerDiscount.test.js diff --git a/public/admin.js b/public/admin.js index d0b6741..010b269 100644 --- a/public/admin.js +++ b/public/admin.js @@ -804,6 +804,44 @@ function offerPreviewText(price, months, regular, running) { // Datumsfeld "2026-10-27" -> "27.10.2026", ohne Umweg über Date (keine Zeitzonen-Verschiebung) const fmtYmd = (ymd) => String(ymd || '').split('-').reverse().join('.') +// Zeitpunkt -> Kalendertag in Deutschland (YYYY-MM-DD); Abbuchungen und Fristen zählen nach deutschem Kalender +const berlinYmd = (value) => new Date(value).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }) +const fmtBerlin = (value) => fmtYmd(berlinYmd(value)) + +// Kalendertag + n Monate, Monatsende wie bei Stripe (31.01. + 1 Monat = 28./29.02.) +function addMonthsYmd(ymd, n) { + const [y, m, d] = ymd.split('-').map(Number) + const last = new Date(Date.UTC(y, m + n, 0)).getUTCDate() + return new Date(Date.UTC(y, m - 1 + n, Math.min(d, last))).toISOString().slice(0, 10) +} + +/* Rabattende eines noch nicht eingelösten Angebots: Der Rabatt läuft N Monate ab Abschluss, der Kunde + kann heute oder spätestens am letzten Gültigkeitstag abschließen. Mit Beginn nach der Widerrufsfrist + starten Leistung und Rabatt gut 2 Wochen später (deadlines.js lateStartAt). */ +function offerDiscountEstimate(months, validUntilYmd, testAccount = false) { + const today = berlinYmd(new Date()) + // Testkonto: Coupon kommt erst nach der ersten Abbuchung ans Abo (offers.js), der Rabatt endet einen Monat später + const n = months + (testAccount ? 1 : 0) + return { earliest: addMonthsYmd(today, n), latest: addMonthsYmd(validUntilYmd < today ? today : validUntilYmd, n) } +} + +// Zeile "Rabatt endet …" in der Angebotsliste +function offerDiscountLine(o) { + if (o.status === 'offen' && o.validUntil) { + const { earliest, latest } = offerDiscountEstimate(o.months, String(o.validUntil).slice(0, 10), o.testAccount) + return earliest === latest + ? `Rabatt endet voraussichtlich am ${fmtYmd(earliest)}` + : `Rabatt endet voraussichtlich zwischen ${fmtYmd(earliest)} und ${fmtYmd(latest)}` + } + if (o.status !== 'aktiv' || !o.discount) return '' + const d = o.discount + if (!d.known) return 'Rabattende unbekannt (Abo nicht gefunden)' + const end = d.subscriptionEndsAt || d.endsAt + const past = new Date(end) <= new Date() + return d.subscriptionEndsAt + ? `Rabatt ${past ? 'endete' : 'endet'} mit dem Abo am ${fmtBerlin(end)}` + : `Rabatt ${past ? 'endete' : 'endet'} am ${fmtBerlin(end)}` +} function showOfferMessage(text) { const el = document.getElementById('offer-message') @@ -820,17 +858,21 @@ async function loadOffers() { tbody.innerHTML = `${offerState.filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'}` return } - tbody.innerHTML = shown.map((o) => ` + tbody.innerHTML = shown.map((o) => { + const discount = offerDiscountLine(o) + return ` ${fmtDateTime(o.createdAt)}
${escapeHtml(o.ref)}${o.createdBy ? ` · ${escapeHtml(o.createdBy.replace(/\s*<.*>$/, ''))}` : ''} ${escapeHtml(o.customerName)}${o.customerCode ? `
Kd.-Nr. ${escapeHtml(o.customerCode)}` : ''} ${escapeHtml(o.text)}
Ersparnis ${euroFromCents(o.savingsCents)}${o.note ? ` · „${escapeHtml(o.note)}“` : ''} + ${discount ? `
${escapeHtml(discount)}` : ''} ${o.internalNote ? `
intern: ${escapeHtml(o.internalNote)}` : ''} ${o.scope === 'laufend' ? 'laufendes Abo' : fmtDate(o.validUntil)} ${offerStatusPill(o)}
Stripe: ${o.stripeCouponUrl ? `Coupon` : '–'}${o.stripeSubscriptionUrl ? ` · Abo` : ''} ${['offen', 'abgelaufen'].includes(o.status) ? `
` : ''} - `).join('') + ` + }).join('') tbody.querySelectorAll('[data-withdraw-offer]').forEach((btn) => btn.addEventListener('click', () => withdrawOfferAction(btn.dataset.withdrawOffer, btn))) } catch (err) { @@ -902,13 +944,23 @@ function renderOfferPreview(form) { } if (ok) { lines.push(`${offerPreviewText(v.monthlyPriceCents, v.months, regular, running)} · Ersparnis ${euroFromCents((regular - v.monthlyPriceCents) * v.months)}`) - if (!running) lines.push(`Angebot gültig bis ${fmtYmd(v.validUntil)}`) + if (running) { + const charges = ctx.subscription.upcomingCharges || [] + if (charges.length > v.months) lines.push(`Rabatt endet am ${fmtBerlin(charges[v.months])} (nach ${v.months === 1 ? '1 Abbuchung' : `${v.months} Abbuchungen`}).`) + } else { + lines.push(`Angebot gültig bis ${fmtYmd(v.validUntil)}`) + const { earliest, latest } = offerDiscountEstimate(v.months, v.validUntil, ctx.customer.testAccount) + const lead = `Rabatt läuft ${v.months === 1 ? '1 Monat' : `${v.months} Monate`} ab Abschluss und endet voraussichtlich` + lines.push(`${earliest === latest + ? `${lead} am ${fmtYmd(earliest)}.` + : `${lead} zwischen ${fmtYmd(earliest)} (Abschluss heute) und ${fmtYmd(latest)} (Abschluss am letzten Tag).`} Bei Beginn nach der Widerrufsfrist gut 2 Wochen später.`) + } } lines.push(running ? `Laufendes Abo: gilt ab der nächsten Abbuchung${ctx.subscription.currentPeriodEnd ? ` (${fmtDate(ctx.subscription.currentPeriodEnd)})` : ''} und wird sofort in Stripe angewendet.` : 'Noch kein laufendes Abo: Der Coupon wird angelegt und beim Abschluss im Kundenportal angewendet.') if (!running && !ctx.customer.portalAccessEnabled) lines.push('Achtung: Kunde hat keinen Portalzugang – er kann erst bestellen, wenn der Zugang freigeschaltet ist.') - if (ctx.customer.testAccount) lines.push('Testkonto: Im Checkout bleibt der 0,50-€-Testrabatt, das Angebot wird danach zusätzlich angehängt.') + if (ctx.customer.testAccount) lines.push(`Testkonto: Im Checkout bleibt der 0,50-€-Testrabatt, das Angebot wird danach zusätzlich angehängt${running ? '' : ' und gilt deshalb erst ab der zweiten Abbuchung'}.`) box.innerHTML = lines.map((l) => `
${escapeHtml(l)}
`).join('') save.disabled = !ok save.textContent = running ? 'Angebot anlegen und aufs laufende Abo anwenden' : 'Angebot anlegen' @@ -943,8 +995,11 @@ async function saveOffer(form) { const result = await api('/api/portal-admin/offers', { method: 'POST', body: JSON.stringify(v) }) const o = result.offer form.classList.add('hidden') + const until = o.scope !== 'laufend' + ? `, gültig bis ${fmtDate(o.validUntil)}` + : o.discount?.known ? `, Rabatt endet am ${fmtBerlin(o.discount.endsAt)}` : '' showOfferMessage([ - `Angebot ${o.ref} für ${o.customerName} angelegt: ${o.text}${o.scope === 'laufend' ? '' : `, gültig bis ${fmtDate(o.validUntil)}`}.`, + `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' : ''}.`, v.notifyCustomer ? (result.mailSent ? 'Die E-Mail an den Kunden ist raus.' : `E-Mail an den Kunden fehlgeschlagen (${result.mailError}).`) : '', ].filter(Boolean).join(' ')) diff --git a/server/routes/offers.js b/server/routes/offers.js index d3a72ba..fba2078 100644 --- a/server/routes/offers.js +++ b/server/routes/offers.js @@ -22,12 +22,17 @@ function sendError(res, err, fallback) { offersAdminRouter.get('/', async (_req, res) => { try { - const [docs, customers] = await Promise.all([ + const [docs, customers, subs] = await Promise.all([ listOffers(), listDocuments(config.collections.customers, [Query.limit(1000)]), + // Abo-Dokumente (ID = Stripe-Subscription) fuer das Rabattende eingeloester Angebote + listDocuments(config.collections.subscriptions, [Query.limit(1000)]), ]) const byId = new Map(customers.map((c) => [c.$id, c])) - return res.json({ offers: docs.map((d) => adminOfferView(d, byId.get(d.customerId))) }) + const subById = new Map(subs.map((s) => [s.$id, s])) + return res.json({ + offers: docs.map((d) => adminOfferView(d, byId.get(d.customerId), subById.get(d.stripeSubscriptionId))), + }) } catch (err) { return sendError(res, err, 'Angebote konnten nicht geladen werden') } @@ -43,9 +48,9 @@ offersAdminRouter.get('/context/:customerId', async (req, res) => { offersAdminRouter.post('/', async (req, res) => { try { - const { doc, customer, mail } = await createOffer(req.body || {}, adminOf(req)) + const { doc, customer, mail, sub } = await createOffer(req.body || {}, adminOf(req)) return res.status(201).json({ - offer: adminOfferView(doc, customer), + offer: adminOfferView(doc, customer, sub), mailSent: mail.sent, mailError: mail.sent ? '' : mail.reason || '', }) diff --git a/server/services/offerDiscount.js b/server/services/offerDiscount.js new file mode 100644 index 0000000..83f9659 --- /dev/null +++ b/server/services/offerDiscount.js @@ -0,0 +1,55 @@ +/** + * Rabattzeitraum eines Abo-Angebots (reine Datumsrechnung, ohne Stripe- oder Appwrite-Aufruf). + * + * Abgebucht wird am Leistungsbeginn (Anker: Ende der Probezeit bzw. Abo-Start) plus k Monate, am + * Monatsende wie bei Stripe. Der Angebots-Coupon (duration "repeating", N Monate) gilt fuer die + * Rechnungen der N Monate ab dem Anwenden, bei monatlicher Abrechnung also fuer N Abbuchungen: + * - Angebot beim Abschluss (Checkout): ab Abo-Start, also die Abbuchungen 1..N + * - laufendes Abo und Testkonto (Coupon kommt erst nach dem Abschluss per Webhook): ab dem Anhaengen + * Der Rabatt endet mit der Abbuchung danach, ab dann gilt wieder der regulaere Preis. + */ + +// Wie addMonths in stripeBilling.js: 31.01. + 1 Monat = 28./29.02., immer vom Anker aus gerechnet +function addMonths(date, months) { + const d = new Date(date) + const day = d.getUTCDate() + d.setUTCDate(1) + d.setUTCMonth(d.getUTCMonth() + months) + const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate() + d.setUTCDate(Math.min(day, last)) + return d +} + +/** Die naechsten `count` Abbuchungen eines Abos, beginnend mit der ersten am oder nach `from` (ISO-Strings). */ +export function chargesFrom(anchor, from, count) { + // new Date(null) waere der 01.01.1970, deshalb fehlende Werte vorab abfangen + if (!anchor || !from) return [] + const start = new Date(anchor) + const since = new Date(from) + if (Number.isNaN(start.getTime()) || Number.isNaN(since.getTime())) return [] + let k = 0 + while (addMonths(start, k) < since) k += 1 + return Array.from({ length: count }, (_, i) => addMonths(start, k + i).toISOString()) +} + +/** + * Rabatt eines eingeloesten Angebots. `attachedLater`: Coupon wurde erst nach dem Abo-Start angehaengt + * (laufendes Abo, Testkonto), dann zaehlt `appliedAt` statt des Abo-Starts. + * Ergebnis: erste rabattierte Abbuchung, Rabattende (= erste Abbuchung zum regulaeren Preis) und ein + * frueheres Abo-Ende (gekuendigt/beendet), oder { known: false }, wenn das Abo fehlt. + */ +export function redeemedDiscount({ months, appliedAt, attachedLater }, sub) { + const anchor = sub?.trialEnd || sub?.startedAt + const from = attachedLater ? appliedAt : sub?.startedAt + const charges = anchor && from ? chargesFrom(anchor, from, months + 1) : [] + if (!charges.length) return { known: false } + const endsAt = charges[months] + const subscriptionEndsAt = sub.endedAt || sub.cancelAt || null + return { + known: true, + firstChargeAt: charges[0], + endsAt, + // Abo endet vor dem Rabatt: danach wird ohnehin nichts mehr abgebucht + subscriptionEndsAt: subscriptionEndsAt && new Date(subscriptionEndsAt) < new Date(endsAt) ? subscriptionEndsAt : null, + } +} diff --git a/server/services/offers.js b/server/services/offers.js index f02f967..d5d9ea6 100644 --- a/server/services/offers.js +++ b/server/services/offers.js @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto' 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 { ACTIVE_STATUSES, getCurrentSubscription, getStripe, isStripeConfigured } from './stripeBilling.js' import { shortRef } from './takeoverCredits.js' @@ -101,9 +102,19 @@ function stripeDashboardUrl(kind, id) { return `https://dashboard.stripe.com/${test}${kind}/${id}` } -export function adminOfferView(doc, customer) { +/** + * sub = Abo-Dokument des eingeloesten Angebots (doc.stripeSubscriptionId), fuer das Rabattende. + * Testkonten bekommen den Coupon erst nach dem Abschluss (activateOfferFromCheckout), wie ein laufendes Abo. + */ +export function adminOfferView(doc, customer, sub = null) { + const testAccount = isTestEmail(customer?.email) + const attachedLater = doc.scope === 'laufend' || testAccount return { ...offerView(doc), + testAccount, + discount: doc.status === OFFER_STATUS.active + ? redeemedDiscount({ months: doc.months, appliedAt: doc.appliedAt, attachedLater }, sub) + : null, customerId: doc.customerId, customerName: customer?.name || customer?.companyName || '(Kunde nicht gefunden)', customerCode: customer?.code || '', @@ -155,7 +166,15 @@ export async function offerContext(customerId) { testAccount: isTestEmail(customer.email), }, subscription: sub - ? { status: sub.status, active: ACTIVE_STATUSES.has(sub.status), currentPeriodEnd: sub.currentPeriodEnd || null } + ? { + status: sub.status, + active: ACTIVE_STATUSES.has(sub.status), + 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) + : [], + } : null, openOffer: open ? offerView(open) : null, regularPriceCents: config.stripe.priceAmountCents, @@ -333,7 +352,7 @@ export async function createOffer(body, admin) { if (mail.sent) doc = await updateDocument(COLLECTION(), id, { notifiedAt: new Date().toISOString() }) } } - return { doc, customer, mail } + return { doc, customer, mail, sub: running ? sub : null } } export async function withdrawOffer(id, admin) { diff --git a/test/offerDiscount.test.js b/test/offerDiscount.test.js new file mode 100644 index 0000000..8c61e0a --- /dev/null +++ b/test/offerDiscount.test.js @@ -0,0 +1,81 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +// Vor dem Import von config.js setzen (jede Testdatei laeuft in einem eigenen Prozess) +process.env.ABO_TEST_EMAILS = 'test@example.invalid' +const { chargesFrom, redeemedDiscount } = await import('../server/services/offerDiscount.js') +const { adminOfferView } = await import('../server/services/offers.js') + +test('Abbuchungen: vom Anker aus, Monatsende wie bei Stripe', () => { + assert.deepEqual(chargesFrom('2026-09-26T08:00:00.000Z', '2026-09-26T08:00:00.000Z', 3), [ + '2026-09-26T08:00:00.000Z', '2026-10-26T08:00:00.000Z', '2026-11-26T08:00:00.000Z', + ]) + // 31. bleibt Anker: Februar kuerzer, danach wieder der 31. + assert.deepEqual(chargesFrom('2026-01-31T10:00:00.000Z', '2026-01-31T10:00:00.000Z', 4), [ + '2026-01-31T10:00:00.000Z', '2026-02-28T10:00:00.000Z', '2026-03-31T10:00:00.000Z', '2026-04-30T10:00:00.000Z', + ]) + // Ab einem Zeitpunkt mitten im Abrechnungsmonat: naechste Abbuchung + assert.deepEqual(chargesFrom('2026-01-15T10:00:00.000Z', '2026-03-20T12:00:00.000Z', 2), [ + '2026-04-15T10:00:00.000Z', '2026-05-15T10:00:00.000Z', + ]) + assert.deepEqual(chargesFrom(null, '2026-03-20T12:00:00.000Z', 2), []) +}) + +test('Angebot beim Abschluss, Leistung sofort: Abbuchungen 1..N rabattiert', () => { + const sub = { startedAt: '2026-09-26T08:00:00.000Z', trialEnd: null } + const d = redeemedDiscount({ months: 2, appliedAt: '2026-09-26T08:00:05.000Z', attachedLater: false }, sub) + assert.deepEqual(d, { known: true, firstChargeAt: '2026-09-26T08:00:00.000Z', endsAt: '2026-11-26T08:00:00.000Z', subscriptionEndsAt: null }) +}) + +test('Angebot beim Abschluss, Beginn nach Widerrufsfrist: Rabatt ab Leistungsbeginn', () => { + const sub = { startedAt: '2026-09-26T08:00:00.000Z', trialEnd: '2026-10-13T04:00:00.000Z' } + const d = redeemedDiscount({ months: 2, appliedAt: '2026-09-26T08:00:05.000Z', attachedLater: false }, sub) + assert.equal(d.firstChargeAt, '2026-10-13T04:00:00.000Z') + assert.equal(d.endsAt, '2026-12-13T04:00:00.000Z') +}) + +test('Testkonto (Coupon nach der ersten Abbuchung): Abbuchungen 2..N+1', () => { + const sub = { startedAt: '2026-09-26T08:00:00.000Z', trialEnd: null } + const d = redeemedDiscount({ months: 2, appliedAt: '2026-09-26T08:00:05.000Z', attachedLater: true }, sub) + assert.equal(d.firstChargeAt, '2026-10-26T08:00:00.000Z') + assert.equal(d.endsAt, '2026-12-26T08:00:00.000Z') +}) + +test('Laufendes Abo: ab der naechsten Abbuchung nach dem Anhaengen', () => { + const sub = { startedAt: '2026-03-14T09:00:00.000Z', trialEnd: null } + const d = redeemedDiscount({ months: 2, appliedAt: '2026-09-26T20:00:00.000Z', attachedLater: true }, sub) + assert.equal(d.firstChargeAt, '2026-10-14T09:00:00.000Z') + assert.equal(d.endsAt, '2026-12-14T09:00:00.000Z') +}) + +test('Abo endet vor dem Rabatt: Abo-Ende wird gemeldet, sonst nicht', () => { + const base = { startedAt: '2026-09-26T08:00:00.000Z', trialEnd: null } + const offer = { months: 3, appliedAt: '2026-09-26T08:00:05.000Z', attachedLater: false } + assert.equal(redeemedDiscount(offer, { ...base, cancelAt: '2026-10-26T08:00:00.000Z' }).subscriptionEndsAt, '2026-10-26T08:00:00.000Z') + assert.equal(redeemedDiscount(offer, { ...base, endedAt: '2026-10-01T08:00:00.000Z' }).subscriptionEndsAt, '2026-10-01T08:00:00.000Z') + assert.equal(redeemedDiscount(offer, { ...base, cancelAt: '2027-09-26T08:00:00.000Z' }).subscriptionEndsAt, null) +}) + +test('Ohne Abo-Dokument ist das Rabattende unbekannt', () => { + assert.deepEqual(redeemedDiscount({ months: 2, appliedAt: '2026-09-26T08:00:05.000Z', attachedLater: false }, null), { known: false }) + assert.deepEqual(redeemedDiscount({ months: 2, appliedAt: null, attachedLater: true }, { startedAt: '2026-09-26T08:00:00.000Z' }), { known: false }) +}) + +test('Admin-Ansicht: Rabatt nur bei eingeloesten Angeboten, Testkonto erkannt', () => { + const doc = { + $id: 'offer-1', $createdAt: '2026-09-26T07:00:00.000Z', status: 'aktiv', scope: 'neu', months: 2, + monthlyPriceCents: 2500, regularPriceCents: 5000, validUntil: '2026-10-27T00:00:00.000Z', + appliedAt: '2026-09-26T08:00:05.000Z', stripeSubscriptionId: 'sub_1', customerId: 'c1', + } + const sub = { $id: 'sub_1', startedAt: '2026-09-26T08:00:00.000Z', trialEnd: null } + const normal = adminOfferView(doc, { name: 'Kunde', email: 'kunde@example.invalid' }, sub) + assert.equal(normal.testAccount, false) + assert.equal(normal.discount.endsAt, '2026-11-26T08:00:00.000Z') + const testView = adminOfferView(doc, { name: 'Test', email: 'TEST@example.invalid' }, sub) + assert.equal(testView.testAccount, true) + assert.equal(testView.discount.endsAt, '2026-12-26T08:00:00.000Z') + const running = adminOfferView({ ...doc, scope: 'laufend', appliedAt: '2026-10-01T12:00:00.000Z' }, { email: 'kunde@example.invalid' }, sub) + assert.equal(running.discount.firstChargeAt, '2026-10-26T08:00:00.000Z') + assert.equal(adminOfferView({ ...doc, status: 'offen', appliedAt: null }, { email: 'kunde@example.invalid' }, null).discount, null) + assert.deepEqual(adminOfferView(doc, { email: 'kunde@example.invalid' }, undefined).discount, { known: false }) +})