486 lines
18 KiB
TypeScript
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"));
|
|
});
|
|
});
|