Abo-Angebote: für Hosting- oder Wartungs-Abo
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7ffe95f24a
commit
e5cc2f3c82
6 files changed
+303
-45
No files matched your search
+56
-16
@@ -3,6 +3,7 @@ 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 { getProduct, PRODUCT_KEYS } from './products.js'
|
||||
import { ACTIVE_STATUSES, getCurrentSubscription, getStripe, isStripeConfigured } from './stripeBilling.js'
|
||||
import { shortRef } from './takeoverCredits.js'
|
||||
|
||||
@@ -55,6 +56,12 @@ const adminLabel = (admin) =>
|
||||
|
||||
const isTestEmail = (email) => config.abo.testEmails.includes(String(email || '').toLowerCase())
|
||||
|
||||
// Altbestand ohne productKey (vor Aufgabe 11) gilt als Wartungs-Abo/-Angebot (F32)
|
||||
const productKeyOf = (doc) => doc?.productKey || 'wartung'
|
||||
|
||||
/** Katalogname ohne "WEBklar " (z. B. "Hosting-Abo"), fuer {{produkt}} in Mails (F33). */
|
||||
const productLabel = (key) => getProduct(key).name.replace(/^WEBklar\s+/, '')
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Texte (eine Quelle fuer Portal, Bestelluebersicht, Checkout, Mails und Stripe)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -87,6 +94,7 @@ export function offerView(doc) {
|
||||
createdAt: doc.$createdAt,
|
||||
status: doc.status === OFFER_STATUS.open && isExpired(doc) ? 'abgelaufen' : doc.status,
|
||||
scope: doc.scope,
|
||||
productKey: productKeyOf(doc),
|
||||
months: doc.months,
|
||||
monthlyPriceCents: doc.monthlyPriceCents,
|
||||
regularPriceCents: doc.regularPriceCents,
|
||||
@@ -150,23 +158,28 @@ export async function listOffers() {
|
||||
return listDocuments(COLLECTION(), [Query.orderDesc('$createdAt'), Query.limit(1000)])
|
||||
}
|
||||
|
||||
/** Offenes, noch gueltiges Angebot eines Kunden (hoechstens eines, siehe createOffer). */
|
||||
export async function getOpenOffer(customerId) {
|
||||
if (!customerId) return null
|
||||
/** Offenes, noch gueltiges Angebot eines Kunden fuer ein Produkt (hoechstens eines je Produkt, siehe createOffer). */
|
||||
export async function getOpenOffer(customerId, productKey) {
|
||||
if (!customerId || !productKey) return null
|
||||
const docs = await listDocuments(COLLECTION(), [
|
||||
Query.equal('customerId', customerId),
|
||||
Query.equal('status', OFFER_STATUS.open),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(20),
|
||||
])
|
||||
return docs.find((d) => !isExpired(d)) || null
|
||||
return docs.find((d) => !isExpired(d) && productKeyOf(d) === productKey) || null
|
||||
}
|
||||
|
||||
/** Fuer das Angebotsformular: Abo-Stand, offenes Angebot, Portalzugang, Testkonto. */
|
||||
/** Fuer das Angebotsformular: Abo-Stand, offene Angebote je Produkt, Portalzugang, Testkonto. */
|
||||
export async function offerContext(customerId) {
|
||||
const customer = await getDocument(config.collections.customers, customerId).catch(() => null)
|
||||
if (!customer) throw httpError(404, 'Kunde nicht gefunden')
|
||||
const [sub, open] = await Promise.all([getCurrentSubscription(customerId), getOpenOffer(customerId)])
|
||||
const [sub, hostingOpen, wartungOpen] = await Promise.all([
|
||||
getCurrentSubscription(customerId),
|
||||
getOpenOffer(customerId, 'hosting'),
|
||||
getOpenOffer(customerId, 'wartung'),
|
||||
])
|
||||
const running = Boolean(sub && ACTIVE_STATUSES.has(sub.status))
|
||||
return {
|
||||
customer: {
|
||||
id: customer.$id,
|
||||
@@ -179,16 +192,22 @@ export async function offerContext(customerId) {
|
||||
subscription: sub
|
||||
? {
|
||||
status: sub.status,
|
||||
active: ACTIVE_STATUSES.has(sub.status),
|
||||
active: running,
|
||||
// Nur fuer dieses Produkt kann ein Angebot sofort aufs Abo angewendet werden (F32)
|
||||
productKey: productKeyOf(sub),
|
||||
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)
|
||||
: [],
|
||||
upcomingCharges: running ? chargesFrom(sub.trialEnd || sub.startedAt, new Date(), config.offers.maxMonths + 1) : [],
|
||||
}
|
||||
: null,
|
||||
openOffer: open ? offerView(open) : null,
|
||||
regularPriceCents: config.stripe.priceAmountCents,
|
||||
openOffers: {
|
||||
hosting: hostingOpen ? offerView(hostingOpen) : null,
|
||||
wartung: wartungOpen ? offerView(wartungOpen) : null,
|
||||
},
|
||||
products: {
|
||||
hosting: { regularPriceCents: getProduct('hosting').amountCents },
|
||||
wartung: { regularPriceCents: getProduct('wartung').amountCents },
|
||||
},
|
||||
maxMonths: config.offers.maxMonths,
|
||||
defaultValidUntil: addDaysYmd(todayBerlin(), config.offers.defaultValidityDays),
|
||||
}
|
||||
@@ -223,11 +242,15 @@ async function attachCouponToSubscription(subscriptionId, couponId, metadata) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function validateOfferInput(body) {
|
||||
const productKey = body?.productKey
|
||||
if (!PRODUCT_KEYS.includes(productKey)) {
|
||||
throw httpError(400, 'Bitte ein Produkt wählen (Hosting-Abo oder Wartungs-Abo).')
|
||||
}
|
||||
const months = Number(body?.months)
|
||||
if (!Number.isInteger(months) || months < 1 || months > config.offers.maxMonths) {
|
||||
throw httpError(400, `Bitte 1 bis ${config.offers.maxMonths} Angebotsmonate wählen.`)
|
||||
}
|
||||
const regularPriceCents = config.stripe.priceAmountCents
|
||||
const regularPriceCents = getProduct(productKey).amountCents
|
||||
const monthlyPriceCents = Number(body?.monthlyPriceCents)
|
||||
if (!Number.isInteger(monthlyPriceCents) || monthlyPriceCents < 0 || monthlyPriceCents >= regularPriceCents) {
|
||||
throw httpError(400, `Der Angebotspreis muss zwischen 0,00 € und ${euro(regularPriceCents - 1)} liegen.`)
|
||||
@@ -240,6 +263,7 @@ export function validateOfferInput(body) {
|
||||
throw httpError(400, 'Bitte ein Gültigkeitsdatum ab heute und höchstens ein Jahr im Voraus wählen.')
|
||||
}
|
||||
return {
|
||||
productKey,
|
||||
months,
|
||||
monthlyPriceCents,
|
||||
regularPriceCents,
|
||||
@@ -259,6 +283,7 @@ async function mailOffer(doc, customer, nextChargeAt = null) {
|
||||
const running = doc.scope === 'laufend'
|
||||
const values = {
|
||||
name: customer.name || '',
|
||||
produkt: productLabel(productKeyOf(doc)),
|
||||
angebot_text: offerText(doc),
|
||||
ersparnis: euro(savingsOf(doc)),
|
||||
hinweis: doc.note || '',
|
||||
@@ -293,11 +318,12 @@ export async function createOffer(body, admin) {
|
||||
const input = validateOfferInput(body)
|
||||
const customer = await getDocument(config.collections.customers, String(body?.customerId || '')).catch(() => null)
|
||||
if (!customer) throw httpError(404, 'Kunde nicht gefunden')
|
||||
if (await getOpenOffer(customer.$id)) {
|
||||
throw httpError(409, 'Für diesen Kunden gibt es schon ein offenes Angebot. Bitte das alte zuerst zurückziehen.')
|
||||
if (await getOpenOffer(customer.$id, input.productKey)) {
|
||||
throw httpError(409, 'Für dieses Produkt gibt es für diesen Kunden schon ein offenes Angebot. Bitte das alte zuerst zurückziehen.')
|
||||
}
|
||||
const sub = await getCurrentSubscription(customer.$id)
|
||||
const running = Boolean(sub && ACTIVE_STATUSES.has(sub.status))
|
||||
// Sofort anwendbar ist ein Angebot nur, wenn das laufende Abo dasselbe Produkt betrifft (F32)
|
||||
const running = Boolean(sub && ACTIVE_STATUSES.has(sub.status) && productKeyOf(sub) === input.productKey)
|
||||
|
||||
const id = randomUUID()
|
||||
const { notifyCustomer, ...fields } = input
|
||||
@@ -428,6 +454,20 @@ export async function activateOfferFromCheckout(session) {
|
||||
})
|
||||
return null
|
||||
}
|
||||
// Angebot und Bestellung fuer unterschiedliche Produkte (F32/F33): nichts anwenden, T03 mit Grund
|
||||
const metaProductKey = session.metadata?.productKey
|
||||
if (metaProductKey && productKeyOf(doc) !== metaProductKey) {
|
||||
await sendAdminNotice({
|
||||
template: 'T03',
|
||||
values: {
|
||||
angebot_nr: shortRef(offerId),
|
||||
stripe_abo_id: subscriptionId,
|
||||
angebot_text: offerText(doc),
|
||||
angebot_status: `Produkt passt nicht: Angebot für ${productLabel(productKeyOf(doc))}, bestellt wurde ${productLabel(metaProductKey)}`,
|
||||
},
|
||||
})
|
||||
return null
|
||||
}
|
||||
if (session.metadata?.offerDeferred === '1') {
|
||||
// Testkonto: Im Checkout lief der Testrabatt, das Angebot kommt jetzt zusaetzlich ans Abo
|
||||
await attachCouponToSubscription(subscriptionId, doc.stripeCouponId)
|
||||
|
||||
Reference in new issue
Block a user