385 lines
19 KiB
JavaScript
385 lines
19 KiB
JavaScript
import { readFile } from 'node:fs/promises'
|
||
import { config } from '../config.js'
|
||
import { getDocument, updateDocument } from './appwriteAdmin.js'
|
||
import { listContractEvents, recordContractEvent, updateContractEvent } from './contractEvents.js'
|
||
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
|
||
import { contractAttachmentFor, describeItems, priceIdsOfInvoice, regularStorageLine } from './products.js'
|
||
import {
|
||
ACTIVE_STATUSES,
|
||
ensurePrice,
|
||
getCurrentSubscription,
|
||
getStripe,
|
||
invoiceLines,
|
||
resolvePriceKeys,
|
||
subscriptionIdOfInvoice,
|
||
syncSubscription,
|
||
} from './stripeBilling.js'
|
||
|
||
/**
|
||
* E-Mail-Zusatzspeicher (Spec 3.4, Bedingungen Fassung 4 § 10): Pakete zu 5 GB fuer 1,00 € je Monat als
|
||
* zweite Position (Stueckzahl) im Stripe-Abo des Kunden, 0 bis 10 Pakete, in beiden Abos.
|
||
*
|
||
* - Plus (Bestellung, "Zahlungspflichtig bestellen"): sofort. Den Rest des laufenden Monats berechnet
|
||
* Stripe anteilig mit der naechsten Abbuchung (proration_behavior create_prorations). Pakete, die fuer
|
||
* den laufenden Monat schon bezahlt sind (nach einem Minus im selben Monat), kosten nicht noch einmal:
|
||
* bis zur bezahlten Menge proration_behavior none, darueber anteilig - notfalls in zwei Schritten
|
||
* (planStorageChange).
|
||
* - Minus ("Speichern"): Stueckzahl sofort bei Stripe, ohne Erstattung (none). Die Postfachgrenze in
|
||
* konsoleH sinkt erst zum Ende des Abrechnungsmonats (Aufgabe fuers Team, dueAt = Periodenende).
|
||
* - Jede Aenderung ist ein Ereignis `speicher` mit Status offen (= Aufgabe fuers Team in konsoleH); nur ein
|
||
* Plus traegt Zustimmungsdaten, ein Minus keine (Rechtsrunde 28.09.2026). Eine neue Aenderung ersetzt
|
||
* die aeltere offene Aufgabe desselben Abos (F21): eine Aufgabe je Abo mit Zielgroesse und Datum.
|
||
* - Bezahlte Menge je Abrechnungsmonat (storagePaidUnits, storagePaidPeriodEnd): beim Plus gesetzt, sonst
|
||
* aus der regulaeren Speicher-Zeile jeder bezahlten Rechnung (recordPaidStorage, Webhook invoice.paid).
|
||
*/
|
||
|
||
function httpError(status, message) {
|
||
const err = new Error(message)
|
||
err.status = status
|
||
return err
|
||
}
|
||
|
||
const fmtDate = (iso) =>
|
||
iso
|
||
? new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||
: '–'
|
||
// Preis wie in den Bedingungen: "2,00 €" (normales Leerzeichen)
|
||
const fmtEuro = (cents) => `${(Number(cents || 0) / 100).toFixed(2).replace('.', ',')} €`
|
||
const toIso = (unixSeconds) => (unixSeconds ? new Date(unixSeconds * 1000).toISOString() : null)
|
||
|
||
const unitsOf = (doc) => Number(doc?.storageUnits) || 0
|
||
const totalGbOf = (units) => config.mailStorage.includedGb + units * config.mailStorage.gbPerUnit
|
||
const monthlyCentsOf = (units) => units * config.mailStorage.amountCents
|
||
|
||
/**
|
||
* Stripe-Schritte fuer eine Aenderung auf newUnits Pakete: { direction: 'plus' | 'minus' | 'none', steps:
|
||
* [{ quantity, proration_behavior }] }. Ein Plus bis zur schon bezahlten Menge (paidUnits) laeuft ohne
|
||
* Verrechnung (none); darueber hinaus anteilig (create_prorations), bei currentUnits < paidUnits in zwei
|
||
* Schritten. Ein Minus laeuft ohne Erstattung (none). newUnits muss eine ganze Zahl von 0 bis maxUnits sein.
|
||
*/
|
||
export function planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits }) {
|
||
if (!Number.isInteger(newUnits) || newUnits < 0 || newUnits > maxUnits) {
|
||
throw httpError(400, `Bitte wählen Sie zwischen 0 und ${maxUnits} Paketen Zusatzspeicher.`)
|
||
}
|
||
const current = Number(currentUnits) || 0
|
||
const paid = Number(paidUnits) || 0
|
||
if (newUnits === current) return { direction: 'none', steps: [] }
|
||
if (newUnits < current) return { direction: 'minus', steps: [{ quantity: newUnits, proration_behavior: 'none' }] }
|
||
if (newUnits <= paid) return { direction: 'plus', steps: [{ quantity: newUnits, proration_behavior: 'none' }] }
|
||
if (current < paid) {
|
||
return {
|
||
direction: 'plus',
|
||
steps: [
|
||
{ quantity: paid, proration_behavior: 'none' },
|
||
{ quantity: newUnits, proration_behavior: 'create_prorations' },
|
||
],
|
||
}
|
||
}
|
||
return { direction: 'plus', steps: [{ quantity: newUnits, proration_behavior: 'create_prorations' }] }
|
||
}
|
||
|
||
/**
|
||
* Fuer den laufenden Abrechnungsmonat schon bezahlte Pakete: storagePaidUnits, wenn storagePaidPeriodEnd
|
||
* der laufende Zeitraum ist (Appwrite gibt Datumswerte mit +00:00 zurueck, der Spiegel schreibt Z: gleicher
|
||
* Zeitpunkt zaehlt), sonst die aktuelle Menge (die regulaere Rechnung des Monats hat sie berechnet).
|
||
*/
|
||
export function paidUnitsOf(doc) {
|
||
const paidEnd = doc?.storagePaidPeriodEnd
|
||
const periodEnd = doc?.currentPeriodEnd
|
||
const samePeriod =
|
||
Boolean(paidEnd && periodEnd) && (paidEnd === periodEnd || new Date(paidEnd).getTime() === new Date(periodEnd).getTime())
|
||
return samePeriod ? Number(doc.storagePaidUnits) || 0 : unitsOf(doc)
|
||
}
|
||
|
||
async function requireSubscription(customerId) {
|
||
const doc = await getCurrentSubscription(customerId)
|
||
if (!doc) throw httpError(404, 'Kein Abo vorhanden.')
|
||
return doc
|
||
}
|
||
|
||
// Position fuer subscriptions.update / invoices.createPreview: vorhandener Speicher-Posten per ID, sonst neu per Preis
|
||
const storageItemParam = (storageItemId, priceId, quantity) => (storageItemId ? { id: storageItemId, quantity } : { price: priceId, quantity })
|
||
|
||
/**
|
||
* Ein Minus wirkt zum Ende des Abrechnungsmonats; endet das Abo frueher (cancelAt), endet der Speicher
|
||
* mit ihm (Spec 3.4 "Der Zusatzspeicher endet automatisch mit dem Abo").
|
||
*/
|
||
function reductionEffectiveAt(doc) {
|
||
const periodEnd = doc.currentPeriodEnd || null
|
||
const cancelAt = doc.cancelAt || null
|
||
if (periodEnd && cancelAt && new Date(cancelAt) < new Date(periodEnd)) return cancelAt
|
||
return periodEnd || cancelAt
|
||
}
|
||
|
||
/**
|
||
* Anteiliger Betrag der Speicher-Zeilen einer Stripe-Vorschau (naechste Rechnung): Belastung fuer die neue
|
||
* Menge abzueglich Gutschrift fuer die alte, jeweils ab prorationDate. Aeltere anteilige Zeilen (eine
|
||
* fruehere Aenderung im selben Monat, schon als Rechnungsposition vorgemerkt) und die regulaeren Zeilen des
|
||
* naechsten Monats zaehlen nicht.
|
||
*/
|
||
function storageProrationCents(preview, priceIds, prorationDate) {
|
||
return (preview?.lines?.data || [])
|
||
.filter((line) => {
|
||
const parent = line.parent || {}
|
||
const details = parent.subscription_item_details || parent.invoice_item_details || {}
|
||
if (details.proration !== true) return false
|
||
if (!priceIds.has(line.pricing?.price_details?.price)) return false
|
||
// Toleranz fuer Sekunden zwischen Vorschau und Stripe-Rundung
|
||
return !line.period?.start || line.period.start >= prorationDate - 300
|
||
})
|
||
.reduce((sum, line) => sum + (Number(line.amount) || 0), 0)
|
||
}
|
||
|
||
/**
|
||
* Vorschau fuer newUnits Pakete: Richtung, Gesamtgroesse, Monatspreis des Speichers, Mehr-/Minderpreis je
|
||
* Monat, anteiliger Betrag fuer den Rest des laufenden Monats (aus invoices.createPreview), naechste Abbuchung
|
||
* und Wirksamkeit (Minus: Periodenende, sonst jetzt). Bis zur schon bezahlten Menge kostet ein Plus nichts.
|
||
*/
|
||
export async function previewStorageChange(customerId, newUnits) {
|
||
const doc = await requireSubscription(customerId)
|
||
const currentUnits = unitsOf(doc)
|
||
const paidUnits = paidUnitsOf(doc)
|
||
const plan = planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits: config.mailStorage.maxUnits })
|
||
const now = new Date()
|
||
let prorationCents = 0
|
||
if (plan.direction === 'plus' && newUnits > paidUnits) {
|
||
const stripe = getStripe()
|
||
const [priceId, stripeSub] = await Promise.all([ensurePrice('mailStorage'), stripe.subscriptions.retrieve(doc.$id)])
|
||
const items = stripeSub.items?.data || []
|
||
const { storageItemId } = describeItems(items)
|
||
const prorationDate = Math.floor(now.getTime() / 1000)
|
||
const preview = await stripe.invoices.createPreview({
|
||
subscription: doc.$id,
|
||
subscription_details: {
|
||
items: [storageItemParam(storageItemId, priceId, newUnits)],
|
||
proration_behavior: 'create_prorations',
|
||
proration_date: prorationDate,
|
||
},
|
||
})
|
||
// Gutschrift-Zeilen tragen den Preis des vorhandenen Postens (kann ein aelterer Speicher-Preis sein)
|
||
const priceIds = new Set([priceId, items.find((item) => item.id === storageItemId)?.price?.id].filter(Boolean))
|
||
const previewed = storageProrationCents(preview, priceIds, prorationDate)
|
||
// F19: Stripe rechnet die Vorschau von der heutigen Menge (currentUnits) aus; berechnet werden nur die
|
||
// Pakete ueber der schon bezahlten Menge, der erste Schritt bis dahin laeuft ohne Verrechnung
|
||
prorationCents = Math.round((previewed * (newUnits - Math.max(currentUnits, paidUnits))) / (newUnits - currentUnits))
|
||
}
|
||
return {
|
||
direction: plan.direction,
|
||
units: newUnits,
|
||
totalGb: totalGbOf(newUnits),
|
||
monthlyCents: monthlyCentsOf(newUnits),
|
||
deltaMonthlyCents: monthlyCentsOf(newUnits) - monthlyCentsOf(currentUnits),
|
||
prorationCents,
|
||
nextChargeAt: doc.currentPeriodEnd || null,
|
||
effectiveAt: plan.direction === 'minus' ? reductionEffectiveAt(doc) : now.toISOString(),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Darf der Kunde die Menge aendern? Im Portal nur bei Status active, ohne Kuendigung und ohne Wechsel, der
|
||
* auf die Zahlung wartet (wie canChangeStorage in GET /api/abo, F12). Ueber das Kuendigungsformular
|
||
* (viaCancellation, Task 10; Bedingungen § 10 Abs. 5 "jederzeit") ist eine Verringerung in jedem laufenden
|
||
* Status erlaubt, auch bei gekuendigtem Abo oder offener Zahlung - eine Erhoehung dort nie (F20).
|
||
*/
|
||
function assertChangeAllowed(doc, events, direction, viaCancellation) {
|
||
if (viaCancellation) {
|
||
if (direction !== 'minus') throw httpError(409, 'Über die Kündigung lässt sich der Zusatzspeicher nur verringern.')
|
||
if (!ACTIVE_STATUSES.has(doc.status)) throw httpError(409, 'Das Abo läuft nicht mehr.')
|
||
return
|
||
}
|
||
if (doc.status !== 'active') {
|
||
throw httpError(
|
||
409,
|
||
'Der Zusatzspeicher lässt sich nur bei laufendem Abo ändern, nicht in der Wartezeit bis zum Leistungsbeginn und nicht bei einem Zahlungsproblem.'
|
||
)
|
||
}
|
||
if (doc.cancelAtPeriodEnd || doc.cancelAt) throw httpError(409, 'Ihr Abo ist gekündigt. Der Zusatzspeicher endet mit dem Abo.')
|
||
if (events.some((e) => e.type === 'wechsel' && e.status === 'offen')) {
|
||
throw httpError(409, 'Ihr Wechsel wartet noch auf die Zahlung. Bitte versuchen Sie es danach erneut.')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ereignis `speicher` (Nachweis der Bestellung bzw. Aufgabe fuers Team). Aeltere offene Aufgaben desselben
|
||
* Abos sind damit ueberholt (F21: erledigt, doneBy 'ersetzt'). Ein Fehler beim Speichern bricht die schon
|
||
* bei Stripe wirksame Aenderung nicht ab: er wird protokolliert, T13 traegt die Aufgabe trotzdem zum Team.
|
||
*/
|
||
async function recordStorageEvent({ doc, events, change, consent, dueAt, viaCancellation }) {
|
||
try {
|
||
const now = new Date().toISOString()
|
||
for (const e of events.filter((e) => e.type === 'speicher' && e.status === 'offen')) {
|
||
await updateContractEvent(e.$id, { status: 'erledigt', doneAt: now, doneBy: 'ersetzt' })
|
||
}
|
||
return await recordContractEvent({
|
||
customerId: doc.customerId,
|
||
subscriptionId: doc.$id,
|
||
type: 'speicher',
|
||
productKey: doc.productKey || 'wartung',
|
||
details: {
|
||
fromUnits: change.fromUnits,
|
||
toUnits: change.toUnits,
|
||
effectiveAt: change.effectiveAt,
|
||
totalGb: change.totalGb,
|
||
monthlyCents: change.monthlyCents,
|
||
deltaMonthlyCents: change.deltaMonthlyCents,
|
||
...(viaCancellation ? { viaCancellation: true } : {}),
|
||
},
|
||
// Nur eine Bestellung (Plus) traegt Zustimmungsdaten; ein Minus keine (Rechtsrunde 28.09.2026)
|
||
consent:
|
||
change.direction === 'plus'
|
||
? {
|
||
termsVersion: consent.termsVersion,
|
||
consentAt: consent.consentAt,
|
||
consentIp: consent.consentIp || null,
|
||
consentUserAgent: consent.consentUserAgent || null,
|
||
}
|
||
: null,
|
||
status: 'offen',
|
||
dueAt,
|
||
})
|
||
} catch (err) {
|
||
console.error(`[speicher] Ereignis für Abo ${doc.$id} nicht gespeichert:`, err.message)
|
||
return null
|
||
}
|
||
}
|
||
|
||
/** Vertragsunterlagen (PDF) in der zugestimmten Fassung als Mail-Anhang; ohne Datei geht die Mail ohne Anhang raus. */
|
||
async function contractPdfAttachment(termsVersion) {
|
||
const { filename, path } = contractAttachmentFor(termsVersion)
|
||
const content = await readFile(path).catch((err) => {
|
||
console.error(`[speicher] Vertragsunterlagen ${filename} nicht lesbar:`, err.message)
|
||
return null
|
||
})
|
||
return content ? [{ filename, content, contentType: 'application/pdf' }] : []
|
||
}
|
||
|
||
/**
|
||
* K13 an den Kunden (Plus: mit PDF der zugestimmten Fassung, F22) und T13 ans Team. Ueber das
|
||
* Kuendigungsformular bestaetigt K03, K13 entfaellt dort (F31). Ein Mailfehler bricht die Aenderung nie ab.
|
||
*/
|
||
async function sendStorageMails({ doc, change, dueAt, consent, viaCancellation, proofMissing }) {
|
||
const plus = change.direction === 'plus'
|
||
const customer = await getDocument(config.collections.customers, doc.customerId).catch(() => null)
|
||
const email = customer?.email || doc.email || ''
|
||
const wirksamAb = fmtDate(change.effectiveAt)
|
||
if (!viaCancellation && email) {
|
||
const attachments = plus ? await contractPdfAttachment(consent.termsVersion) : []
|
||
await sendCustomerMail(email, {
|
||
template: 'K13',
|
||
values: {
|
||
name: customer?.name || '',
|
||
pakete: change.toUnits,
|
||
gesamt_gb: change.totalGb,
|
||
monatspreis_zusatz: fmtEuro(change.monthlyCents),
|
||
wirksam_ab: wirksamAb,
|
||
},
|
||
blocks: { plus, minus: !plus },
|
||
attachments,
|
||
}).catch((e) => console.error(`[speicher] K13 an ${email} fehlgeschlagen:`, e.message))
|
||
}
|
||
await sendAdminNotice({
|
||
template: 'T13',
|
||
values: {
|
||
aktion: plus ? 'einrichten' : `reduzieren zum ${wirksamAb}`,
|
||
email,
|
||
postfaecher_hinweis:
|
||
(doc.productKey || 'wartung') === 'hosting' ? 'Hosting-Abo: 1 Postfach' : 'Wartungs-Abo: alle Postfächer des Kunden zusammen',
|
||
neue_groesse_gb: change.totalGb,
|
||
faellig: fmtDate(dueAt),
|
||
},
|
||
blocks: proofMissing ? { nachweis_fehlt: true } : {},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Menge auf newUnits Pakete aendern: Stripe-Schritte nacheinander (die erste Position mit { price, quantity },
|
||
* danach { id, quantity }; bei 0 bleibt die Position mit Menge 0 bestehen), Spiegel und bezahlte Menge
|
||
* (Plus: max(paidUnits, newUnits) fuer den laufenden Monat), Ereignis `speicher` (offen; dueAt jetzt bzw.
|
||
* Periodenende), K13 und T13. consent { termsVersion, consentAt, consentIp, consentUserAgent } ist beim Plus
|
||
* Pflicht (400), beim Minus null. options.viaCancellation: Kuendigungsformular (nur Verringerung, F20).
|
||
* Liefert { subscription, event, change }.
|
||
*/
|
||
export async function changeStorage(customerId, newUnits, consent, options = {}) {
|
||
const viaCancellation = options.viaCancellation === true
|
||
const doc = await requireSubscription(customerId)
|
||
const currentUnits = unitsOf(doc)
|
||
const paidUnits = paidUnitsOf(doc)
|
||
const plan = planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits: config.mailStorage.maxUnits })
|
||
if (plan.direction === 'none') throw httpError(400, 'Die Anzahl der Pakete ist unverändert.')
|
||
const plus = plan.direction === 'plus'
|
||
if (plus && !(consent?.termsVersion && consent?.consentAt)) {
|
||
throw httpError(400, 'Bitte stimmen Sie den Regeln zum Zusatzspeicher zu.')
|
||
}
|
||
const events = (await listContractEvents(customerId)).filter((e) => e.subscriptionId === doc.$id)
|
||
assertChangeAllowed(doc, events, plan.direction, viaCancellation)
|
||
|
||
// Stripe: Speicher-Posten anlegen oder Menge aendern, Schritt fuer Schritt (planStorageChange)
|
||
const stripe = getStripe()
|
||
const [priceId, stripeSub] = await Promise.all([ensurePrice('mailStorage'), stripe.subscriptions.retrieve(doc.$id)])
|
||
let { storageItemId } = describeItems(stripeSub.items?.data)
|
||
const prorationDate = Math.floor(Date.now() / 1000)
|
||
for (const step of plan.steps) {
|
||
const updated = await stripe.subscriptions.update(doc.$id, {
|
||
items: [storageItemParam(storageItemId, priceId, step.quantity)],
|
||
proration_behavior: step.proration_behavior,
|
||
...(step.proration_behavior === 'create_prorations' ? { proration_date: prorationDate } : {}),
|
||
})
|
||
storageItemId = describeItems(updated.items?.data).storageItemId || storageItemId
|
||
}
|
||
|
||
// Spiegel (storageUnits aus den Positionen) und bezahlte Menge des laufenden Monats. Die Aenderung ist
|
||
// bei Stripe schon wirksam: scheitert die Buchung der bezahlten Menge (z. B. Attribut fehlt, Setup-Skript
|
||
// nicht gelaufen), laufen Ereignis und Mails trotzdem; ein Plus nach einem Minus im selben Monat wuerde
|
||
// dann anteilig berechnet statt frei bleiben - Fehler im Log.
|
||
let subscription = (await syncSubscription(doc.$id)) || doc
|
||
if (plus) {
|
||
const paid = { storagePaidUnits: Math.max(paidUnits, newUnits), storagePaidPeriodEnd: subscription.currentPeriodEnd || doc.currentPeriodEnd || null }
|
||
subscription = await updateDocument(config.collections.subscriptions, doc.$id, paid).catch((err) => {
|
||
console.error(`[speicher] Bezahlte Menge für Abo ${doc.$id} nicht gespeichert (${err.status || '?'}):`, err.message)
|
||
return subscription
|
||
})
|
||
}
|
||
|
||
const now = new Date().toISOString()
|
||
const effectiveAt = plus ? now : reductionEffectiveAt(subscription)
|
||
const change = {
|
||
direction: plan.direction,
|
||
fromUnits: currentUnits,
|
||
toUnits: newUnits,
|
||
effectiveAt,
|
||
totalGb: totalGbOf(newUnits),
|
||
monthlyCents: monthlyCentsOf(newUnits),
|
||
deltaMonthlyCents: monthlyCentsOf(newUnits) - monthlyCentsOf(currentUnits),
|
||
}
|
||
// Aufgabe fuers Team: Plus sofort einrichten, Minus zum Periodenende reduzieren
|
||
const dueAt = plus ? now : effectiveAt
|
||
const event = await recordStorageEvent({ doc, events, change, consent, dueAt, viaCancellation })
|
||
await sendStorageMails({ doc: subscription, change, dueAt, consent, viaCancellation, proofMissing: !event })
|
||
return { subscription, event, change }
|
||
}
|
||
|
||
/**
|
||
* Bezahlte Rechnung (Webhook invoice.paid, frisch geladen mit loadInvoice): hat sie eine regulaere
|
||
* Zusatzspeicher-Zeile (nicht anteilig), ist deren Stueckzahl fuer den Zeitraum bis line.period.end bezahlt
|
||
* (storagePaidUnits, storagePaidPeriodEnd). Idempotent; fehlt der Spiegel noch (Webhook-Reihenfolge),
|
||
* wird er aus Stripe angelegt.
|
||
*/
|
||
export async function recordPaidStorage(invoice) {
|
||
const lines = await invoiceLines(invoice)
|
||
const loaded = { ...invoice, lines: { ...(invoice?.lines || {}), data: lines, has_more: false } }
|
||
const line = regularStorageLine(loaded, await resolvePriceKeys(priceIdsOfInvoice(loaded)))
|
||
if (!line) return { skipped: 'keine reguläre Speicher-Zeile' }
|
||
const ref = line.parent?.subscription_item_details?.subscription || subscriptionIdOfInvoice(loaded)
|
||
const subscriptionId = typeof ref === 'string' ? ref : ref?.id
|
||
if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' }
|
||
const data = { storagePaidUnits: Number(line.quantity) || 0, storagePaidPeriodEnd: toIso(line.period?.end) }
|
||
try {
|
||
await updateDocument(config.collections.subscriptions, subscriptionId, data)
|
||
} catch (err) {
|
||
if (err.status !== 404) throw err
|
||
if (!(await syncSubscription(subscriptionId))) return { skipped: 'Abo ohne Kundenzuordnung' }
|
||
await updateDocument(config.collections.subscriptions, subscriptionId, data)
|
||
}
|
||
return { recorded: true, subscriptionId, units: data.storagePaidUnits, periodEnd: data.storagePaidPeriodEnd }
|
||
}
|