Wechsel Hosting → Wartung: neuer Abbuchungstag, Gutschrift, 12 neue Monate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-28 11:30:21 +00:00
1 parent 379cc26dcd
commit 188db4b3c1
15 files changed
+2035 -12

No files matched your search

+81
View File
@@ -11,6 +11,14 @@ import { getLedger } from '../services/hourLedger.js'
import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
import { changeStorage, previewStorageChange, recordPaidStorage } from '../services/mailStorage.js'
import { activateOfferFromCheckout, getOpenOffer, offerText, offerView } from '../services/offers.js'
import {
handlePendingUpdateApplied,
handlePendingUpdateExpired,
handleSwitchInvoicePaid,
handleSwitchPaymentFailed,
previewSwitch,
switchToWartung,
} from '../services/planSwitch.js'
import { PRODUCT_KEYS, productView, termsDocFor } from '../services/products.js'
import {
ACTIVE_STATUSES,
@@ -311,6 +319,64 @@ router.post('/storage', requireSession, async (req, res) => {
}
})
// ---------------------------------------------------------------------------
// Wechsel vom Hosting- ins Wartungs-Abo (Spec 3.5, Bedingungen § 9): Vorschau und Bestellung (planSwitch.js)
// ---------------------------------------------------------------------------
/** Vorschau fuer die Bestelluebersicht: Gutschrift, heute faellig, Monatspreis, neuer Abbuchungstag, Mindestlaufzeit. */
router.post('/switch/preview', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus nicht möglich.' })
}
return res.json(await previewSwitch(getSessionCustomerId(req)))
} catch (err) {
return res.status(err.status || 500).json({ error: err.message || 'Vorschau fehlgeschlagen' })
}
})
/**
* Bestellung { acceptTerms: true, earlyStart: true, termsVersion, prorationDate } ("Zahlungspflichtig bestellen").
* Nachweis: Fassung, Zeitpunkt, IP, Browser und die Zustimmung zum Sofortbeginn (§ 357a Abs. 2 BGB) landen am
* Abo und im Ereignis `wechsel`. Antwort: die Abo-Ansicht wie in GET /api/abo und paymentUrl (Karte: Zahlung
* noch zu bestaetigen, das Portal leitet zur Stripe-Zahlungsseite weiter; sonst null).
*/
router.post('/switch', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht gewechselt werden.' })
}
const { acceptTerms, earlyStart, termsVersion, prorationDate } = req.body || {}
if (acceptTerms !== true) {
return res.status(400).json({ error: 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu wechseln.' })
}
if (earlyStart !== true) {
return res.status(400).json({ error: 'Bitte bestätigen Sie, dass wir sofort mit dem Wartungs-Abo beginnen sollen.' })
}
if (!termsVersion || !Number.isInteger(prorationDate)) {
return res.status(400).json({ error: 'Die Bestellung ist unvollständig. Bitte laden Sie die Seite neu.' })
}
if (termsVersion !== config.abo.termsVersion) {
return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' })
}
const customerId = getSessionCustomerId(req)
const now = new Date().toISOString()
const { subscription, paymentUrl } = await switchToWartung(
customerId,
{ termsVersion, consentAt: now, earlyStartConsentAt: now, consentIp: req.ip, consentUserAgent: req.get('user-agent') || '' },
prorationDate
)
const events = await listContractEvents(customerId).catch((e) => {
console.error('[abo] Vertragsereignisse nicht lesbar:', e.message)
return []
})
return res.json({ subscription: publicSubscription(subscription, events), paymentUrl })
} catch (err) {
console.error('[abo] Wechsel fehlgeschlagen:', err.message)
return res.status(err.status || 500).json({ error: err.message || 'Wechsel fehlgeschlagen' })
}
})
// ---------------------------------------------------------------------------
// Stripe-Webhook
// ---------------------------------------------------------------------------
@@ -461,11 +527,17 @@ async function handleStripeEvent(event) {
if (storage.recorded) {
console.log(`[abo] Zusatzspeicher bezahlt: Abo ${storage.subscriptionId}, ${storage.units} Pakete bis ${storage.periodEnd}`)
}
// Wechselrechnung bezahlt (F26): SEPA-Wechsel erledigt; Karte, falls vor pending_update_applied
const switched = await handleSwitchInvoicePaid(fresh)
if (switched.completed) console.log(`[abo] Wechsel ins Wartungs-Abo bezahlt: Ereignis ${switched.eventId}`)
return
}
case 'invoice.payment_failed': {
const ref = obj.parent?.subscription_details?.subscription || obj.subscription
const sub = ref ? await syncSubscription(typeof ref === 'string' ? ref : ref.id) : null
// Wechselrechnung per SEPA-Lastschrift nicht eingeloest: T12 mit den Handgriffen (vor T04, damit eine
// Wiederholung nach einem Fehler hier beide Hinweise nachholt)
await handleSwitchPaymentFailed(obj)
await sendAdminNotice({
template: 'T04',
values: {
@@ -479,6 +551,15 @@ async function handleStripeEvent(event) {
case 'customer.subscription.created':
await syncSubscription(obj.id)
return
// Wechsel ins Wartungs-Abo mit Karte (Rechtsrunde 28.09.2026): Stripe hat die zurueckgehaltene Aenderung
// mit der Zahlung uebernommen (erledigt, T12) oder verworfen (gescheitert, K14, T12). Am Stripe-Endpunkt
// freischalten (Task 20).
case 'customer.subscription.pending_update_applied':
await handlePendingUpdateApplied(obj.id)
return
case 'customer.subscription.pending_update_expired':
await handlePendingUpdateExpired(obj.id)
return
case 'customer.subscription.updated': {
const sub = await syncSubscription(obj.id)
const prev = event.data.previous_attributes || {}