319 lines
10 KiB
TypeScript
319 lines
10 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
|
|
import { emailMarketingCommercialProducts, hiveGridBasePlans, hiveGridBillingCatalogVersion, hiveGridStorageAddOns, hiveGridVat } from "@hivegrid/entitlements";
|
|
import { z } from "zod";
|
|
|
|
const CURRENCY = "EUR";
|
|
|
|
export const billingCatalogResourceKinds = Object.freeze({
|
|
basePlan: "base_plan",
|
|
emailMarketingAddOn: "email_marketing_add_on",
|
|
storageAddOn: "storage_add_on",
|
|
} as const);
|
|
|
|
export type BillingCatalogResourceKind = (typeof billingCatalogResourceKinds)[keyof typeof billingCatalogResourceKinds];
|
|
|
|
export type DesiredLagoTax = {
|
|
readonly code: string;
|
|
readonly name: string;
|
|
readonly description: string;
|
|
readonly rate: number;
|
|
readonly appliedToOrganization: boolean;
|
|
};
|
|
|
|
export type DesiredLagoPlan = {
|
|
readonly code: string;
|
|
readonly name: string;
|
|
readonly invoiceDisplayName: string;
|
|
readonly description: string;
|
|
readonly interval: "monthly";
|
|
readonly amountCents: number;
|
|
readonly amountCurrency: typeof CURRENCY;
|
|
readonly payInAdvance: true;
|
|
readonly taxCodes: readonly string[];
|
|
readonly metadata: Readonly<Record<string, string>>;
|
|
};
|
|
|
|
export type DesiredLagoCatalog = {
|
|
readonly version: string;
|
|
readonly digestSha256: string;
|
|
readonly tax: DesiredLagoTax;
|
|
readonly plans: readonly DesiredLagoPlan[];
|
|
};
|
|
|
|
function desiredPlan(input: {
|
|
code: string;
|
|
name: string;
|
|
description: string;
|
|
amountCents: number;
|
|
kind: BillingCatalogResourceKind;
|
|
metadata?: Readonly<Record<string, string>>;
|
|
}): DesiredLagoPlan {
|
|
return Object.freeze({
|
|
code: input.code,
|
|
name: input.name,
|
|
invoiceDisplayName: input.name,
|
|
description: `${input.description} · HiveGrid Katalog ${hiveGridBillingCatalogVersion}`,
|
|
interval: "monthly",
|
|
amountCents: input.amountCents,
|
|
amountCurrency: CURRENCY,
|
|
payInAdvance: true,
|
|
taxCodes: Object.freeze([hiveGridVat.code]),
|
|
metadata: Object.freeze({
|
|
hivegrid_catalog_version: hiveGridBillingCatalogVersion,
|
|
hivegrid_resource_kind: input.kind,
|
|
...input.metadata,
|
|
}),
|
|
});
|
|
}
|
|
|
|
function buildDesiredPlans(): readonly DesiredLagoPlan[] {
|
|
const basePlans = hiveGridBasePlans.flatMap((plan) =>
|
|
plan.variants.map((variant) =>
|
|
desiredPlan({
|
|
code: variant.code,
|
|
name: variant.contractMonths === 12 ? `${plan.publicName} · 12 Monate` : `${plan.publicName} · monatlich`,
|
|
description:
|
|
variant.contractMonths === 12 ? `${plan.publicName}, monatliche SEPA-Rate bei 12 Monaten Mindestlaufzeit` : `${plan.publicName}, monatlich kuendbar`,
|
|
amountCents: variant.baseAmountCents,
|
|
kind: billingCatalogResourceKinds.basePlan,
|
|
metadata: {
|
|
hivegrid_technical_plan_code: plan.technicalCode,
|
|
hivegrid_billing_cadence: variant.cadence,
|
|
hivegrid_contract_months: String(variant.contractMonths),
|
|
hivegrid_included_office_seats: String(plan.includedSeats.office),
|
|
hivegrid_included_field_seats: String(plan.includedSeats.field),
|
|
hivegrid_included_storage_gb: String(plan.includedStorageGb),
|
|
hivegrid_storage_per_additional_seat_gb: String(plan.storagePerAdditionalSeatGb),
|
|
hivegrid_extra_office_seat_cents: String(variant.extraSeatAmountCents.office),
|
|
hivegrid_extra_field_seat_cents: String(variant.extraSeatAmountCents.field),
|
|
},
|
|
})
|
|
)
|
|
);
|
|
const storagePlans = hiveGridStorageAddOns.map((addOn) =>
|
|
desiredPlan({
|
|
code: addOn.code,
|
|
name: addOn.publicName,
|
|
description: `${addOn.publicName}, festes wiederkehrendes Speicherpaket`,
|
|
amountCents: addOn.amountCents,
|
|
kind: billingCatalogResourceKinds.storageAddOn,
|
|
metadata: {
|
|
hivegrid_included_storage_gb: String(addOn.includedStorageGb),
|
|
hivegrid_billing_cadence: "monthly",
|
|
hivegrid_purchasable: String(addOn.purchasable),
|
|
},
|
|
})
|
|
);
|
|
const emailMarketingPlans = emailMarketingCommercialProducts.map((addOn) =>
|
|
desiredPlan({
|
|
code: addOn.code,
|
|
name: addOn.title,
|
|
description: `${addOn.title}, festes wiederkehrendes Monatskontingent`,
|
|
amountCents: addOn.amountCents!,
|
|
kind: billingCatalogResourceKinds.emailMarketingAddOn,
|
|
metadata: {
|
|
hivegrid_monthly_email_limit: String(addOn.monthlyEmailLimit),
|
|
hivegrid_billing_cadence: "monthly",
|
|
},
|
|
})
|
|
);
|
|
return Object.freeze([...basePlans, ...storagePlans, ...emailMarketingPlans].sort((left, right) => left.code.localeCompare(right.code)));
|
|
}
|
|
|
|
function stableJson(value: unknown): string {
|
|
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
if (value !== null && typeof value === "object") {
|
|
return `{${Object.entries(value as Record<string, unknown>)
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([key, nested]) => `${JSON.stringify(key)}:${stableJson(nested)}`)
|
|
.join(",")}}`;
|
|
}
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
export function buildDesiredLagoCatalog(): DesiredLagoCatalog {
|
|
const tax = Object.freeze({
|
|
code: hiveGridVat.code,
|
|
name: hiveGridVat.name,
|
|
description: `HiveGrid Deutschland · Katalog ${hiveGridBillingCatalogVersion}`,
|
|
rate: hiveGridVat.rateBasisPoints / 100,
|
|
appliedToOrganization: false,
|
|
});
|
|
const plans = buildDesiredPlans();
|
|
const digestInput = {
|
|
version: hiveGridBillingCatalogVersion,
|
|
tax,
|
|
plans,
|
|
};
|
|
return Object.freeze({
|
|
...digestInput,
|
|
digestSha256: createHash("sha256").update(stableJson(digestInput)).digest("hex"),
|
|
});
|
|
}
|
|
|
|
const lagoTaxSchema = z.object({
|
|
code: z.string(),
|
|
name: z.string(),
|
|
description: z.string().nullable().optional(),
|
|
rate: z.coerce.number(),
|
|
applied_to_organization: z.boolean(),
|
|
});
|
|
|
|
const lagoPlanSchema = z.object({
|
|
code: z.string(),
|
|
name: z.string(),
|
|
invoice_display_name: z.string().nullable().optional(),
|
|
description: z.string().nullable().optional(),
|
|
interval: z.string(),
|
|
amount_cents: z.number().int(),
|
|
amount_currency: z.string(),
|
|
pay_in_advance: z.boolean(),
|
|
taxes: z
|
|
.array(
|
|
z.object({
|
|
code: z.string(),
|
|
})
|
|
)
|
|
.default([]),
|
|
metadata: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
});
|
|
|
|
export const lagoTaxResponseSchema = z.object({ tax: lagoTaxSchema });
|
|
export const lagoPlanResponseSchema = z.object({ plan: lagoPlanSchema });
|
|
export const lagoOrganizationResponseSchema = z.object({
|
|
organization: z.object({ lago_id: z.string().min(1), name: z.string().min(1) }),
|
|
});
|
|
|
|
export type LagoTaxSnapshot = z.infer<typeof lagoTaxSchema>;
|
|
export type LagoPlanSnapshot = z.infer<typeof lagoPlanSchema>;
|
|
|
|
export type LagoCatalogSnapshot = {
|
|
readonly organization: {
|
|
readonly name: string;
|
|
readonly idSha256: string;
|
|
};
|
|
readonly tax: LagoTaxSnapshot | null;
|
|
readonly plans: readonly LagoPlanSnapshot[];
|
|
};
|
|
|
|
export type LagoCatalogAction = {
|
|
readonly resource: "tax" | "plan";
|
|
readonly code: string;
|
|
readonly action: "create" | "update" | "noop";
|
|
readonly driftFields: readonly string[];
|
|
};
|
|
|
|
export type LagoCatalogAssessment = {
|
|
readonly catalogVersion: string;
|
|
readonly catalogDigestSha256: string;
|
|
readonly complete: boolean;
|
|
readonly actions: readonly LagoCatalogAction[];
|
|
};
|
|
|
|
function normalizedMetadata(metadata: Record<string, unknown> | null | undefined): Record<string, string> {
|
|
return Object.fromEntries(
|
|
Object.entries(metadata ?? {})
|
|
.filter(([, value]) => typeof value === "string")
|
|
.map(([key, value]) => [key, value as string])
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
);
|
|
}
|
|
|
|
function differentFields(actual: Readonly<Record<string, unknown>>, desired: Readonly<Record<string, unknown>>): string[] {
|
|
return Object.keys(desired)
|
|
.filter((field) => stableJson(actual[field]) !== stableJson(desired[field]))
|
|
.sort();
|
|
}
|
|
|
|
export function assessLagoCatalog(snapshot: LagoCatalogSnapshot, desired = buildDesiredLagoCatalog()): LagoCatalogAssessment {
|
|
const taxDrift = snapshot.tax
|
|
? differentFields(
|
|
{
|
|
code: snapshot.tax.code,
|
|
name: snapshot.tax.name,
|
|
description: snapshot.tax.description ?? "",
|
|
rate: snapshot.tax.rate,
|
|
appliedToOrganization: snapshot.tax.applied_to_organization,
|
|
},
|
|
desired.tax
|
|
)
|
|
: [];
|
|
const actions: LagoCatalogAction[] = [
|
|
{
|
|
resource: "tax",
|
|
code: desired.tax.code,
|
|
action: snapshot.tax ? (taxDrift.length > 0 ? "update" : "noop") : "create",
|
|
driftFields: taxDrift,
|
|
},
|
|
];
|
|
|
|
for (const plan of desired.plans) {
|
|
const matches = snapshot.plans.filter((candidate) => candidate.code === plan.code);
|
|
if (matches.length > 1) {
|
|
throw new Error(`billing catalog: Lago returned duplicate plan code ${plan.code}`);
|
|
}
|
|
const actual = matches[0];
|
|
const driftFields = actual
|
|
? differentFields(
|
|
{
|
|
code: actual.code,
|
|
name: actual.name,
|
|
invoiceDisplayName: actual.invoice_display_name ?? "",
|
|
description: actual.description ?? "",
|
|
interval: actual.interval,
|
|
amountCents: actual.amount_cents,
|
|
amountCurrency: actual.amount_currency.toUpperCase(),
|
|
payInAdvance: actual.pay_in_advance,
|
|
taxCodes: actual.taxes.map((tax) => tax.code).sort(),
|
|
metadata: normalizedMetadata(actual.metadata),
|
|
},
|
|
plan
|
|
)
|
|
: [];
|
|
actions.push({
|
|
resource: "plan",
|
|
code: plan.code,
|
|
action: actual ? (driftFields.length > 0 ? "update" : "noop") : "create",
|
|
driftFields,
|
|
});
|
|
}
|
|
|
|
return Object.freeze({
|
|
catalogVersion: desired.version,
|
|
catalogDigestSha256: desired.digestSha256,
|
|
complete: actions.every((action) => action.action === "noop"),
|
|
actions: Object.freeze(actions),
|
|
});
|
|
}
|
|
|
|
export function toLagoTaxPayload(tax: DesiredLagoTax): Record<string, unknown> {
|
|
return {
|
|
tax: {
|
|
name: tax.name,
|
|
code: tax.code,
|
|
rate: tax.rate,
|
|
description: tax.description,
|
|
applied_to_organization: tax.appliedToOrganization,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function toLagoPlanPayload(plan: DesiredLagoPlan, update: boolean): Record<string, unknown> {
|
|
return {
|
|
plan: {
|
|
name: plan.name,
|
|
code: plan.code,
|
|
invoice_display_name: plan.invoiceDisplayName,
|
|
description: plan.description,
|
|
interval: plan.interval,
|
|
amount_cents: plan.amountCents,
|
|
amount_currency: plan.amountCurrency,
|
|
pay_in_advance: plan.payInAdvance,
|
|
tax_codes: plan.taxCodes,
|
|
metadata: plan.metadata,
|
|
...(update ? { cascade_updates: false } : {}),
|
|
},
|
|
};
|
|
}
|