Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/services/admin/billing-admin.server.ts
T

463 lines
17 KiB
TypeScript

import { createLagoBillingGateway, createMolliePaymentGateway, type BillingGateway, type PaymentGateway } from "@hivegrid/billing";
import { loadWebConfig } from "@hivegrid/config";
import { resolveTenantBillingState, resolveTenantBillingStates } from "@hivegrid/db/billing-domain.server";
import { applyLagoSubscriptionSync, applyMolliePaymentSync } from "@hivegrid/db/billing-sync.server";
import {
acquirePortalDomainBillingActivationLocks,
reactivateEntitledPortalDomains,
type PortalDomainBillingFenceClient,
} from "@hivegrid/db/portal-domain-billing-fence.server";
import type { Prisma } from "@hivegrid/db/prisma-browser";
import { runWithRlsContext } from "@hivegrid/db/rls-context.server";
import type { AdminBillingInputRow } from "@hivegrid/view-models";
import { db } from "~/utils/db.server";
import { invalidateTenantModuleAccessCache } from "~/utils/connector/tenantConnector.server";
import { clearTenantSubscriptionCache } from "~/utils/services/.server/subscriptionService";
import { assertAdminActionReason, recordAdminAction, type AdminActionAuditContext } from "./admin-action-audit.server";
export type BillingSyncProvider = "lago" | "mollie";
export class AdminBillingError extends Error {
override readonly name = "AdminBillingError";
readonly i18nKey: string;
constructor(readonly code: "TENANT_NOT_FOUND" | "MISSING_PROVIDER_REFERENCE" | "INVALID_PLAN" | "INVALID_SEATS" | "INVALID_TRIAL_DAYS") {
super(code);
this.i18nKey = `admin.billing.errors.${code}`;
}
}
type TenantSummary = {
id: string;
name: string;
slug: string;
};
export type AdminBillingDeps = {
readonly db: typeof db;
readonly resolveBillingState: typeof resolveTenantBillingState;
readonly resolveBillingStates: typeof resolveTenantBillingStates;
readonly invalidateModuleCache: (tenantId: string) => void;
readonly clearSubscriptionCache: (tenantId: string) => void;
readonly recordAction: (context: AdminActionAuditContext) => Promise<void>;
readonly lagoGateway: BillingGateway | null;
readonly mollieGateway: PaymentGateway | null;
readonly applyLagoSync: typeof applyLagoSubscriptionSync;
readonly applyMollieSync: typeof applyMolliePaymentSync;
readonly now: () => Date;
};
function createConfiguredLagoGateway(): BillingGateway | null {
const config = loadWebConfig().config.lago;
if (!config.apiUrl || !config.apiKey) return null;
return createLagoBillingGateway({
apiUrl: config.apiUrl,
apiKey: config.apiKey,
timeoutMs: config.timeoutMs,
});
}
function createConfiguredMollieGateway(): PaymentGateway | null {
const config = loadWebConfig().config.mollie;
if (!config.apiKey) return null;
return createMolliePaymentGateway({
apiKey: config.apiKey,
profileId: config.profileId,
timeoutMs: config.timeoutMs,
});
}
function defaultDeps(): AdminBillingDeps {
return {
db,
resolveBillingState: resolveTenantBillingState,
resolveBillingStates: resolveTenantBillingStates,
invalidateModuleCache: invalidateTenantModuleAccessCache,
clearSubscriptionCache: clearTenantSubscriptionCache,
recordAction: recordAdminAction,
lagoGateway: createConfiguredLagoGateway(),
mollieGateway: createConfiguredMollieGateway(),
applyLagoSync: applyLagoSubscriptionSync,
applyMollieSync: applyMolliePaymentSync,
now: () => new Date(),
};
}
function dateToIso(value: Date | null | undefined): string | null {
return value ? value.toISOString() : null;
}
function requireTenant(tenant: TenantSummary | null): TenantSummary {
if (!tenant) throw new AdminBillingError("TENANT_NOT_FOUND");
return tenant;
}
function normalizePlanCode(value: string | null | undefined): string {
const planCode = (value ?? "").trim();
if (planCode.length === 0) throw new AdminBillingError("INVALID_PLAN");
return planCode;
}
function normalizeSeatQuantity(value: number | string | null | undefined): number {
const seatQuantity = typeof value === "number" ? value : Number.parseInt(value ?? "", 10);
if (!Number.isInteger(seatQuantity) || seatQuantity < 1 || seatQuantity > 10_000) {
throw new AdminBillingError("INVALID_SEATS");
}
return seatQuantity;
}
export async function listAdminBillingRows(deps: AdminBillingDeps = defaultDeps()): Promise<AdminBillingInputRow[]> {
const tenants = await deps.db.tenant.findMany({
orderBy: { name: "asc" },
take: 100,
select: { id: true, name: true, slug: true },
});
const billingStates = await Promise.all(tenants.map((tenant) => runWithRlsContext({ tenantId: tenant.id }, () => deps.resolveBillingState(tenant.id))));
const billingStatesByTenantId = new Map(billingStates.map((state) => [state.tenantId, state]));
return tenants.map((tenant) => {
const state = billingStatesByTenantId.get(tenant.id);
if (!state) throw new AdminBillingError("TENANT_NOT_FOUND");
return {
tenantId: tenant.id,
tenantName: tenant.name,
tenantSlug: tenant.slug,
status: state.status,
readOnly: state.readOnly,
planCode: state.planCode,
seatQuantity: state.seatQuantity,
lagoCustomerId: state.lagoCustomerId,
mollieCustomerId: state.mollieCustomerId,
lastInvoiceExternalId: state.lastInvoiceExternalId,
lastPaymentExternalId: state.lastPaymentExternalId,
currentPeriodStart: dateToIso(state.currentPeriodStart),
currentPeriodEnd: dateToIso(state.currentPeriodEnd),
pastDueSince: dateToIso(state.pastDueSince),
frozenAt: dateToIso(state.frozenAt),
cancelledAt: dateToIso(state.cancelledAt),
entitlementCount: state.entitlements.length,
};
});
}
export type SetTenantBillingPlanSeatsInput = {
readonly tenantId: string;
readonly planCode: string;
readonly seatQuantity: number | string;
readonly reason: string;
readonly actorUserId: string | null;
readonly actorEmail?: string | null;
readonly request?: Request;
};
type SubscriptionProductMatch = { id: string } | null;
async function findPlanProduct(planCode: string, deps: AdminBillingDeps): Promise<SubscriptionProductMatch> {
return await deps.db.subscriptionProduct.findFirst({
where: {
OR: [{ id: planCode }, { code: planCode }, { lagoPlanCode: planCode }],
},
select: { id: true },
});
}
async function attachMatchingProduct(
input: { subscriptionId: string; product: { id: string }; planCode: string; seatQuantity: number; now: Date },
tx: Prisma.TransactionClient
): Promise<void> {
await tx.tenantSubscriptionProduct.updateMany({
where: {
tenantSubscriptionId: input.subscriptionId,
subscriptionProductId: { not: input.product.id },
OR: [{ endsAt: null }, { endsAt: { gt: input.now } }],
},
data: {
cancelledAt: input.now,
endsAt: input.now,
},
});
const existing = await tx.tenantSubscriptionProduct.findFirst({
where: {
tenantSubscriptionId: input.subscriptionId,
subscriptionProductId: input.product.id,
},
select: { id: true },
});
if (existing) {
await tx.tenantSubscriptionProduct.update({
where: { id: existing.id },
data: {
planCode: input.planCode,
seatQuantity: input.seatQuantity,
quantity: input.seatQuantity,
cancelledAt: null,
endsAt: null,
},
});
return;
}
await tx.tenantSubscriptionProduct.create({
data: {
tenantSubscriptionId: input.subscriptionId,
subscriptionProductId: input.product.id,
planCode: input.planCode,
seatQuantity: input.seatQuantity,
quantity: input.seatQuantity,
cancelledAt: null,
endsAt: null,
},
});
}
export async function setTenantBillingPlanSeats(input: SetTenantBillingPlanSeatsInput, deps: AdminBillingDeps = defaultDeps()): Promise<void> {
return runWithRlsContext({ tenantId: input.tenantId }, () => setTenantBillingPlanSeatsInContext(input, deps));
}
async function setTenantBillingPlanSeatsInContext(input: SetTenantBillingPlanSeatsInput, deps: AdminBillingDeps): Promise<void> {
const reason = assertAdminActionReason(input.reason);
const planCode = normalizePlanCode(input.planCode);
const seatQuantity = normalizeSeatQuantity(input.seatQuantity);
const now = deps.now();
const tenant = requireTenant(
await deps.db.tenant.findUnique({
where: { id: input.tenantId },
select: { id: true, name: true, slug: true },
})
);
const before = await deps.resolveBillingState(tenant.id);
const product = await findPlanProduct(planCode, deps);
await deps.db.$transaction(async (tx) => {
const billingFenceClient = tx as unknown as PortalDomainBillingFenceClient;
await acquirePortalDomainBillingActivationLocks(billingFenceClient, { tenantId: tenant.id });
const subscription = await tx.tenantSubscription.upsert({
where: { tenantId: tenant.id },
create: {
tenantId: tenant.id,
planCode,
seatQuantity,
status: "active",
},
update: {
planCode,
seatQuantity,
status: "active",
frozenAt: null,
cancelledAt: null,
},
select: { id: true },
});
if (product) {
await attachMatchingProduct({ subscriptionId: subscription.id, product, planCode, seatQuantity, now }, tx);
}
await reactivateEntitledPortalDomains(billingFenceClient, { tenantId: tenant.id, now });
});
deps.clearSubscriptionCache(tenant.id);
deps.invalidateModuleCache(tenant.id);
await deps.recordAction({
action: "subscription.overridden",
actorUserId: input.actorUserId,
actorEmail: input.actorEmail ?? null,
targetKind: "tenant",
targetId: tenant.id,
reason,
before: { planCode: before.planCode, seatQuantity: before.seatQuantity, status: before.status },
after: { planCode, seatQuantity, status: "active", catalogProductAttached: Boolean(product) },
request: input.request,
});
}
/** Bounds for a single operator-issued trial extension. */
export const TRIAL_EXTENSION_MIN_DAYS = 1;
export const TRIAL_EXTENSION_MAX_DAYS = 365;
export function normalizeTrialExtensionDays(value: number | string | null | undefined): number {
const days = typeof value === "number" ? value : Number.parseInt(value ?? "", 10);
if (!Number.isInteger(days) || days < TRIAL_EXTENSION_MIN_DAYS || days > TRIAL_EXTENSION_MAX_DAYS) {
throw new AdminBillingError("INVALID_TRIAL_DAYS");
}
return days;
}
/**
* Pure date rule for a trial extension: extends from the current trial end
* when it is still in the future, otherwise from now — an extension can
* therefore never move a trial end backwards.
*/
export function resolveExtendedTrialEnd(currentTrialEnd: Date | null | undefined, now: Date, days: number): Date {
const base = currentTrialEnd && currentTrialEnd.getTime() > now.getTime() ? currentTrialEnd : now;
return new Date(base.getTime() + days * 24 * 60 * 60 * 1000);
}
export type ExtendTenantTrialInput = {
readonly tenantId: string;
readonly days: number | string;
readonly reason: string;
readonly actorUserId: string | null;
readonly actorEmail?: string | null;
readonly request?: Request;
};
/**
* Extends a tenant's trial (DoD #3): reason-gated, 1-365 days, never moves
* the trial end backwards, clears past_due/frozen dunning state and puts
* the subscription back onto `trial`. Audited as `tenant.trialExtended`.
*/
export async function extendTenantTrial(input: ExtendTenantTrialInput, deps: AdminBillingDeps = defaultDeps()): Promise<void> {
return runWithRlsContext({ tenantId: input.tenantId }, () => extendTenantTrialInContext(input, deps));
}
async function extendTenantTrialInContext(input: ExtendTenantTrialInput, deps: AdminBillingDeps): Promise<void> {
const reason = assertAdminActionReason(input.reason);
const days = normalizeTrialExtensionDays(input.days);
const now = deps.now();
const tenant = requireTenant(
await deps.db.tenant.findUnique({
where: { id: input.tenantId },
select: { id: true, name: true, slug: true },
})
);
const existing = await deps.db.tenantSubscription.findUnique({
where: { tenantId: tenant.id },
select: { id: true, status: true, trialEndsAt: true, pastDueSince: true, frozenAt: true },
});
const trialEndsAt = resolveExtendedTrialEnd(existing?.trialEndsAt, now, days);
await deps.db.tenantSubscription.upsert({
where: { tenantId: tenant.id },
create: {
tenantId: tenant.id,
status: "trial",
trialEndsAt,
},
update: {
status: "trial",
trialEndsAt,
pastDueSince: null,
frozenAt: null,
},
});
deps.clearSubscriptionCache(tenant.id);
deps.invalidateModuleCache(tenant.id);
await deps.recordAction({
action: "tenant.trialExtended",
actorUserId: input.actorUserId,
actorEmail: input.actorEmail ?? null,
targetKind: "tenant",
targetId: tenant.id,
reason,
before: {
status: existing?.status ?? null,
trialEndsAt: existing?.trialEndsAt?.toISOString() ?? null,
pastDueSince: existing?.pastDueSince?.toISOString() ?? null,
frozenAt: existing?.frozenAt?.toISOString() ?? null,
},
after: { status: "trial", trialEndsAt: trialEndsAt.toISOString(), extendedByDays: days },
request: input.request,
});
}
export type RequestManualBillingSyncInput = {
readonly tenantId: string;
readonly provider: BillingSyncProvider;
readonly actorUserId: string | null;
readonly actorEmail?: string | null;
readonly request?: Request;
};
async function applyManualProviderSync(
input: {
provider: BillingSyncProvider;
tenantId: string;
state: Awaited<ReturnType<typeof resolveTenantBillingState>>;
now: Date;
},
deps: AdminBillingDeps
): Promise<Record<string, unknown>> {
if (input.provider === "lago") {
if (!deps.lagoGateway) throw new AdminBillingError("MISSING_PROVIDER_REFERENCE");
const [customer, invoices] = await Promise.all([
deps.lagoGateway.getCustomer(input.tenantId),
deps.lagoGateway.listInvoices({ tenantId: input.tenantId, perPage: 1 }),
]);
const latestInvoice = invoices[0] ?? null;
const result = await deps.applyLagoSync({
tenantId: input.tenantId,
eventKey: `admin:lago:${input.tenantId}:${input.now.toISOString()}`,
lagoCustomerId: customer?.providerCustomerId ?? input.state.lagoCustomerId,
status: input.state.status,
planCode: input.state.planCode,
seatQuantity: input.state.seatQuantity,
invoiceExternalId: latestInvoice?.providerInvoiceId ?? input.state.lastInvoiceExternalId,
receivedAt: input.now,
});
return {
provider: input.provider,
synced: true,
billingStatus: result.status,
invoiceExternalId: latestInvoice?.providerInvoiceId ?? input.state.lastInvoiceExternalId,
};
}
const providerPaymentId = input.state.lastPaymentExternalId;
const invoiceExternalId = input.state.lastInvoiceExternalId;
if (!providerPaymentId || !invoiceExternalId) throw new AdminBillingError("MISSING_PROVIDER_REFERENCE");
if (!deps.mollieGateway) throw new AdminBillingError("MISSING_PROVIDER_REFERENCE");
const payment = await deps.mollieGateway.getPayment(providerPaymentId);
if (!payment) throw new AdminBillingError("MISSING_PROVIDER_REFERENCE");
const result = await deps.applyMollieSync({
tenantId: input.tenantId,
eventKey: `admin:mollie:${input.tenantId}:${payment.providerPaymentId}:${input.now.toISOString()}`,
mollieCustomerId: input.state.mollieCustomerId,
paymentExternalId: payment.externalId ?? providerPaymentId,
invoiceExternalId: payment.metadata.invoiceExternalId ?? invoiceExternalId,
status: payment.status,
receivedAt: input.now,
});
return {
provider: input.provider,
synced: true,
billingStatus: result.status,
providerPaymentId: payment.providerPaymentId,
paymentStatus: payment.status,
};
}
export async function requestManualBillingSync(input: RequestManualBillingSyncInput, deps: AdminBillingDeps = defaultDeps()): Promise<void> {
return runWithRlsContext({ tenantId: input.tenantId }, () => requestManualBillingSyncInContext(input, deps));
}
async function requestManualBillingSyncInContext(input: RequestManualBillingSyncInput, deps: AdminBillingDeps): Promise<void> {
const tenant = requireTenant(
await deps.db.tenant.findUnique({
where: { id: input.tenantId },
select: { id: true, name: true, slug: true },
})
);
const state = await deps.resolveBillingState(tenant.id);
const syncResult = await applyManualProviderSync({ provider: input.provider, tenantId: tenant.id, state, now: deps.now() }, deps);
deps.clearSubscriptionCache(tenant.id);
deps.invalidateModuleCache(tenant.id);
await deps.recordAction({
action: "subscription.syncRequested",
actorUserId: input.actorUserId,
actorEmail: input.actorEmail ?? null,
targetKind: "tenant",
targetId: tenant.id,
reason: `manual-${input.provider}-sync`,
after: syncResult,
request: input.request,
});
}