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]>
This commit is contained in:
1 parent
dcb578f062
commit
f227aeb8f9
269 files changed
+28054
-141
No files matched your search
@@ -0,0 +1,81 @@
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user