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

486 lines
18 KiB
TypeScript

// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import { getRlsExecutionContext } from "@hivegrid/db/rls-context.server";
import {
AdminBillingError,
extendTenantTrial,
listAdminBillingRows,
normalizeTrialExtensionDays,
requestManualBillingSync,
resolveExtendedTrialEnd,
setTenantBillingPlanSeats,
type AdminBillingDeps,
} from "./billing-admin.server";
function createDeps(overrides: Partial<AdminBillingDeps> = {}): AdminBillingDeps {
const tx = {
$queryRaw: vi.fn(async () => []),
tenantSubscription: {
upsert: vi.fn(async () => ({ id: "sub_1" })),
},
tenantSubscriptionProduct: {
updateMany: vi.fn(async () => ({ count: 0 })),
findFirst: vi.fn(async () => null),
update: vi.fn(async () => ({})),
create: vi.fn(async () => ({})),
},
portalDomainHostnameClaim: {
findMany: vi
.fn()
.mockResolvedValueOnce([{ id: "claim_1" }])
.mockResolvedValueOnce([]),
findFirst: vi.fn(async () => null),
updateMany: vi.fn(async () => ({ count: 0 })),
},
portalCustomDomain: {
updateMany: vi.fn(async () => ({ count: 0 })),
},
tenantProductAcquisition: {
findMany: vi.fn(async () => []),
},
outboxEvent: {
create: vi.fn(),
findUnique: vi.fn(async () => null),
},
};
const db = {
tenant: {
findMany: vi.fn(async () => [{ id: "tenant_1", name: "Acme", slug: "acme" }]),
findUnique: vi.fn(async () => ({ id: "tenant_1", name: "Acme", slug: "acme" })),
},
tenantSubscription: {
findUnique: vi.fn(async () => ({
id: "sub_1",
status: "past_due",
trialEndsAt: new Date("2026-07-01T00:00:00.000Z"),
pastDueSince: new Date("2026-07-02T00:00:00.000Z"),
frozenAt: null,
})),
upsert: vi.fn(async () => ({ id: "sub_1" })),
},
subscriptionProduct: {
findFirst: vi.fn(async () => ({ id: "product_1" })),
},
$transaction: vi.fn(async (callback) => callback(tx)),
__tx: tx,
};
return {
db: db as never,
resolveBillingState: vi.fn(async () => ({
tenantId: "tenant_1",
status: "active",
readOnly: false,
planCode: "starter",
seatQuantity: 3,
lagoCustomerId: "lago_1",
mollieCustomerId: "mollie_1",
lastInvoiceExternalId: "inv_1",
lastPaymentExternalId: "pay_1",
trialStartsAt: null,
trialEndsAt: null,
pastDueSince: null,
frozenAt: null,
cancelledAt: null,
currentPeriodStart: new Date("2026-07-01T00:00:00.000Z"),
currentPeriodEnd: new Date("2026-08-01T00:00:00.000Z"),
entitlements: [
{
featureKey: "crm",
title: "CRM",
enabled: true,
limitType: 1,
limit: 1,
quotaValue: null,
accumulate: false,
sourceProductId: "product_1",
sourcePlanCode: "starter",
},
],
})),
resolveBillingStates: vi.fn(async () => [
{
tenantId: "tenant_1",
status: "active",
readOnly: false,
planCode: "starter",
seatQuantity: 3,
lagoCustomerId: "lago_1",
mollieCustomerId: "mollie_1",
lastInvoiceExternalId: "inv_1",
lastPaymentExternalId: "pay_1",
trialStartsAt: null,
trialEndsAt: null,
pastDueSince: null,
frozenAt: null,
cancelledAt: null,
currentPeriodStart: new Date("2026-07-01T00:00:00.000Z"),
currentPeriodEnd: new Date("2026-08-01T00:00:00.000Z"),
entitlements: [
{
featureKey: "crm",
title: "CRM",
enabled: true,
limitType: 1,
limit: 1,
quotaValue: null,
accumulate: false,
sourceProductId: "product_1",
sourcePlanCode: "starter",
},
],
},
]),
invalidateModuleCache: vi.fn(),
clearSubscriptionCache: vi.fn(),
recordAction: vi.fn(async () => undefined),
lagoGateway: {
createCustomer: vi.fn(),
getCustomer: vi.fn(async () => ({
provider: "lago",
externalId: "tenant_1",
providerCustomerId: "lago_1",
name: "Acme",
email: "billing@example.test",
currency: "EUR",
createdAt: "2026-07-01T00:00:00Z",
})),
createSubscription: vi.fn(),
getSubscription: vi.fn(),
listInvoices: vi.fn(async () => [
{
provider: "lago",
providerInvoiceId: "invoice_1",
number: "HIV-001",
externalCustomerId: "tenant_1",
status: "finalized",
paymentStatus: "pending",
invoiceType: "one_off",
currency: "EUR",
totalAmountCents: 11900,
issuingDate: "2026-07-04",
fileUrl: "https://files.example.test/invoice_1.pdf",
},
]),
downloadInvoice: vi.fn(),
},
mollieGateway: {
createPayment: vi.fn(),
getPayment: vi.fn(async () => ({
provider: "mollie",
providerPaymentId: "pay_1",
externalId: "pay_1",
status: "paid",
amountCents: 11900,
amountRefundedCents: null,
amountRemainingCents: 0,
currency: "EUR",
checkoutUrl: null,
paidAt: "2026-07-04T00:00:00Z",
expiresAt: null,
metadata: {
tenantId: "tenant_1",
invoiceExternalId: "inv_1",
paymentExternalId: "pay_1",
lagoCustomerId: null,
lagoSubscriptionId: null,
planCode: null,
seatQuantity: null,
},
})),
},
applyLagoSync: vi.fn(async (input) => ({
tenantId: input.tenantId,
provider: "lago",
idempotencyKey: input.eventKey,
replayed: false,
status: "active",
})),
applyMollieSync: vi.fn(async (input) => ({
tenantId: input.tenantId,
provider: "mollie",
idempotencyKey: input.eventKey,
replayed: false,
status: "active",
})),
now: () => new Date("2026-07-04T00:00:00.000Z"),
...overrides,
};
}
describe("listAdminBillingRows", () => {
it("maps tenants and billing state into loader rows", async () => {
const deps = createDeps();
vi.mocked(deps.resolveBillingState).mockImplementationOnce(async (tenantId) => {
expect(getRlsExecutionContext()?.tenantId).toBe(tenantId);
return createDeps().resolveBillingState(tenantId);
});
await expect(listAdminBillingRows(deps)).resolves.toMatchObject([
{
tenantId: "tenant_1",
tenantName: "Acme",
planCode: "starter",
seatQuantity: 3,
entitlementCount: 1,
currentPeriodStart: "2026-07-01T00:00:00.000Z",
},
]);
expect(deps.resolveBillingStates).not.toHaveBeenCalled();
expect(deps.resolveBillingState).toHaveBeenCalledWith("tenant_1");
});
it("loads cross-tenant billing details in one isolated context per tenant", async () => {
const deps = createDeps();
vi.mocked(deps.db.tenant.findMany).mockResolvedValue([
{ id: "tenant_1", name: "Acme", slug: "acme" },
{ id: "tenant_2", name: "Beta", slug: "beta" },
] as never);
const baseState = await createDeps().resolveBillingState("tenant_1");
const observedTenantIds: Array<string | undefined> = [];
vi.mocked(deps.resolveBillingState).mockImplementation(async (tenantId) => {
observedTenantIds.push(getRlsExecutionContext()?.tenantId);
return { ...baseState, tenantId };
});
const rows = await listAdminBillingRows(deps);
expect(rows.map((row) => row.tenantId)).toEqual(["tenant_1", "tenant_2"]);
expect(observedTenantIds).toEqual(["tenant_1", "tenant_2"]);
});
});
describe("setTenantBillingPlanSeats", () => {
it("sets plan/seats, attaches matching catalog product, invalidates caches, and audits the reason", async () => {
const deps = createDeps();
vi.mocked(deps.resolveBillingState).mockImplementationOnce(async (tenantId) => {
expect(getRlsExecutionContext()?.tenantId).toBe("tenant_1");
return createDeps().resolveBillingState(tenantId);
});
await setTenantBillingPlanSeats(
{
tenantId: "tenant_1",
planCode: "starter",
seatQuantity: "5",
reason: "Sonderkunde",
actorUserId: "user_1",
actorEmail: "ops@hivegrid.de",
},
deps
);
expect(deps.db.tenant.findUnique).toHaveBeenCalledWith({ where: { id: "tenant_1" }, select: { id: true, name: true, slug: true } });
expect(deps.db.subscriptionProduct.findFirst).toHaveBeenCalled();
const transaction = (
deps.db as never as {
__tx: {
$queryRaw: ReturnType<typeof vi.fn>;
tenantSubscription: { upsert: ReturnType<typeof vi.fn> };
tenantSubscriptionProduct: { create: ReturnType<typeof vi.fn> };
};
}
).__tx;
const rawCalls = transaction.$queryRaw.mock.calls as Array<[TemplateStringsArray, ...unknown[]]>;
expect(rawCalls[0]?.[0].join("?")).toContain("hashtextextended('portal-domain-billing:' || ? || ':' || ?, 26003)");
expect(rawCalls[0]?.slice(1)).toEqual(["tenant_1", "claim_1"]);
expect(rawCalls[1]?.[0].join("?")).toContain('FROM "PortalCustomDomain"');
expect(rawCalls[2]?.[0].join("?")).toContain('FROM "PortalDomainHostnameClaim"');
expect(transaction.$queryRaw.mock.invocationCallOrder[2]).toBeLessThan(
transaction.tenantSubscription.upsert.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY
);
expect(transaction.tenantSubscription.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({ planCode: "starter", seatQuantity: 5, status: "active" }),
update: expect.objectContaining({ planCode: "starter", seatQuantity: 5, status: "active" }),
})
);
expect(transaction.tenantSubscriptionProduct.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ subscriptionProductId: "product_1", planCode: "starter", seatQuantity: 5, quantity: 5 }),
})
);
expect(deps.clearSubscriptionCache).toHaveBeenCalledWith("tenant_1");
expect(deps.invalidateModuleCache).toHaveBeenCalledWith("tenant_1");
expect(deps.recordAction).toHaveBeenCalledWith(expect.objectContaining({ action: "subscription.overridden", reason: "Sonderkunde" }));
});
it("rejects invalid seats before writing", async () => {
const deps = createDeps();
await expect(
setTenantBillingPlanSeats({ tenantId: "tenant_1", planCode: "starter", seatQuantity: "0", reason: "Sonderkunde", actorUserId: "user_1" }, deps)
).rejects.toMatchObject({ code: "INVALID_SEATS" });
expect(deps.db.$transaction).not.toHaveBeenCalled();
});
});
describe("requestManualBillingSync", () => {
it("refreshes Lago state directly and audits it", async () => {
const deps = createDeps();
vi.mocked(deps.resolveBillingState).mockImplementationOnce(async (tenantId) => {
expect(getRlsExecutionContext()?.tenantId).toBe("tenant_1");
return createDeps().resolveBillingState(tenantId);
});
await requestManualBillingSync({ tenantId: "tenant_1", provider: "lago", actorUserId: "user_1" }, deps);
expect(deps.lagoGateway?.getCustomer).toHaveBeenCalledWith("tenant_1");
expect(deps.lagoGateway?.listInvoices).toHaveBeenCalledWith({ tenantId: "tenant_1", perPage: 1 });
expect(deps.applyLagoSync).toHaveBeenCalledWith(
expect.objectContaining({
tenantId: "tenant_1",
eventKey: "admin:lago:tenant_1:2026-07-04T00:00:00.000Z",
lagoCustomerId: "lago_1",
invoiceExternalId: "invoice_1",
planCode: "starter",
seatQuantity: 3,
status: "active",
})
);
expect(deps.clearSubscriptionCache).toHaveBeenCalledWith("tenant_1");
expect(deps.invalidateModuleCache).toHaveBeenCalledWith("tenant_1");
expect(deps.recordAction).toHaveBeenCalledWith(expect.objectContaining({ action: "subscription.syncRequested", reason: "manual-lago-sync" }));
});
it("refreshes Mollie payment state directly and audits it", async () => {
const deps = createDeps();
await requestManualBillingSync({ tenantId: "tenant_1", provider: "mollie", actorUserId: "user_1" }, deps);
expect(deps.mollieGateway?.getPayment).toHaveBeenCalledWith("pay_1");
expect(deps.applyMollieSync).toHaveBeenCalledWith(
expect.objectContaining({
tenantId: "tenant_1",
eventKey: "admin:mollie:tenant_1:pay_1:2026-07-04T00:00:00.000Z",
mollieCustomerId: "mollie_1",
paymentExternalId: "pay_1",
invoiceExternalId: "inv_1",
status: "paid",
})
);
expect(deps.recordAction).toHaveBeenCalledWith(expect.objectContaining({ action: "subscription.syncRequested", reason: "manual-mollie-sync" }));
});
it("requires payment references before requesting Mollie sync", async () => {
const deps = createDeps({
resolveBillingState: vi.fn(async () => ({
...(await createDeps().resolveBillingState("tenant_1")),
lastInvoiceExternalId: null,
lastPaymentExternalId: null,
})),
});
await expect(requestManualBillingSync({ tenantId: "tenant_1", provider: "mollie", actorUserId: "user_1" }, deps)).rejects.toBeInstanceOf(AdminBillingError);
expect(deps.db.$transaction).not.toHaveBeenCalled();
expect(deps.mollieGateway?.getPayment).not.toHaveBeenCalled();
});
});
describe("extendTenantTrial", () => {
it("extends from the future trial end, clears dunning state, and audits the reason", async () => {
const deps = createDeps();
const db = deps.db as unknown as {
tenantSubscription: { findUnique: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> };
};
// Current trial ends 2026-07-10, "now" is 2026-07-04 -> extension bases on the trial end.
db.tenantSubscription.findUnique.mockResolvedValueOnce({
id: "sub_1",
status: "trial",
trialEndsAt: new Date("2026-07-10T00:00:00.000Z"),
pastDueSince: null,
frozenAt: null,
});
await extendTenantTrial({ tenantId: "tenant_1", days: 14, reason: "Kunde testet noch", actorUserId: "admin_1", actorEmail: "op@example.test" }, deps);
expect(db.tenantSubscription.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { tenantId: "tenant_1" },
update: expect.objectContaining({
status: "trial",
trialEndsAt: new Date("2026-07-24T00:00:00.000Z"),
pastDueSince: null,
frozenAt: null,
}),
})
);
expect(deps.clearSubscriptionCache).toHaveBeenCalledWith("tenant_1");
expect(deps.invalidateModuleCache).toHaveBeenCalledWith("tenant_1");
expect(deps.recordAction).toHaveBeenCalledWith(
expect.objectContaining({
action: "tenant.trialExtended",
targetId: "tenant_1",
reason: "Kunde testet noch",
after: expect.objectContaining({ status: "trial", extendedByDays: 14 }),
})
);
});
it("bases the extension on now when the previous trial already expired (never backwards)", async () => {
const deps = createDeps();
const db = deps.db as unknown as {
tenantSubscription: { findUnique: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> };
};
db.tenantSubscription.findUnique.mockResolvedValueOnce({
id: "sub_1",
status: "past_due",
trialEndsAt: new Date("2026-06-01T00:00:00.000Z"),
pastDueSince: new Date("2026-06-15T00:00:00.000Z"),
frozenAt: new Date("2026-06-20T00:00:00.000Z"),
});
await extendTenantTrial({ tenantId: "tenant_1", days: 7, reason: "Zahlung geklaert", actorUserId: "admin_1" }, deps);
expect(db.tenantSubscription.upsert).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
trialEndsAt: new Date("2026-07-11T00:00:00.000Z"),
pastDueSince: null,
frozenAt: null,
}),
})
);
});
it("rejects out-of-range day counts before writing", async () => {
const deps = createDeps();
await expect(extendTenantTrial({ tenantId: "tenant_1", days: 0, reason: "Kunde testet noch", actorUserId: "admin_1" }, deps)).rejects.toMatchObject({
code: "INVALID_TRIAL_DAYS",
});
await expect(extendTenantTrial({ tenantId: "tenant_1", days: 366, reason: "Kunde testet noch", actorUserId: "admin_1" }, deps)).rejects.toMatchObject({
code: "INVALID_TRIAL_DAYS",
});
const db = deps.db as unknown as { tenantSubscription: { upsert: ReturnType<typeof vi.fn> } };
expect(db.tenantSubscription.upsert).not.toHaveBeenCalled();
expect(deps.recordAction).not.toHaveBeenCalled();
});
it("binds the tenant RLS context for the whole write", async () => {
const deps = createDeps();
let observedTenantId: string | null | undefined;
const db = deps.db as unknown as { tenantSubscription: { upsert: ReturnType<typeof vi.fn> } };
db.tenantSubscription.upsert.mockImplementationOnce(async () => {
observedTenantId = getRlsExecutionContext()?.tenantId;
return { id: "sub_1" };
});
await extendTenantTrial({ tenantId: "tenant_1", days: 3, reason: "Kunde testet noch", actorUserId: "admin_1" }, deps);
expect(observedTenantId).toBe("tenant_1");
});
});
describe("trial extension helpers", () => {
it("normalizes numeric strings and rejects garbage", () => {
expect(normalizeTrialExtensionDays("30")).toBe(30);
expect(() => normalizeTrialExtensionDays("abc")).toThrow(AdminBillingError);
expect(() => normalizeTrialExtensionDays(2.5)).toThrow(AdminBillingError);
});
it("never moves the trial end backwards", () => {
const now = new Date("2026-07-04T00:00:00.000Z");
expect(resolveExtendedTrialEnd(new Date("2026-01-01T00:00:00.000Z"), now, 1)).toEqual(new Date("2026-07-05T00:00:00.000Z"));
expect(resolveExtendedTrialEnd(null, now, 1)).toEqual(new Date("2026-07-05T00:00:00.000Z"));
});
});