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]>
82 lines
3.6 KiB
TypeScript
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;
|
|
}
|