Files
Wabenhain/src/lib/sponsorship-subscription/account.ts
T
Nick SzaunigandClaude Opus 5.5 f227aeb8f9 feat: Bienenpatenschaft mit Urkunde, Abo, Medaillen und 3D-Konto
Setzt Teilprojekte 1-5 der Spec 2026-10-16 um:
- Urkunden-Engine (lib/certificate): ein Layout fuer PDF, Vorschau und
  3D-Textur, Vorlage classic@1, eingebettete OFL-Schriften, Testmatrix
- Einmal-Patenschaft ueber Traegerprodukte (Kategorie PATENSCHAFT),
  Paket wird im Checkout in Patenschaft (Regelsatz) und Honig aufgeteilt,
  Kontopflicht, rein digitale Bestellungen (FulfillmentMethod DIGITAL)
- Ausstellung nach Zahlung idempotent, Wiederholung im App-Prozess
  (instrumentation + background-jobs), Ablage in CERTIFICATE_DIR
- Verkaufsseite /patenschaft mit Konfigurator und Live-Vorschau,
  Warenkorb mit Schluessel je Position
- Kundenkonto: 3D-Ansicht von Urkunde und Medaille, Downloads, ZIP,
  Uebertragung per befristetem Einmal-Link, Treuestufen bis VIP
- Admin: Angebote, Patenschaften, Druck, Korrektur mit Revisionen,
  Abos, Erntefenster, Deckung, SEPA-Frist, Treuestufen
- Abo ueber Stripe Billing: Lieferung erst nach bezahlten Monaten,
  SEPA-Neukunden-Frist und Rueckbuchungsschutz, Honigreservierung,
  Kuendigungsbutton, Monatsrechnungen, Stripe-Einrichtungsskript

Startangebote werden inaktiv angelegt; Livebetrieb erst nach Freigabe.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 14:07:30 +02:00

82 lines
3.6 KiB
TypeScript

import { prisma } from "@/lib/prisma";
import type { SponsorshipCycle, SubscriptionStatus, DeliveryStatus } from "@/generated/prisma/client";
import { deliveryProgress } from "@/lib/sponsorship-subscription/entitlement";
import { contractReference } from "@/lib/sponsorship-subscription/cancellation";
/**
* Abo eines Kontos fuer die Kontoseite und den Kuendigungsknopf. Gefiltert
* nach customerId: ein fremdes Abo ist hier schlicht nicht vorhanden (404).
*/
export interface OwnedSubscriptionView {
id: string;
status: SubscriptionStatus;
offerName: string;
cycle: SponsorshipCycle | null;
/** Zuletzt eingezogener Monatsbetrag, sonst der Preis des Angebots. */
monthlyCents: number;
contractReference: string;
beeName: string | null;
sponsorshipId: string | null;
currentPeriodEnd: Date | null;
cancelAt: Date | null;
endedAt: Date | null;
paidMonths: number;
progress: { paid: number; required: number } | null;
/** Bezahlte Monate, die wegen der SEPA-Neukunden-Frist noch nicht zaehlen. */
heldPayments: number;
deliveries: { id: string; dueAt: Date; jars: number; status: DeliveryStatus }[];
stripeCustomerId: string | null;
customerEmail: string;
}
function beeNameOf(personalization: unknown): string | null {
const inner = (personalization as { personalization?: { beeName?: unknown } } | null)?.personalization;
return typeof inner?.beeName === "string" ? inner.beeName : null;
}
export async function getOwnedSubscription(customerId: string, id: string, now: Date = new Date()): Promise<OwnedSubscriptionView | null> {
const subscription = await prisma.sponsorshipSubscription.findFirst({
where: { id, customerId },
include: {
offer: { select: { name: true, cycle: true, priceCents: true, honeyJars: true } },
sponsorship: { select: { id: true, number: true } },
customer: { select: { email: true, stripeCustomerId: true } },
payments: {
orderBy: { paidAt: "asc" },
select: { id: true, paidAt: true, countsFrom: true, reversedAt: true, deliveryId: true, amountCents: true },
},
deliveries: { orderBy: { dueAt: "desc" }, select: { id: true, dueAt: true, jars: true, status: true } },
},
});
if (!subscription) return null;
const valid = subscription.payments.filter((p) => p.reversedAt === null);
const last = valid[valid.length - 1];
const hasHoney = subscription.offer.honeyJars > 0 && subscription.offer.cycle !== null;
return {
id: subscription.id,
status: subscription.status,
offerName: subscription.offer.name,
cycle: subscription.offer.cycle,
monthlyCents: last?.amountCents ?? subscription.offer.priceCents,
contractReference: contractReference(subscription),
beeName: beeNameOf(subscription.personalization),
sponsorshipId: subscription.sponsorship?.id ?? null,
currentPeriodEnd: subscription.currentPeriodEnd,
cancelAt: subscription.cancelAt,
endedAt: subscription.endedAt,
paidMonths: valid.length,
progress: hasHoney && subscription.offer.cycle ? deliveryProgress(subscription.offer.cycle, subscription.payments) : null,
heldPayments: valid.filter((p) => p.deliveryId === null && p.countsFrom > now).length,
deliveries: subscription.deliveries,
stripeCustomerId: subscription.customer.stripeCustomerId,
customerEmail: subscription.customer.email,
};
}
/** Kuendigung moeglich? Nur laufende Vertraege; ein gekuendigtes Abo zeigt stattdessen sein Ende. */
export function canCancel(view: Pick<OwnedSubscriptionView, "status" | "cancelAt">): boolean {
return (view.status === "AKTIV" || view.status === "IM_VERZUG") && view.cancelAt === null;
}