463 lines
17 KiB
TypeScript
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,
|
|
});
|
|
}
|