Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/scripts/billing-catalog/catalog.ts
T

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 } : {}),
},
};
}