Compare commits
3
Commits
ba9916cfe6
...
054b04d5b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
054b04d5b6 | ||
|
|
c600118374 | ||
|
|
d5eb908cb6 |
No files matched your search
+66
-6
@@ -802,6 +802,47 @@ function offerPreviewText(price, months, regular, running) {
|
||||
return `${lead} ${amount}, danach ${eur(regular)} pro Monat`
|
||||
}
|
||||
|
||||
// Datumsfeld "2026-10-27" -> "27.10.2026", ohne Umweg über Date (keine Zeitzonen-Verschiebung)
|
||||
const fmtYmd = (ymd) => String(ymd || '').split('-').reverse().join('.')
|
||||
// Zeitpunkt -> Kalendertag in Deutschland (YYYY-MM-DD); Abbuchungen und Fristen zählen nach deutschem Kalender
|
||||
const berlinYmd = (value) => new Date(value).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
|
||||
const fmtBerlin = (value) => fmtYmd(berlinYmd(value))
|
||||
|
||||
// Kalendertag + n Monate, Monatsende wie bei Stripe (31.01. + 1 Monat = 28./29.02.)
|
||||
function addMonthsYmd(ymd, n) {
|
||||
const [y, m, d] = ymd.split('-').map(Number)
|
||||
const last = new Date(Date.UTC(y, m + n, 0)).getUTCDate()
|
||||
return new Date(Date.UTC(y, m - 1 + n, Math.min(d, last))).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/* Rabattende eines noch nicht eingelösten Angebots: Der Rabatt läuft N Monate ab Abschluss, der Kunde
|
||||
kann heute oder spätestens am letzten Gültigkeitstag abschließen. Mit Beginn nach der Widerrufsfrist
|
||||
starten Leistung und Rabatt gut 2 Wochen später (deadlines.js lateStartAt). */
|
||||
function offerDiscountEstimate(months, validUntilYmd, testAccount = false) {
|
||||
const today = berlinYmd(new Date())
|
||||
// Testkonto: Coupon kommt erst nach der ersten Abbuchung ans Abo (offers.js), der Rabatt endet einen Monat später
|
||||
const n = months + (testAccount ? 1 : 0)
|
||||
return { earliest: addMonthsYmd(today, n), latest: addMonthsYmd(validUntilYmd < today ? today : validUntilYmd, n) }
|
||||
}
|
||||
|
||||
// Zeile "Rabatt endet …" in der Angebotsliste
|
||||
function offerDiscountLine(o) {
|
||||
if (o.status === 'offen' && o.validUntil) {
|
||||
const { earliest, latest } = offerDiscountEstimate(o.months, String(o.validUntil).slice(0, 10), o.testAccount)
|
||||
return earliest === latest
|
||||
? `Rabatt endet voraussichtlich am ${fmtYmd(earliest)}`
|
||||
: `Rabatt endet voraussichtlich zwischen ${fmtYmd(earliest)} und ${fmtYmd(latest)}`
|
||||
}
|
||||
if (o.status !== 'aktiv' || !o.discount) return ''
|
||||
const d = o.discount
|
||||
if (!d.known) return 'Rabattende unbekannt (Abo nicht gefunden)'
|
||||
const end = d.subscriptionEndsAt || d.endsAt
|
||||
const past = new Date(end) <= new Date()
|
||||
return d.subscriptionEndsAt
|
||||
? `Rabatt ${past ? 'endete' : 'endet'} mit dem Abo am ${fmtBerlin(end)}`
|
||||
: `Rabatt ${past ? 'endete' : 'endet'} am ${fmtBerlin(end)}`
|
||||
}
|
||||
|
||||
function showOfferMessage(text) {
|
||||
const el = document.getElementById('offer-message')
|
||||
el.textContent = text
|
||||
@@ -817,17 +858,21 @@ async function loadOffers() {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="muted">${offerState.filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'}</td></tr>`
|
||||
return
|
||||
}
|
||||
tbody.innerHTML = shown.map((o) => `
|
||||
tbody.innerHTML = shown.map((o) => {
|
||||
const discount = offerDiscountLine(o)
|
||||
return `
|
||||
<tr>
|
||||
<td>${fmtDateTime(o.createdAt)}<br><span class="muted">${escapeHtml(o.ref)}${o.createdBy ? ` · ${escapeHtml(o.createdBy.replace(/\s*<.*>$/, ''))}` : ''}</span></td>
|
||||
<td><strong>${escapeHtml(o.customerName)}</strong>${o.customerCode ? `<br><span class="muted">Kd.-Nr. ${escapeHtml(o.customerCode)}</span>` : ''}</td>
|
||||
<td>${escapeHtml(o.text)}<br><span class="muted">Ersparnis ${euroFromCents(o.savingsCents)}${o.note ? ` · „${escapeHtml(o.note)}“` : ''}</span>
|
||||
${discount ? `<br><span class="muted">${escapeHtml(discount)}</span>` : ''}
|
||||
${o.internalNote ? `<br><span class="muted"><em>intern: ${escapeHtml(o.internalNote)}</em></span>` : ''}</td>
|
||||
<td style="white-space:nowrap;overflow-wrap:normal">${o.scope === 'laufend' ? '<span class="muted">laufendes Abo</span>' : fmtDate(o.validUntil)}</td>
|
||||
<td>${offerStatusPill(o)}
|
||||
<br><span class="muted">Stripe:</span> ${o.stripeCouponUrl ? `<a class="plain" href="${escapeHtml(o.stripeCouponUrl)}" target="_blank" rel="noreferrer">Coupon</a>` : '–'}${o.stripeSubscriptionUrl ? ` · <a class="plain" href="${escapeHtml(o.stripeSubscriptionUrl)}" target="_blank" rel="noreferrer">Abo</a>` : ''}
|
||||
${['offen', 'abgelaufen'].includes(o.status) ? `<div style="margin-top:6px"><button class="mini-btn danger" data-withdraw-offer="${escapeHtml(o.id)}">Zurückziehen</button></div>` : ''}</td>
|
||||
</tr>`).join('')
|
||||
</tr>`
|
||||
}).join('')
|
||||
tbody.querySelectorAll('[data-withdraw-offer]').forEach((btn) =>
|
||||
btn.addEventListener('click', () => withdrawOfferAction(btn.dataset.withdrawOffer, btn)))
|
||||
} catch (err) {
|
||||
@@ -899,12 +944,23 @@ function renderOfferPreview(form) {
|
||||
}
|
||||
if (ok) {
|
||||
lines.push(`${offerPreviewText(v.monthlyPriceCents, v.months, regular, running)} · Ersparnis ${euroFromCents((regular - v.monthlyPriceCents) * v.months)}`)
|
||||
if (running) {
|
||||
const charges = ctx.subscription.upcomingCharges || []
|
||||
if (charges.length > v.months) lines.push(`Rabatt endet am ${fmtBerlin(charges[v.months])} (nach ${v.months === 1 ? '1 Abbuchung' : `${v.months} Abbuchungen`}).`)
|
||||
} else {
|
||||
lines.push(`Angebot gültig bis ${fmtYmd(v.validUntil)}`)
|
||||
const { earliest, latest } = offerDiscountEstimate(v.months, v.validUntil, ctx.customer.testAccount)
|
||||
const lead = `Rabatt läuft ${v.months === 1 ? '1 Monat' : `${v.months} Monate`} ab Abschluss und endet voraussichtlich`
|
||||
lines.push(`${earliest === latest
|
||||
? `${lead} am ${fmtYmd(earliest)}.`
|
||||
: `${lead} zwischen ${fmtYmd(earliest)} (Abschluss heute) und ${fmtYmd(latest)} (Abschluss am letzten Tag).`} Bei Beginn nach der Widerrufsfrist gut 2 Wochen später.`)
|
||||
}
|
||||
}
|
||||
lines.push(running
|
||||
? `Laufendes Abo: gilt ab der nächsten Abbuchung${ctx.subscription.currentPeriodEnd ? ` (${fmtDate(ctx.subscription.currentPeriodEnd)})` : ''} und wird sofort in Stripe angewendet.`
|
||||
: 'Noch kein laufendes Abo: Der Coupon wird angelegt und beim Abschluss im Kundenportal angewendet.')
|
||||
if (!running && !ctx.customer.portalAccessEnabled) lines.push('Achtung: Kunde hat keinen Portalzugang – er kann erst bestellen, wenn der Zugang freigeschaltet ist.')
|
||||
if (ctx.customer.testAccount) lines.push('Testkonto: Im Checkout bleibt der 0,50-€-Testrabatt, das Angebot wird danach zusätzlich angehängt.')
|
||||
if (ctx.customer.testAccount) lines.push(`Testkonto: Im Checkout bleibt der 0,50-€-Testrabatt, das Angebot wird danach zusätzlich angehängt${running ? '' : ' und gilt deshalb erst ab der zweiten Abbuchung'}.`)
|
||||
box.innerHTML = lines.map((l) => `<div>${escapeHtml(l)}</div>`).join('')
|
||||
save.disabled = !ok
|
||||
save.textContent = running ? 'Angebot anlegen und aufs laufende Abo anwenden' : 'Angebot anlegen'
|
||||
@@ -930,16 +986,20 @@ async function saveOffer(form) {
|
||||
errEl.classList.add('hidden')
|
||||
const v = offerFormValues(form)
|
||||
const ctx = offerState.context
|
||||
const text = offerPreviewText(v.monthlyPriceCents, v.months, ctx.regularPriceCents, Boolean(ctx.subscription?.active))
|
||||
if (!window.confirm(`Angebot für ${ctx.customer.name || 'den Kunden'} anlegen?\n\n${text}\n\nDer Coupon wird sofort in Stripe angelegt${ctx.subscription?.active ? ' und auf das laufende Abo angewendet' : ''}.`)) return
|
||||
const running = Boolean(ctx.subscription?.active)
|
||||
const text = offerPreviewText(v.monthlyPriceCents, v.months, ctx.regularPriceCents, running)
|
||||
if (!window.confirm(`Angebot für ${ctx.customer.name || 'den Kunden'} anlegen?\n\n${text}${running ? '' : `\nGültig bis ${fmtYmd(v.validUntil)}`}\n\nDer Coupon wird sofort in Stripe angelegt${running ? ' und auf das laufende Abo angewendet' : ''}.`)) return
|
||||
const save = form.querySelector('#offer-save')
|
||||
save.disabled = true
|
||||
try {
|
||||
const result = await api('/api/portal-admin/offers', { method: 'POST', body: JSON.stringify(v) })
|
||||
const o = result.offer
|
||||
form.classList.add('hidden')
|
||||
const until = o.scope !== 'laufend'
|
||||
? `, gültig bis ${fmtDate(o.validUntil)}`
|
||||
: o.discount?.known ? `, Rabatt endet am ${fmtBerlin(o.discount.endsAt)}` : ''
|
||||
showOfferMessage([
|
||||
`Angebot ${o.ref} für ${o.customerName} angelegt: ${o.text}.`,
|
||||
`Angebot ${o.ref} für ${o.customerName} angelegt: ${o.text}${until}.`,
|
||||
`Stripe-Coupon ${o.stripeCouponId} ist eingerichtet${o.status === 'aktiv' ? ' und am laufenden Abo aktiv' : ''}.`,
|
||||
v.notifyCustomer ? (result.mailSent ? 'Die E-Mail an den Kunden ist raus.' : `E-Mail an den Kunden fehlgeschlagen (${result.mailError}).`) : '',
|
||||
].filter(Boolean).join(' '))
|
||||
|
||||
+2
-1
@@ -803,8 +803,9 @@ function renderAboSideOffer(card, data, { ended }) {
|
||||
<h3>Wartungs-Abo</h3>
|
||||
${offer ? '<span class="kb-offer-tag">Ihr persönliches Angebot</span>' : ''}
|
||||
<p class="kb-price">${offer
|
||||
? `<strong>${euroShort(offer.monthlyPriceCents)}</strong><span>${offer.months === 1 ? 'im ersten Monat' : `pro Monat in den ersten ${offer.months} Monaten`}, danach 50 €</span>`
|
||||
? `<strong>${euroShort(offer.monthlyPriceCents)}</strong><span>${offer.months === 1 ? 'im ersten Monat' : `pro Monat in den ersten ${offer.months} Monaten`}, danach 50 €</span>`
|
||||
: '<strong>50 €</strong><span>pro Monat</span>'}</p>
|
||||
${offer?.validUntil ? `<p class="kb-offer-until">Angebot gültig bis <strong>${fmtDateShort(offer.validUntil)}</strong></p>` : ''}
|
||||
<ul class="kb-checks">${ABO_SIDE_FEATURES.map((f) => `<li>${escapeHtml(f)}</li>`).join('')}</ul>
|
||||
<button type="button" class="kb-btn-primary kb-btn-block" data-view="abo"${data.available ? '' : ' disabled'}>Abo abschließen</button>
|
||||
<p class="kb-fine">${data.available
|
||||
|
||||
@@ -122,6 +122,8 @@
|
||||
display: inline-block; margin: 0 0 10px; padding: 3px 9px; border-radius: 999px; font-size: 11.5px; font-weight: 700;
|
||||
color: #86efac; background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4);
|
||||
}
|
||||
.kb-offer-until { margin: -6px 0 14px; font-size: 12.5px; color: var(--kb-muted); }
|
||||
.kb-offer-until strong { color: #fff; }
|
||||
.kb-fine { margin: 10px 0 0; font-size: 11.5px; line-height: 1.5; color: var(--kb-faint); }
|
||||
.kb-fine a { color: #fff; font-weight: 700; }
|
||||
.kb-status { display: inline-block; padding: 3px 11px; border-radius: 999px; font-size: 12px; font-weight: 700; }
|
||||
@@ -713,7 +715,7 @@ button.kb-avatar:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
|
||||
#view-wartung .kb-abo { padding: 20px 18px 18px; border-radius: 20px; }
|
||||
#view-wartung .kb-abo h3 { font-size: 22px; }
|
||||
#view-wartung .kb-price strong, #view-wartung .kb-hours strong { font-size: 32px; }
|
||||
#view-wartung .kb-price span, #view-wartung .kb-hours span, #view-wartung .kb-sub { font-size: 14px; }
|
||||
#view-wartung .kb-price span, #view-wartung .kb-hours span, #view-wartung .kb-sub, #view-wartung .kb-offer-until { font-size: 14px; }
|
||||
#view-wartung .kb-checks { gap: 9px; font-size: 14.5px; }
|
||||
#view-wartung .kb-fine { font-size: 12.5px; }
|
||||
#view-wartung .kb-side-alert { font-size: 13.5px; }
|
||||
|
||||
+2
-4
@@ -3,7 +3,7 @@ import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { config, assertServerConfig, WOMS_DATABASE_ID } from './config.js'
|
||||
import { verifyDatabaseAccess } from './services/appwriteAdmin.js'
|
||||
import { syncPreviewTls } from './services/previewDeploy.js'
|
||||
import { syncPreviewTlsOnStartup } from './services/previewDeploy.js'
|
||||
import { requireSession, sessionMiddleware } from './middleware/session.js'
|
||||
import authRoutes from './routes/auth.js'
|
||||
import projectsRoutes from './routes/projects.js'
|
||||
@@ -101,9 +101,7 @@ app.use((err, _req, res, _next) => {
|
||||
|
||||
const server = app.listen(config.port, '0.0.0.0', () => {
|
||||
console.log(`Webklar Kundenbereich läuft auf Port ${config.port}`)
|
||||
syncPreviewTls()
|
||||
.then((r) => console.log('[startup] Preview-TLS-Sync:', JSON.stringify(r)))
|
||||
.catch((err) => console.error('[startup] Preview-TLS-Sync fehlgeschlagen:', err.message))
|
||||
syncPreviewTlsOnStartup()
|
||||
verifyDatabaseAccess().then((result) => {
|
||||
if (result.ok) return
|
||||
console.error(
|
||||
|
||||
@@ -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 || '',
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -10,8 +10,8 @@ const execFileAsync = promisify(execFile)
|
||||
/**
|
||||
* Subdomains, die als "oeffentliche Website" (ohne Login) laufen sollen.
|
||||
* Diese bekommen in syncPreviewTls einen Router OHNE preview-auth-ForwardAuth.
|
||||
* Faellt bei Appwrite-Fehlern bewusst auf ein leeres Set zurueck -> alle Seiten
|
||||
* bleiben dann login-geschuetzt (sicherer Default).
|
||||
* Liefert null, wenn Appwrite nicht antwortet (z.B. direkt nach einem
|
||||
* Server-Neustart, solange Appwrite noch hochfaehrt).
|
||||
*/
|
||||
async function getPublicSubdomains() {
|
||||
try {
|
||||
@@ -22,10 +22,38 @@ async function getPublicSubdomains() {
|
||||
return new Set(docs.map((d) => d.subdomain).filter(Boolean))
|
||||
} catch (err) {
|
||||
console.error('[previewDeploy] getPublicSubdomains fehlgeschlagen:', err.message)
|
||||
return new Set()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Oeffentliche Subdomains aus einer von syncPreviewTls geschriebenen
|
||||
* preview-hosts.yml: alle HTTPS-Router ohne preview-chain.
|
||||
*/
|
||||
export function publicSubdomainsFromHostsFile(content) {
|
||||
const result = new Set()
|
||||
let current = null
|
||||
let isProtected = false
|
||||
const finish = () => {
|
||||
if (current && !isProtected) result.add(current)
|
||||
current = null
|
||||
}
|
||||
for (const line of String(content || '').split('\n')) {
|
||||
if (/^ {4}\S/.test(line)) {
|
||||
finish()
|
||||
const match = line.match(/^ {4}preview-([a-z0-9-]+)-https:\s*$/)
|
||||
if (match) {
|
||||
current = match[1]
|
||||
isProtected = false
|
||||
}
|
||||
} else if (current && line.includes('preview-chain@docker')) {
|
||||
isProtected = true
|
||||
}
|
||||
}
|
||||
finish()
|
||||
return result
|
||||
}
|
||||
|
||||
export async function fetchPreviewConfig(repoFullName, branch) {
|
||||
const [owner, repo] = repoFullName.split('/')
|
||||
if (!owner || !repo || !config.gitea.apiToken) {
|
||||
@@ -305,7 +333,16 @@ export async function syncPreviewTls() {
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
|
||||
const publicSet = await getPublicSubdomains()
|
||||
const hostsFile = path.join(outDir, 'preview-hosts.yml')
|
||||
let publicSet = await getPublicSubdomains()
|
||||
let publicSource = 'appwrite'
|
||||
if (!publicSet) {
|
||||
// Appwrite antwortet nicht: oeffentliche Seiten aus der letzten Datei
|
||||
// uebernehmen, statt sie alle hinter den Login zu setzen. Neue
|
||||
// Subdomains bleiben login-geschuetzt (sicherer Default).
|
||||
publicSet = await fs.readFile(hostsFile, 'utf8').then(publicSubdomainsFromHostsFile, () => new Set())
|
||||
publicSource = 'previous-file'
|
||||
}
|
||||
|
||||
const blocks = slugs.map((name) => {
|
||||
const host = `${name}.${baseHost}`
|
||||
@@ -347,8 +384,34 @@ export async function syncPreviewTls() {
|
||||
].join('\n')
|
||||
|
||||
await fs.mkdir(outDir, { recursive: true })
|
||||
await fs.writeFile(path.join(outDir, 'preview-hosts.yml'), content, 'utf8')
|
||||
return { synced: true, hosts: slugs.length }
|
||||
await fs.writeFile(hostsFile, content, 'utf8')
|
||||
return { synced: true, hosts: slugs.length, publicSource }
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach einem Server-Neustart startet der Kundenbereich oft vor Appwrite. Dann
|
||||
* kennt syncPreviewTls die oeffentlichen Seiten nur aus der letzten Datei; der
|
||||
* Abgleich wird wiederholt, bis er mit Appwrite-Daten geklappt hat.
|
||||
*/
|
||||
export async function syncPreviewTlsOnStartup({
|
||||
sync = syncPreviewTls,
|
||||
retryMs = 15_000,
|
||||
maxAttempts = 40,
|
||||
} = {}) {
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const result = await sync()
|
||||
console.log('[startup] Preview-TLS-Sync:', JSON.stringify(result))
|
||||
if (!result.synced || result.publicSource === 'appwrite') return result
|
||||
} catch (err) {
|
||||
console.error('[startup] Preview-TLS-Sync fehlgeschlagen:', err.message)
|
||||
}
|
||||
if (attempt < maxAttempts) await new Promise((resolve) => setTimeout(resolve, retryMs))
|
||||
}
|
||||
console.error(
|
||||
`[startup] Preview-TLS-Sync: Appwrite nach ${maxAttempts} Versuchen nicht erreichbar, oeffentliche Seiten bleiben wie in der letzten Datei`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
export async function runDeploy(repoFullName, branch, previewConfig) {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
// Vor dem Import von config.js setzen (jede Testdatei laeuft in einem eigenen Prozess)
|
||||
process.env.ABO_TEST_EMAILS = 'test@example.invalid'
|
||||
const { chargesFrom, redeemedDiscount } = await import('../server/services/offerDiscount.js')
|
||||
const { adminOfferView } = await import('../server/services/offers.js')
|
||||
|
||||
test('Abbuchungen: vom Anker aus, Monatsende wie bei Stripe', () => {
|
||||
assert.deepEqual(chargesFrom('2026-09-26T08:00:00.000Z', '2026-09-26T08:00:00.000Z', 3), [
|
||||
'2026-09-26T08:00:00.000Z', '2026-10-26T08:00:00.000Z', '2026-11-26T08:00:00.000Z',
|
||||
])
|
||||
// 31. bleibt Anker: Februar kuerzer, danach wieder der 31.
|
||||
assert.deepEqual(chargesFrom('2026-01-31T10:00:00.000Z', '2026-01-31T10:00:00.000Z', 4), [
|
||||
'2026-01-31T10:00:00.000Z', '2026-02-28T10:00:00.000Z', '2026-03-31T10:00:00.000Z', '2026-04-30T10:00:00.000Z',
|
||||
])
|
||||
// Ab einem Zeitpunkt mitten im Abrechnungsmonat: naechste Abbuchung
|
||||
assert.deepEqual(chargesFrom('2026-01-15T10:00:00.000Z', '2026-03-20T12:00:00.000Z', 2), [
|
||||
'2026-04-15T10:00:00.000Z', '2026-05-15T10:00:00.000Z',
|
||||
])
|
||||
assert.deepEqual(chargesFrom(null, '2026-03-20T12:00:00.000Z', 2), [])
|
||||
})
|
||||
|
||||
test('Angebot beim Abschluss, Leistung sofort: Abbuchungen 1..N rabattiert', () => {
|
||||
const sub = { startedAt: '2026-09-26T08:00:00.000Z', trialEnd: null }
|
||||
const d = redeemedDiscount({ months: 2, appliedAt: '2026-09-26T08:00:05.000Z', attachedLater: false }, sub)
|
||||
assert.deepEqual(d, { known: true, firstChargeAt: '2026-09-26T08:00:00.000Z', endsAt: '2026-11-26T08:00:00.000Z', subscriptionEndsAt: null })
|
||||
})
|
||||
|
||||
test('Angebot beim Abschluss, Beginn nach Widerrufsfrist: Rabatt ab Leistungsbeginn', () => {
|
||||
const sub = { startedAt: '2026-09-26T08:00:00.000Z', trialEnd: '2026-10-13T04:00:00.000Z' }
|
||||
const d = redeemedDiscount({ months: 2, appliedAt: '2026-09-26T08:00:05.000Z', attachedLater: false }, sub)
|
||||
assert.equal(d.firstChargeAt, '2026-10-13T04:00:00.000Z')
|
||||
assert.equal(d.endsAt, '2026-12-13T04:00:00.000Z')
|
||||
})
|
||||
|
||||
test('Testkonto (Coupon nach der ersten Abbuchung): Abbuchungen 2..N+1', () => {
|
||||
const sub = { startedAt: '2026-09-26T08:00:00.000Z', trialEnd: null }
|
||||
const d = redeemedDiscount({ months: 2, appliedAt: '2026-09-26T08:00:05.000Z', attachedLater: true }, sub)
|
||||
assert.equal(d.firstChargeAt, '2026-10-26T08:00:00.000Z')
|
||||
assert.equal(d.endsAt, '2026-12-26T08:00:00.000Z')
|
||||
})
|
||||
|
||||
test('Laufendes Abo: ab der naechsten Abbuchung nach dem Anhaengen', () => {
|
||||
const sub = { startedAt: '2026-03-14T09:00:00.000Z', trialEnd: null }
|
||||
const d = redeemedDiscount({ months: 2, appliedAt: '2026-09-26T20:00:00.000Z', attachedLater: true }, sub)
|
||||
assert.equal(d.firstChargeAt, '2026-10-14T09:00:00.000Z')
|
||||
assert.equal(d.endsAt, '2026-12-14T09:00:00.000Z')
|
||||
})
|
||||
|
||||
test('Abo endet vor dem Rabatt: Abo-Ende wird gemeldet, sonst nicht', () => {
|
||||
const base = { startedAt: '2026-09-26T08:00:00.000Z', trialEnd: null }
|
||||
const offer = { months: 3, appliedAt: '2026-09-26T08:00:05.000Z', attachedLater: false }
|
||||
assert.equal(redeemedDiscount(offer, { ...base, cancelAt: '2026-10-26T08:00:00.000Z' }).subscriptionEndsAt, '2026-10-26T08:00:00.000Z')
|
||||
assert.equal(redeemedDiscount(offer, { ...base, endedAt: '2026-10-01T08:00:00.000Z' }).subscriptionEndsAt, '2026-10-01T08:00:00.000Z')
|
||||
assert.equal(redeemedDiscount(offer, { ...base, cancelAt: '2027-09-26T08:00:00.000Z' }).subscriptionEndsAt, null)
|
||||
})
|
||||
|
||||
test('Ohne Abo-Dokument ist das Rabattende unbekannt', () => {
|
||||
assert.deepEqual(redeemedDiscount({ months: 2, appliedAt: '2026-09-26T08:00:05.000Z', attachedLater: false }, null), { known: false })
|
||||
assert.deepEqual(redeemedDiscount({ months: 2, appliedAt: null, attachedLater: true }, { startedAt: '2026-09-26T08:00:00.000Z' }), { known: false })
|
||||
})
|
||||
|
||||
test('Admin-Ansicht: Rabatt nur bei eingeloesten Angeboten, Testkonto erkannt', () => {
|
||||
const doc = {
|
||||
$id: 'offer-1', $createdAt: '2026-09-26T07:00:00.000Z', status: 'aktiv', scope: 'neu', months: 2,
|
||||
monthlyPriceCents: 2500, regularPriceCents: 5000, validUntil: '2026-10-27T00:00:00.000Z',
|
||||
appliedAt: '2026-09-26T08:00:05.000Z', stripeSubscriptionId: 'sub_1', customerId: 'c1',
|
||||
}
|
||||
const sub = { $id: 'sub_1', startedAt: '2026-09-26T08:00:00.000Z', trialEnd: null }
|
||||
const normal = adminOfferView(doc, { name: 'Kunde', email: 'kunde@example.invalid' }, sub)
|
||||
assert.equal(normal.testAccount, false)
|
||||
assert.equal(normal.discount.endsAt, '2026-11-26T08:00:00.000Z')
|
||||
const testView = adminOfferView(doc, { name: 'Test', email: 'TEST@example.invalid' }, sub)
|
||||
assert.equal(testView.testAccount, true)
|
||||
assert.equal(testView.discount.endsAt, '2026-12-26T08:00:00.000Z')
|
||||
const running = adminOfferView({ ...doc, scope: 'laufend', appliedAt: '2026-10-01T12:00:00.000Z' }, { email: 'kunde@example.invalid' }, sub)
|
||||
assert.equal(running.discount.firstChargeAt, '2026-10-26T08:00:00.000Z')
|
||||
assert.equal(adminOfferView({ ...doc, status: 'offen', appliedAt: null }, { email: 'kunde@example.invalid' }, null).discount, null)
|
||||
assert.deepEqual(adminOfferView(doc, { email: 'kunde@example.invalid' }, undefined).discount, { known: false })
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import { test, before, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import http from 'node:http'
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
// Nachgestellter Server-Neustart: Appwrite antwortet erst, wenn appwriteUp true ist.
|
||||
let appwriteUp = true
|
||||
const appwrite = http.createServer((req, res) => {
|
||||
if (!appwriteUp) return req.socket.destroy() // wie beim Boot: "fetch failed"
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.end(JSON.stringify({ total: 1, documents: [{ subdomain: 'oeffentlich' }] }))
|
||||
})
|
||||
|
||||
let tmp
|
||||
let hostsFile
|
||||
let previewDeploy
|
||||
|
||||
before(async () => {
|
||||
await new Promise((resolve) => appwrite.listen(0, '127.0.0.1', resolve))
|
||||
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-sync-'))
|
||||
for (const name of ['oeffentlich', 'privat']) {
|
||||
await fs.mkdir(path.join(tmp, 'previews', name), { recursive: true })
|
||||
}
|
||||
hostsFile = path.join(tmp, 'dynamic', 'preview-hosts.yml')
|
||||
Object.assign(process.env, {
|
||||
APPWRITE_ENDPOINT: `http://127.0.0.1:${appwrite.address().port}/v1`,
|
||||
APPWRITE_API_KEY: 'test-key',
|
||||
PREVIEW_DEPLOY_ROOT: path.join(tmp, 'previews'),
|
||||
TRAEFIK_DYNAMIC_DIR: path.join(tmp, 'dynamic'),
|
||||
})
|
||||
// config.js liest die Umgebung beim Import
|
||||
previewDeploy = await import('../server/services/previewDeploy.js')
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
appwrite.close()
|
||||
await fs.rm(tmp, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const publicInFile = async () =>
|
||||
[...previewDeploy.publicSubdomainsFromHostsFile(await fs.readFile(hostsFile, 'utf8'))].sort()
|
||||
|
||||
/* ------------------------------------------------------------- Datei --- */
|
||||
|
||||
test('Datei lesen: nur HTTPS-Router ohne preview-chain gelten als öffentlich', () => {
|
||||
const content = [
|
||||
'# Auto-generated by previewDeploy.syncPreviewTls - do not edit manually',
|
||||
'http:',
|
||||
' routers:',
|
||||
' preview-a-b-https:',
|
||||
' rule: "Host(`a-b.project.webklar.com`)"',
|
||||
' service: preview-nginx@docker',
|
||||
' priority: 100',
|
||||
'',
|
||||
' preview-a-b-http:',
|
||||
' middlewares:',
|
||||
' - preview-sites-redirect@docker',
|
||||
' preview-geheim-https:',
|
||||
' service: preview-nginx@docker',
|
||||
' middlewares:',
|
||||
' - preview-chain@docker',
|
||||
' preview-geheim-http:',
|
||||
' priority: 100',
|
||||
].join('\n')
|
||||
assert.deepEqual([...previewDeploy.publicSubdomainsFromHostsFile(content)], ['a-b'])
|
||||
assert.equal(previewDeploy.publicSubdomainsFromHostsFile('').size, 0)
|
||||
assert.equal(previewDeploy.publicSubdomainsFromHostsFile(undefined).size, 0)
|
||||
})
|
||||
|
||||
/* --------------------------------------------------------- Abgleich --- */
|
||||
|
||||
test('Abgleich mit Appwrite: öffentliche Seite ohne Login, Rest geschützt', async () => {
|
||||
appwriteUp = true
|
||||
const result = await previewDeploy.syncPreviewTls()
|
||||
assert.deepEqual(result, { synced: true, hosts: 2, publicSource: 'appwrite' })
|
||||
assert.deepEqual(await publicInFile(), ['oeffentlich'])
|
||||
})
|
||||
|
||||
test('Neustart, Appwrite noch nicht da: öffentliche Seiten bleiben öffentlich, neue bleiben geschützt', async () => {
|
||||
appwriteUp = true
|
||||
await previewDeploy.syncPreviewTls()
|
||||
await fs.mkdir(path.join(tmp, 'previews', 'neu'))
|
||||
appwriteUp = false
|
||||
try {
|
||||
const result = await previewDeploy.syncPreviewTls()
|
||||
assert.deepEqual(result, { synced: true, hosts: 3, publicSource: 'previous-file' })
|
||||
assert.deepEqual(await publicInFile(), ['oeffentlich'])
|
||||
assert.match(await fs.readFile(hostsFile, 'utf8'), /preview-neu-https:/)
|
||||
} finally {
|
||||
appwriteUp = true
|
||||
await fs.rm(path.join(tmp, 'previews', 'neu'), { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('Ohne Appwrite und ohne alte Datei: alles login-geschützt (sicherer Default)', async () => {
|
||||
await fs.rm(hostsFile, { force: true })
|
||||
appwriteUp = false
|
||||
try {
|
||||
const result = await previewDeploy.syncPreviewTls()
|
||||
assert.equal(result.publicSource, 'previous-file')
|
||||
assert.deepEqual(await publicInFile(), [])
|
||||
} finally {
|
||||
appwriteUp = true
|
||||
}
|
||||
})
|
||||
|
||||
/* ---------------------------------------------------------- Startup --- */
|
||||
|
||||
test('Startup: wiederholt, bis der Abgleich mit Appwrite-Daten geklappt hat', async () => {
|
||||
const answers = [
|
||||
{ synced: true, hosts: 2, publicSource: 'previous-file' },
|
||||
new Error('Schreiben fehlgeschlagen'),
|
||||
{ synced: true, hosts: 2, publicSource: 'appwrite' },
|
||||
]
|
||||
let calls = 0
|
||||
const sync = async () => {
|
||||
const answer = answers[calls++]
|
||||
if (answer instanceof Error) throw answer
|
||||
return answer
|
||||
}
|
||||
const result = await previewDeploy.syncPreviewTlsOnStartup({ sync, retryMs: 0 })
|
||||
assert.equal(calls, 3)
|
||||
assert.equal(result.publicSource, 'appwrite')
|
||||
})
|
||||
|
||||
test('Startup: ohne Preview-Konfiguration kein Wiederholen', async () => {
|
||||
let calls = 0
|
||||
const sync = async () => {
|
||||
calls++
|
||||
return { synced: false, reason: 'PREVIEW_DEPLOY_ROOT oder TRAEFIK_DYNAMIC_DIR nicht gesetzt' }
|
||||
}
|
||||
await previewDeploy.syncPreviewTlsOnStartup({ sync, retryMs: 0 })
|
||||
assert.equal(calls, 1)
|
||||
})
|
||||
|
||||
test('Startup: gibt nach maxAttempts auf', async () => {
|
||||
let calls = 0
|
||||
const sync = async () => {
|
||||
calls++
|
||||
return { synced: true, hosts: 2, publicSource: 'previous-file' }
|
||||
}
|
||||
const result = await previewDeploy.syncPreviewTlsOnStartup({ sync, retryMs: 0, maxAttempts: 4 })
|
||||
assert.equal(calls, 4)
|
||||
assert.equal(result, null)
|
||||
})
|
||||
Reference in new issue
Block a user