Abo-Angebote: Admin sieht, bis wann der Rabatt läuft

"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 <heute + N>
  und <gültig bis + N>", 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 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-26 23:01:45 +00:00
1 parent c600118374
commit 054b04d5b6
5 files changed
+227 -12

No files matched your search

+9 -4
View File
@@ -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 || '',
})
+55
View File
@@ -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,
}
}
+22 -3
View File
@@ -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) {