Kundenansicht P6: Wechsel merkt sich die gewünschte Funktion (details.funktion, wechselFunktion)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
db47f13510
commit
d5a7312896
3 files changed
+137
-26
No files matched your search
+43
-3
@@ -19,10 +19,13 @@ import {
|
||||
previewSwitch,
|
||||
reconcilePendingSwitch,
|
||||
switchToWartung,
|
||||
wechselFunktionVon,
|
||||
} from '../services/planSwitch.js'
|
||||
import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, listCustomerFeatureDocs, paidFeatureOrders } from '../services/siteFeatures.js'
|
||||
import { assertOwnProject, handleFeatureCheckoutEvent, isFeatureCheckoutEvent, listCustomerFeatureDocs, paidFeatureOrders } from '../services/siteFeatures.js'
|
||||
import { featureConfig } from '../services/featureRegistry.js'
|
||||
import { istAboModell } from '../services/aboModell.js'
|
||||
import { ENTHALTEN_IN } from '../services/aboFunktionen.js'
|
||||
import { neueAnsicht } from '../services/kundenansicht/schalter.js'
|
||||
import { PRODUCT_KEYS, contractAttachmentFor, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js'
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
@@ -164,6 +167,21 @@ function isPilotAllowed(customer, offer = null) {
|
||||
// Kunde (eingeloggt)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* publicSubscription, in der neuen Ansicht mit wechselFunktion (die beim Wechsel gewuenschte Funktion, die noch einzuschalten ist, sonst null;
|
||||
* planSwitch.wechselFunktionVon). In der alten Ansicht fehlt der Schluessel. Ein Lesefehler ergibt null (die Seite bleibt nutzbar).
|
||||
*/
|
||||
async function subscriptionMitWechselFunktion(req, customerId, sub, events) {
|
||||
const view = publicSubscription(sub, events)
|
||||
if (!view || !neueAnsicht(req)) return view
|
||||
try {
|
||||
return { ...view, wechselFunktion: await wechselFunktionVon(customerId, events.filter((e) => e.subscriptionId === sub.$id)) }
|
||||
} catch (err) {
|
||||
console.error('[abo] Gewünschte Funktion des Wechsels nicht lesbar:', err.message)
|
||||
return { ...view, wechselFunktion: null }
|
||||
}
|
||||
}
|
||||
|
||||
/** Katalog, Abo-Status mit Speicherstand und Wechsel-Moeglichkeit, Angebote je Produkt, Stundenkonto (nur Wartung). */
|
||||
router.get('/', requireSession, async (req, res) => {
|
||||
try {
|
||||
@@ -211,7 +229,7 @@ router.get('/', requireSession, async (req, res) => {
|
||||
},
|
||||
offers: { hosting: hostingOffer ? offerView(hostingOffer) : null, wartung: wartungOffer ? offerView(wartungOffer) : null },
|
||||
ledger,
|
||||
subscription: publicSubscription(sub, events),
|
||||
subscription: await subscriptionMitWechselFunktion(req, customerId, sub, events),
|
||||
})
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Abo konnte nicht geladen werden' })
|
||||
@@ -364,6 +382,26 @@ router.post('/switch/preview', requireSession, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Die beim Wechsel gewuenschte Funktion (Kundenansicht neu, P6, V11) aus dem Body: { key, projectId } oder null. Gueltig ist ein Schluessel
|
||||
* aus dem Register, den nur das Wartungs-Abo enthaelt (ENTHALTEN_IN nur 'wartung': news, termine, shop, unterkunft, zeiten; nicht analyse) und
|
||||
* ein Projekt des Kunden (assertOwnProject). Alles andere wird verworfen, nie abgelehnt: die Bequemlichkeit darf den bezahlten Vertrag nicht
|
||||
* blockieren. Nur die beiden Schluessel key und projectId werden uebernommen.
|
||||
*/
|
||||
async function wechselFunktionAusBody(customerId, funktion) {
|
||||
if (!funktion || typeof funktion !== 'object' || Array.isArray(funktion)) return null
|
||||
const { key, projectId } = funktion
|
||||
if (typeof key !== 'string' || typeof projectId !== 'string' || !projectId) return null
|
||||
const enthalten = Object.hasOwn(ENTHALTEN_IN, key) ? ENTHALTEN_IN[key] : null
|
||||
if (!enthalten || enthalten.length !== 1 || enthalten[0] !== 'wartung') return null
|
||||
try {
|
||||
await assertOwnProject(customerId, projectId)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return { key, projectId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestellung { acceptTerms: true, earlyStart: true, termsVersion, prorationDate, amountDueCents } ("Zahlungspflichtig
|
||||
* bestellen"); prorationDate und amountDueCents kommen aus der Vorschau.
|
||||
@@ -391,12 +429,14 @@ router.post('/switch', requireSession, async (req, res) => {
|
||||
}
|
||||
const customerId = getSessionCustomerId(req)
|
||||
const now = new Date().toISOString()
|
||||
// Gewuenschte Funktion (nur neue Ansicht): geprueft oder verworfen, der Wechsel laeuft in jedem Fall
|
||||
const funktion = neueAnsicht(req) ? await wechselFunktionAusBody(customerId, req.body?.funktion) : null
|
||||
const { subscription, paymentUrl } = await switchToWartung(
|
||||
customerId,
|
||||
{ termsVersion, consentAt: now, earlyStartConsentAt: now, consentIp: req.ip, consentUserAgent: req.get('user-agent') || '' },
|
||||
prorationDate,
|
||||
// Fix 1 (8): der in der Bestelluebersicht gezeigte Betrag; weicht die neue Vorschau ab, 409
|
||||
{ expectedAmountCents: amountDueCents }
|
||||
{ expectedAmountCents: amountDueCents, funktion }
|
||||
)
|
||||
const events = await listContractEvents(customerId).catch((e) => {
|
||||
console.error('[abo] Vertragsereignisse nicht lesbar:', e.message)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getDocument } from './appwriteAdmin.js'
|
||||
import { eventIdFor, getContractEvent, listContractEvents, recordContractEvent, updateContractEvent } from './contractEvents.js'
|
||||
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
|
||||
import { contractAttachmentFor, describeItems } from './products.js'
|
||||
import { getFeatureDoc } from './siteFeatures.js'
|
||||
import {
|
||||
addMonths,
|
||||
ensurePrice,
|
||||
@@ -335,6 +336,9 @@ const monthlyCentsOf = (facts) => facts.monthlyCents ?? config.products.wartung.
|
||||
// Ein Wechsel je Abo zur selben Zeit (Doppelklick): der zweite wartet nicht, sondern bekommt 409
|
||||
const switchesInFlight = new Set()
|
||||
|
||||
// Obergrenze fuer details des Ereignisses `wechsel` (Schutz des Nachweises): die gewuenschte Funktion (details.funktion) kommt nur hinein, wenn es passt
|
||||
const DETAILS_MAX = 2000
|
||||
|
||||
/**
|
||||
* Wechsel ins Wartungs-Abo (Button "Zahlungspflichtig bestellen"). consent: { termsVersion, consentAt,
|
||||
* consentIp, consentUserAgent, earlyStartConsentAt } - ohne Zustimmung zum Sofortbeginn kein Wechsel (400, F16).
|
||||
@@ -343,8 +347,11 @@ const switchesInFlight = new Set()
|
||||
* ab, 409 ohne Aenderung bei Stripe.
|
||||
* Liefert { subscription, paymentUrl, event }: paymentUrl nur, wenn Stripe die Aenderung bis zur Zahlung
|
||||
* zurueckhaelt (pending_update, Karte), sonst null - auch bei SEPA, dort zieht Stripe die Lastschrift ein.
|
||||
* options.funktion ({ key, projectId }, von der Route geprueft; Kundenansicht neu, P6): die Funktion, die der Kunde mit dem Wechsel einschalten
|
||||
* will, landet in event.details.funktion (GET /api/abo nennt sie als wechselFunktion). Sie ist nur eine Bequemlichkeit: passt sie nicht mehr
|
||||
* in den Nachweis (details ueber 2000 Zeichen), bleibt sie weg, der Wechsel laeuft in jedem Fall.
|
||||
*/
|
||||
export async function switchToWartung(customerId, consent, prorationDate, { expectedAmountCents } = {}) {
|
||||
export async function switchToWartung(customerId, consent, prorationDate, { expectedAmountCents, funktion = null } = {}) {
|
||||
if (!consent?.termsVersion || !consent?.consentAt) {
|
||||
throw httpError(400, 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu wechseln.')
|
||||
}
|
||||
@@ -357,13 +364,13 @@ export async function switchToWartung(customerId, consent, prorationDate, { expe
|
||||
if (switchesInFlight.has(doc.$id)) throw httpError(409, 'Ihr Wechsel wird gerade verarbeitet.')
|
||||
switchesInFlight.add(doc.$id)
|
||||
try {
|
||||
return await performSwitch(customerId, doc, consent, prorationDate, expectedAmountCents)
|
||||
return await performSwitch(customerId, doc, consent, prorationDate, expectedAmountCents, funktion)
|
||||
} finally {
|
||||
switchesInFlight.delete(doc.$id)
|
||||
}
|
||||
}
|
||||
|
||||
async function performSwitch(customerId, current, consent, prorationDate, expectedAmountCents) {
|
||||
async function performSwitch(customerId, current, consent, prorationDate, expectedAmountCents, funktion = null) {
|
||||
const ctx = await switchContext(customerId, current)
|
||||
const { doc, customer, stripeSub } = ctx
|
||||
// Seit der Vorschau hat ein neuer Abrechnungsmonat begonnen: Gutschrift und Betrag stimmen nicht mehr
|
||||
@@ -422,31 +429,34 @@ async function performSwitch(customerId, current, consent, prorationDate, expect
|
||||
})) || doc
|
||||
}
|
||||
|
||||
const details = {
|
||||
from: 'hosting',
|
||||
amountDueCents,
|
||||
monthlyCents,
|
||||
prorationDate,
|
||||
switchedAt,
|
||||
paymentMethod: ctx.paymentMethod,
|
||||
invoiceId: idOf(updated.latest_invoice),
|
||||
// F12/F27: Link zur Stripe-Zahlungsseite, solange die Zahlung offen ist (GET /api/abo switchPending)
|
||||
paymentUrl,
|
||||
// Fix 1 (2): Ablauf der zurueckgehaltenen Aenderung, fuer den Abgleich ohne Webhook
|
||||
pendingExpiresAt: pending && updated.pending_update.expires_at ? new Date(updated.pending_update.expires_at * 1000).toISOString() : null,
|
||||
effectiveAt: effective ? switchedAt : null,
|
||||
paidAt: effective && paid ? switchedAt : null,
|
||||
// F27: Stand vor dem Wechsel, damit eine gescheiterte Lastschrift ihn wiederherstellen kann
|
||||
previousTermStartAt: termStartOf(stripeSub).toISOString(),
|
||||
previousMetadata: Object.fromEntries(
|
||||
RESTORABLE_METADATA.map((key) => [key, stripeSub.metadata?.[key] == null ? null : String(stripeSub.metadata[key]).slice(0, 300)])
|
||||
),
|
||||
}
|
||||
// Gewuenschte Funktion (nur Bequemlichkeit): der Nachweis darf dadurch nie zu gross werden
|
||||
if (funktion && JSON.stringify({ ...details, funktion }).length <= DETAILS_MAX) details.funktion = funktion
|
||||
const event = await recordSwitchEvent({
|
||||
doc,
|
||||
customerId,
|
||||
consent,
|
||||
status: effective && paid ? 'erledigt' : 'offen',
|
||||
details: {
|
||||
from: 'hosting',
|
||||
amountDueCents,
|
||||
monthlyCents,
|
||||
prorationDate,
|
||||
switchedAt,
|
||||
paymentMethod: ctx.paymentMethod,
|
||||
invoiceId: idOf(updated.latest_invoice),
|
||||
// F12/F27: Link zur Stripe-Zahlungsseite, solange die Zahlung offen ist (GET /api/abo switchPending)
|
||||
paymentUrl,
|
||||
// Fix 1 (2): Ablauf der zurueckgehaltenen Aenderung, fuer den Abgleich ohne Webhook
|
||||
pendingExpiresAt: pending && updated.pending_update.expires_at ? new Date(updated.pending_update.expires_at * 1000).toISOString() : null,
|
||||
effectiveAt: effective ? switchedAt : null,
|
||||
paidAt: effective && paid ? switchedAt : null,
|
||||
// F27: Stand vor dem Wechsel, damit eine gescheiterte Lastschrift ihn wiederherstellen kann
|
||||
previousTermStartAt: termStartOf(stripeSub).toISOString(),
|
||||
previousMetadata: Object.fromEntries(
|
||||
RESTORABLE_METADATA.map((key) => [key, stripeSub.metadata?.[key] == null ? null : String(stripeSub.metadata[key]).slice(0, 300)])
|
||||
),
|
||||
},
|
||||
details,
|
||||
})
|
||||
|
||||
const contact = { name: customer?.name || '', email: customer?.email || doc.email || '' }
|
||||
@@ -461,6 +471,28 @@ async function performSwitch(customerId, current, consent, prorationDate, expect
|
||||
return { subscription, paymentUrl, event }
|
||||
}
|
||||
|
||||
/** Frist, in der die beim Wechsel gewuenschte Funktion noch angeboten wird (Tage nach der Erledigung des Wechsels). */
|
||||
const WECHSEL_FUNKTION_TAGE = 14
|
||||
|
||||
/**
|
||||
* Die beim Wechsel gewuenschte Funktion (Kundenansicht neu, P6, V11), die der Kunde jetzt noch einschalten kann: { key, projectId } oder null.
|
||||
* events: Vertragsereignisse des aktuellen Abos (listContractEvents, neueste zuerst, details geparst). Es zaehlt das juengste Ereignis
|
||||
* `wechsel`: es ist erledigt, traegt details.funktion, wurde vor hoechstens 14 Tagen erledigt (doneAt, sonst details.paidAt, sonst
|
||||
* details.effectiveAt) und das Funktionsdokument des Projekts fehlt oder ist off (die Funktion ist noch nicht eingeschaltet). Gescheitert,
|
||||
* widerrufen und offen ergeben null, ebenso ein juengerer Wechsel ohne Funktion. Liest hoechstens ein Funktionsdokument; ein Lesefehler wird weitergereicht.
|
||||
*/
|
||||
export async function wechselFunktionVon(customerId, events, { now = new Date() } = {}) {
|
||||
const event = (events || []).find((e) => e.type === 'wechsel')
|
||||
if (!event || event.status !== 'erledigt') return null
|
||||
const funktion = event.details?.funktion
|
||||
if (!funktion || typeof funktion.key !== 'string' || typeof funktion.projectId !== 'string' || !funktion.key || !funktion.projectId) return null
|
||||
const erledigt = Date.parse(event.doneAt || event.details?.paidAt || event.details?.effectiveAt || '')
|
||||
if (!Number.isFinite(erledigt) || now.getTime() - erledigt > WECHSEL_FUNKTION_TAGE * 24 * 60 * 60 * 1000) return null
|
||||
const doc = await getFeatureDoc(customerId, funktion.projectId, funktion.key)
|
||||
if (doc && doc.state !== 'off') return null
|
||||
return { key: funktion.key, projectId: funktion.projectId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ereignis `wechsel` (Nachweis der Bestellung und Stand der Zahlung). Feste ID je Abo und Wechselzeitpunkt
|
||||
* (eventIdFor, 36 Zeichen). Ein Fehler beim Speichern bricht den schon bei Stripe ausgefuehrten Wechsel nicht
|
||||
|
||||
Reference in new issue
Block a user