Wartungs-Abo v2: Rechtsrunde umgesetzt
- Bestellseite: Pflichtangaben direkt ueber dem Button, Zahlungsarten, Wahl des Leistungsbeginns (sofort / nach Widerrufsfrist via 15 Tage Stripe-Probezeit), keine Vorauswahl - Zustimmungsnachweis: Zeitpunkt, Version, Wahl, IP, Browser in Stripe- Metadaten und Appwrite (neue Attribute, Setup-Skript ergaenzt) - Checkout: Karte + SEPA (Fallback nur Karte), korrigierter Hinweistext - Vertragsbestaetigung mit PDF der Vertragsunterlagen statt HTML - Kuendigen/Widerrufen: Formular direkt (keine Zwischenseite), Wunsch- termin, Druck/PDF der Bestaetigung, Vorbefuellung bei Login - Widerrufsbutton im Abo waehrend der Frist, 'beginnt am'-Status - Pilotbetrieb per ABO_PILOT_EMAILS - Vertragsbedingungen v2, Datenschutzerklaerung, Kalkulator ohne MwSt und mit 100/80 EUR Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c0214725c0
commit
4ab154bfa2
18 files changed
+354
-163
No files matched your search
@@ -174,6 +174,11 @@ export async function syncSubscription(subscriptionId) {
|
||||
termsVersion: meta.termsVersion || null,
|
||||
termsAcceptedAt: meta.termsAcceptedAt || null,
|
||||
earlyStartConsentAt: meta.earlyStartConsentAt || null,
|
||||
startChoice: meta.startChoice || null,
|
||||
trialEnd: toIso(sub.trial_end),
|
||||
consentIp: meta.consentIp || null,
|
||||
consentUserAgent: meta.consentUserAgent || null,
|
||||
consentTextVersion: meta.consentTextVersion || null,
|
||||
})
|
||||
return doc
|
||||
}
|
||||
@@ -259,7 +264,13 @@ async function getOrCreateStripeCustomer(customer) {
|
||||
return created.id
|
||||
}
|
||||
|
||||
export async function createAboCheckout(customer, { termsAcceptedAt, earlyStartConsentAt }) {
|
||||
/**
|
||||
* Stripe Checkout fuer das Abo. Der Kunde hat seine Bestellung vorher im Portal
|
||||
* abgegeben ("Zahlungspflichtig bestellen"); hier hinterlegt er die Zahlungsart.
|
||||
* startChoice: 'sofort' (Leistung und erste Abbuchung sofort) oder 'nach_frist'
|
||||
* (15 Tage Probezeit ohne Leistung und ohne Abbuchung, Start nach der Widerrufsfrist).
|
||||
*/
|
||||
export async function createAboCheckout(customer, consent) {
|
||||
const current = await getCurrentSubscription(customer.$id)
|
||||
if (current && ACTIVE_STATUSES.has(current.status)) {
|
||||
const err = new Error('Sie haben das Wartungs-Abo bereits abgeschlossen.')
|
||||
@@ -271,35 +282,56 @@ export async function createAboCheckout(customer, { termsAcceptedAt, earlyStartC
|
||||
ensureAboPrice(),
|
||||
getOrCreateStripeCustomer(customer),
|
||||
])
|
||||
const lateStart = consent.startChoice === 'nach_frist'
|
||||
// Stripe-Metadaten: max. 500 Zeichen je Wert
|
||||
const meta = {
|
||||
webklarCustomerId: customer.$id,
|
||||
email: customer.email || '',
|
||||
termsVersion: config.abo.termsVersion,
|
||||
termsAcceptedAt,
|
||||
earlyStartConsentAt,
|
||||
consentTextVersion: config.abo.termsVersion,
|
||||
termsAcceptedAt: consent.termsAcceptedAt,
|
||||
startChoice: consent.startChoice,
|
||||
earlyStartConsentAt: consent.earlyStartConsentAt || '',
|
||||
consentIp: String(consent.consentIp || '').slice(0, 64),
|
||||
consentUserAgent: String(consent.consentUserAgent || '').slice(0, 300),
|
||||
}
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
const params = {
|
||||
mode: 'subscription',
|
||||
customer: stripeCustomerId,
|
||||
client_reference_id: customer.$id,
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
payment_method_types: ['card', 'sepa_debit'],
|
||||
locale: 'de',
|
||||
billing_address_collection: 'required',
|
||||
customer_update: { address: 'auto', name: 'auto' },
|
||||
subscription_data: {
|
||||
metadata: meta,
|
||||
description: 'Webklar Wartungs-Abo',
|
||||
description: config.stripe.productName,
|
||||
...(lateStart ? { trial_period_days: config.abo.lateStartTrialDays } : {}),
|
||||
},
|
||||
metadata: meta,
|
||||
custom_text: {
|
||||
submit: {
|
||||
message:
|
||||
'Der Vertrag ist mit Ihrer Bestellung im Kundenportal bereits geschlossen. Hier hinterlegen Sie nur noch die Zahlungsart. 50,00 € monatlich, monatlich kündbar.',
|
||||
message: lateStart
|
||||
? 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit dem Abschluss kommt der Vertrag zustande. Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab. Danach 50,00 € monatlich, monatlich kündbar.'
|
||||
: 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit der ersten Zahlung kommt der Vertrag zustande. 50,00 € monatlich, monatlich kündbar.',
|
||||
},
|
||||
},
|
||||
success_url: `${config.publicBaseUrl}/dashboard.html?abo=success`,
|
||||
cancel_url: `${config.publicBaseUrl}/dashboard.html?abo=cancel`,
|
||||
})
|
||||
}
|
||||
let session
|
||||
try {
|
||||
session = await stripe.checkout.sessions.create(params)
|
||||
} catch (err) {
|
||||
// SEPA-Lastschrift noch nicht im Stripe-Konto aktiviert -> nur Karte anbieten
|
||||
if (/sepa_debit|payment_method_types/i.test(err.message || '')) {
|
||||
console.warn('[stripe] SEPA-Lastschrift nicht verfuegbar, Checkout nur mit Karte:', err.message)
|
||||
session = await stripe.checkout.sessions.create({ ...params, payment_method_types: ['card'] })
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
return session.url
|
||||
}
|
||||
|
||||
@@ -326,6 +358,33 @@ export async function cancelAtPeriodEnd(subscriptionId) {
|
||||
return syncSubscription(subscriptionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kuendigung zu einem Wunschtermin: wirksam zum Ende des Abrechnungsmonats, in dem
|
||||
* der Wunschtermin liegt (ohne Wunschtermin: Ende des laufenden Monats).
|
||||
* Liefert das Abo-Dokument und den Beendigungszeitpunkt (ISO).
|
||||
*/
|
||||
export async function cancelSubscriptionAt(subscriptionId, requestedDate = null) {
|
||||
const stripe = getStripe()
|
||||
const sub = await stripe.subscriptions.retrieve(subscriptionId)
|
||||
const periodEnd = periodEndOf(sub)
|
||||
const requested = requestedDate ? Math.floor(requestedDate.getTime() / 1000) : null
|
||||
if (!requested || !periodEnd || requested <= periodEnd) {
|
||||
await stripe.subscriptions.update(subscriptionId, { cancel_at_period_end: true })
|
||||
const doc = await syncSubscription(subscriptionId)
|
||||
return { subscription: doc, effectiveAt: toIso(periodEnd) }
|
||||
}
|
||||
let boundary = new Date(periodEnd * 1000)
|
||||
for (let i = 0; i < 36 && boundary.getTime() / 1000 < requested; i += 1) {
|
||||
boundary = addMonths(new Date(periodEnd * 1000), i + 1)
|
||||
}
|
||||
await stripe.subscriptions.update(subscriptionId, {
|
||||
cancel_at: Math.floor(boundary.getTime() / 1000),
|
||||
proration_behavior: 'none',
|
||||
})
|
||||
const doc = await syncSubscription(subscriptionId)
|
||||
return { subscription: doc, effectiveAt: boundary.toISOString() }
|
||||
}
|
||||
|
||||
export function constructWebhookEvent(rawBody, signature) {
|
||||
if (!config.stripe.webhookSecret) {
|
||||
const err = new Error('STRIPE_WEBHOOK_SECRET fehlt')
|
||||
|
||||
Reference in new issue
Block a user