diff --git a/apps/agenstra/backend-agent-controller/project.json b/apps/agenstra/backend-agent-controller/project.json index 7b08c4053..bb18cc35b 100644 --- a/apps/agenstra/backend-agent-controller/project.json +++ b/apps/agenstra/backend-agent-controller/project.json @@ -173,12 +173,12 @@ "executor": "nx:run-commands", "outputs": ["{workspaceRoot}/dist/clients/agent-controller/js"], "options": { - "command": "npx @openapitools/openapi-generator-cli generate -i libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml -g typescript-axios -o dist/clients/agent-controller/js --additional-properties=npmName=@forepath/agenstra-agent-controller-client,npmVersion=1.0.0,withInterfaces=true && cd dist/clients/agent-controller/js && npm install && npm run build" + "command": "npx @openapitools/openapi-generator-cli generate -i libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml -g typescript-axios -o dist/clients/agent-controller/js --additional-properties=npmName=@forepath/agenstra-agent-controller-client,npmVersion=1.0.0,withInterfaces=true,axiosVersion=1.18.1 && cd dist/clients/agent-controller/js && npm install && npm run build" }, "configurations": { "test": {}, "release": { - "command": "npx @openapitools/openapi-generator-cli generate -i libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml -g typescript-axios -o dist/clients/agent-controller/js --additional-properties=npmName=@forepath/agenstra-agent-controller-client,npmVersion=$VERSION,withInterfaces=true && cd dist/clients/agent-controller/js && node -e \"const fs=require('fs');const pkg=JSON.parse(fs.readFileSync('package.json'));pkg.publishConfig={registry:'https://npm.pkg.github.com'};fs.writeFileSync('package.json',JSON.stringify(pkg,null,2));\" && npm install && npm run build && npm publish" + "command": "npx @openapitools/openapi-generator-cli generate -i libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml -g typescript-axios -o dist/clients/agent-controller/js --additional-properties=npmName=@forepath/agenstra-agent-controller-client,npmVersion=$VERSION,withInterfaces=true,axiosVersion=1.18.1 && cd dist/clients/agent-controller/js && node -e \"const fs=require('fs');const pkg=JSON.parse(fs.readFileSync('package.json'));pkg.publishConfig={registry:'https://npm.pkg.github.com'};fs.writeFileSync('package.json',JSON.stringify(pkg,null,2));\" && npm install && npm run build && npm publish" } }, "defaultConfiguration": "test" diff --git a/apps/agenstra/backend-agent-manager/project.json b/apps/agenstra/backend-agent-manager/project.json index 1039e9510..786ab3145 100644 --- a/apps/agenstra/backend-agent-manager/project.json +++ b/apps/agenstra/backend-agent-manager/project.json @@ -235,12 +235,12 @@ "executor": "nx:run-commands", "outputs": ["{workspaceRoot}/dist/clients/agent-manager/js"], "options": { - "command": "npx @openapitools/openapi-generator-cli generate -i libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml -g typescript-axios -o dist/clients/agent-manager/js --additional-properties=npmName=@forepath/agenstra-agent-manager-client,npmVersion=1.0.0,withInterfaces=true && cd dist/clients/agent-manager/js && npm install && npm run build" + "command": "npx @openapitools/openapi-generator-cli generate -i libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml -g typescript-axios -o dist/clients/agent-manager/js --additional-properties=npmName=@forepath/agenstra-agent-manager-client,npmVersion=1.0.0,withInterfaces=true,axiosVersion=1.18.1 && cd dist/clients/agent-manager/js && npm install && npm run build" }, "configurations": { "test": {}, "release": { - "command": "npx @openapitools/openapi-generator-cli generate -i libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml -g typescript-axios -o dist/clients/agent-manager/js --additional-properties=npmName=@forepath/agenstra-agent-manager-client,npmVersion=$VERSION,withInterfaces=true && cd dist/clients/agent-manager/js && node -e \"const fs=require('fs');const pkg=JSON.parse(fs.readFileSync('package.json'));pkg.publishConfig={registry:'https://npm.pkg.github.com'};fs.writeFileSync('package.json',JSON.stringify(pkg,null,2));\" && npm install && npm run build && npm publish" + "command": "npx @openapitools/openapi-generator-cli generate -i libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml -g typescript-axios -o dist/clients/agent-manager/js --additional-properties=npmName=@forepath/agenstra-agent-manager-client,npmVersion=$VERSION,withInterfaces=true,axiosVersion=1.18.1 && cd dist/clients/agent-manager/js && node -e \"const fs=require('fs');const pkg=JSON.parse(fs.readFileSync('package.json'));pkg.publishConfig={registry:'https://npm.pkg.github.com'};fs.writeFileSync('package.json',JSON.stringify(pkg,null,2));\" && npm install && npm run build && npm publish" } }, "defaultConfiguration": "test" diff --git a/apps/decabill/backend-billing-manager/project.json b/apps/decabill/backend-billing-manager/project.json index 447ca63ea..2dd24a962 100644 --- a/apps/decabill/backend-billing-manager/project.json +++ b/apps/decabill/backend-billing-manager/project.json @@ -181,12 +181,12 @@ "executor": "nx:run-commands", "outputs": ["{workspaceRoot}/dist/clients/billing/js"], "options": { - "command": "npx @openapitools/openapi-generator-cli generate -i libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml -g typescript-axios -o dist/clients/billing-manager/js --additional-properties=npmName=@forepath/decabill-billing-manager-client,npmVersion=1.0.0,withInterfaces=true && cd dist/clients/billing-manager/js && npm install && npm run build" + "command": "npx @openapitools/openapi-generator-cli generate -i libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml -g typescript-axios -o dist/clients/billing-manager/js --additional-properties=npmName=@forepath/decabill-billing-manager-client,npmVersion=1.0.0,withInterfaces=true,axiosVersion=1.18.1 && cd dist/clients/billing-manager/js && npm install && npm run build" }, "configurations": { "test": {}, "release": { - "command": "npx @openapitools/openapi-generator-cli generate -i libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml -g typescript-axios -o dist/clients/billing-manager/js --additional-properties=npmName=@forepath/decabill-billing-manager-client,npmVersion=$VERSION,withInterfaces=true && cd dist/clients/billing-manager/js && node -e \"const fs=require('fs');const pkg=JSON.parse(fs.readFileSync('package.json'));pkg.publishConfig={registry:'https://npm.pkg.github.com'};fs.writeFileSync('package.json',JSON.stringify(pkg,null,2));\" && npm install && npm run build && npm publish" + "command": "npx @openapitools/openapi-generator-cli generate -i libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml -g typescript-axios -o dist/clients/billing-manager/js --additional-properties=npmName=@forepath/decabill-billing-manager-client,npmVersion=$VERSION,withInterfaces=true,axiosVersion=1.18.1 && cd dist/clients/billing-manager/js && node -e \"const fs=require('fs');const pkg=JSON.parse(fs.readFileSync('package.json'));pkg.publishConfig={registry:'https://npm.pkg.github.com'};fs.writeFileSync('package.json',JSON.stringify(pkg,null,2));\" && npm install && npm run build && npm publish" } }, "defaultConfiguration": "test" diff --git a/apps/decabill/backend-billing-manager/src/migrations/1775900000000_CreateMeterTables.ts b/apps/decabill/backend-billing-manager/src/migrations/1775900000000_CreateMeterTables.ts new file mode 100644 index 000000000..fe92cd529 --- /dev/null +++ b/apps/decabill/backend-billing-manager/src/migrations/1775900000000_CreateMeterTables.ts @@ -0,0 +1,130 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateMeterTables1775900000000 implements MigrationInterface { + name = 'CreateMeterTables1775900000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "billing_meters" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "tenant_id" character varying(64) NOT NULL DEFAULT 'default', + "key" character varying(100) NOT NULL, + "name" character varying(255) NOT NULL, + "description" text, + "unit_label" character varying(64), + "aggregator" character varying(16) NOT NULL, + "default_unit_price_net" numeric(12,4) NOT NULL DEFAULT 0, + "is_active" boolean NOT NULL DEFAULT true, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_billing_meters" PRIMARY KEY ("id"), + CONSTRAINT "uq_billing_meters_tenant_key" UNIQUE ("tenant_id", "key") + ) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_billing_meters_tenant_active" + ON "billing_meters" ("tenant_id", "is_active") + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "billing_service_plan_meters" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "service_plan_id" uuid NOT NULL, + "meter_id" uuid NOT NULL, + "unit_price_net" numeric(12,4), + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_billing_service_plan_meters" PRIMARY KEY ("id"), + CONSTRAINT "uq_billing_service_plan_meters_plan_meter" UNIQUE ("service_plan_id", "meter_id"), + CONSTRAINT "FK_billing_service_plan_meters_plan" + FOREIGN KEY ("service_plan_id") REFERENCES "billing_service_plans"("id") ON DELETE CASCADE, + CONSTRAINT "FK_billing_service_plan_meters_meter" + FOREIGN KEY ("meter_id") REFERENCES "billing_meters"("id") ON DELETE RESTRICT + ) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_billing_service_plan_meters_plan" + ON "billing_service_plan_meters" ("service_plan_id") + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "billing_addon_meters" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "addon_id" uuid NOT NULL, + "meter_id" uuid NOT NULL, + "unit_price_net" numeric(12,4), + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_billing_addon_meters" PRIMARY KEY ("id"), + CONSTRAINT "uq_billing_addon_meters_addon_meter" UNIQUE ("addon_id", "meter_id"), + CONSTRAINT "FK_billing_addon_meters_addon" + FOREIGN KEY ("addon_id") REFERENCES "billing_addons"("id") ON DELETE CASCADE, + CONSTRAINT "FK_billing_addon_meters_meter" + FOREIGN KEY ("meter_id") REFERENCES "billing_meters"("id") ON DELETE RESTRICT + ) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_billing_addon_meters_addon" + ON "billing_addon_meters" ("addon_id") + `); + + await queryRunner.query(` + ALTER TABLE "billing_usage_records" + ADD COLUMN IF NOT EXISTS "meter_id" uuid, + ADD COLUMN IF NOT EXISTS "value" numeric(18,6), + ADD COLUMN IF NOT EXISTS "attachment_type" character varying(16), + ADD COLUMN IF NOT EXISTS "addon_id" uuid + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE "billing_usage_records" + ADD CONSTRAINT "FK_billing_usage_records_meter" + FOREIGN KEY ("meter_id") REFERENCES "billing_meters"("id") ON DELETE RESTRICT; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$ + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE "billing_usage_records" + ADD CONSTRAINT "FK_billing_usage_records_addon" + FOREIGN KEY ("addon_id") REFERENCES "billing_addons"("id") ON DELETE RESTRICT; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$ + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_billing_usage_records_subscription_meter" + ON "billing_usage_records" ("subscription_id", "meter_id", "attachment_type") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_billing_usage_records_subscription_meter"`); + await queryRunner.query(` + ALTER TABLE "billing_usage_records" DROP CONSTRAINT IF EXISTS "FK_billing_usage_records_addon" + `); + await queryRunner.query(` + ALTER TABLE "billing_usage_records" DROP CONSTRAINT IF EXISTS "FK_billing_usage_records_meter" + `); + await queryRunner.query(` + ALTER TABLE "billing_usage_records" + DROP COLUMN IF EXISTS "addon_id", + DROP COLUMN IF EXISTS "attachment_type", + DROP COLUMN IF EXISTS "value", + DROP COLUMN IF EXISTS "meter_id" + `); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_billing_addon_meters_addon"`); + await queryRunner.query(`DROP TABLE IF EXISTS "billing_addon_meters"`); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_billing_service_plan_meters_plan"`); + await queryRunner.query(`DROP TABLE IF EXISTS "billing_service_plan_meters"`); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_billing_meters_tenant_active"`); + await queryRunner.query(`DROP TABLE IF EXISTS "billing_meters"`); + } +} diff --git a/apps/decabill/backend-billing-manager/src/migrations/1776000000000_DeclaredRequiredMeters.ts b/apps/decabill/backend-billing-manager/src/migrations/1776000000000_DeclaredRequiredMeters.ts new file mode 100644 index 000000000..c18324c08 --- /dev/null +++ b/apps/decabill/backend-billing-manager/src/migrations/1776000000000_DeclaredRequiredMeters.ts @@ -0,0 +1,58 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class DeclaredRequiredMeters1776000000000 implements MigrationInterface { + name = 'DeclaredRequiredMeters1776000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "billing_service_plan_meters" + ADD COLUMN IF NOT EXISTS "source" character varying(16) NOT NULL DEFAULT 'manual', + ADD COLUMN IF NOT EXISTS "required" boolean NOT NULL DEFAULT false + `); + + await queryRunner.query(` + ALTER TABLE "billing_addon_meters" + ADD COLUMN IF NOT EXISTS "source" character varying(16) NOT NULL DEFAULT 'manual', + ADD COLUMN IF NOT EXISTS "required" boolean NOT NULL DEFAULT false + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "billing_service_type_meters" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "service_type_id" uuid NOT NULL, + "meter_id" uuid NOT NULL, + "unit_price_net" numeric(12,4), + "source" character varying(16) NOT NULL DEFAULT 'manual', + "required" boolean NOT NULL DEFAULT false, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_billing_service_type_meters" PRIMARY KEY ("id"), + CONSTRAINT "uq_billing_service_type_meters_type_meter" UNIQUE ("service_type_id", "meter_id"), + CONSTRAINT "FK_billing_service_type_meters_type" + FOREIGN KEY ("service_type_id") REFERENCES "billing_service_types"("id") ON DELETE CASCADE, + CONSTRAINT "FK_billing_service_type_meters_meter" + FOREIGN KEY ("meter_id") REFERENCES "billing_meters"("id") ON DELETE RESTRICT + ) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_billing_service_type_meters_type" + ON "billing_service_type_meters" ("service_type_id") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_billing_service_type_meters_type"`); + await queryRunner.query(`DROP TABLE IF EXISTS "billing_service_type_meters"`); + await queryRunner.query(` + ALTER TABLE "billing_addon_meters" + DROP COLUMN IF EXISTS "required", + DROP COLUMN IF EXISTS "source" + `); + await queryRunner.query(` + ALTER TABLE "billing_service_plan_meters" + DROP COLUMN IF EXISTS "required", + DROP COLUMN IF EXISTS "source" + `); + } +} diff --git a/apps/decabill/backend-billing-manager/src/migrations/1776200000000_WidenMeterAggregatorColumn.ts b/apps/decabill/backend-billing-manager/src/migrations/1776200000000_WidenMeterAggregatorColumn.ts new file mode 100644 index 000000000..b8515b023 --- /dev/null +++ b/apps/decabill/backend-billing-manager/src/migrations/1776200000000_WidenMeterAggregatorColumn.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class WidenMeterAggregatorColumn1776200000000 implements MigrationInterface { + name = 'WidenMeterAggregatorColumn1776200000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "billing_meters" + ALTER COLUMN "aggregator" TYPE character varying(32) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "billing_meters" + ALTER COLUMN "aggregator" TYPE character varying(16) + `); + } +} diff --git a/apps/decabill/backend-billing-manager/src/queue/job-registry.spec.ts b/apps/decabill/backend-billing-manager/src/queue/job-registry.spec.ts index 78b86b9ac..a36d7b028 100644 --- a/apps/decabill/backend-billing-manager/src/queue/job-registry.spec.ts +++ b/apps/decabill/backend-billing-manager/src/queue/job-registry.spec.ts @@ -39,6 +39,28 @@ describe('billing job-registry', () => { delete process.env.BILLING_PRICE_RECALC_ENABLED; }); + it('getBillingRepeatableJobs includes optional meter-collect coordinator every 60s', () => { + delete process.env.BILLING_METER_COLLECT_ENABLED; + delete process.env.BILLING_METER_COLLECT_INTERVAL; + const withMeterCollect = getBillingRepeatableJobs(); + expect(withMeterCollect.map((job) => job.name)).toContain(BillingJobName.METER_COLLECT_COORDINATOR); + + const meterCollectJob = withMeterCollect.find((job) => job.name === BillingJobName.METER_COLLECT_COORDINATOR); + expect(meterCollectJob?.everyMs).toBe(60_000); + + process.env.BILLING_METER_COLLECT_INTERVAL = '120000'; + const customInterval = getBillingRepeatableJobs().find( + (job) => job.name === BillingJobName.METER_COLLECT_COORDINATOR, + ); + expect(customInterval?.everyMs).toBe(120_000); + delete process.env.BILLING_METER_COLLECT_INTERVAL; + + process.env.BILLING_METER_COLLECT_ENABLED = 'false'; + const withoutMeterCollect = getBillingRepeatableJobs(); + expect(withoutMeterCollect.map((job) => job.name)).not.toContain(BillingJobName.METER_COLLECT_COORDINATOR); + delete process.env.BILLING_METER_COLLECT_ENABLED; + }); + it('getBillingRepeatableJobs includes update check coordinator', () => { const jobs = getBillingRepeatableJobs(); const updateCheckJob = jobs.find((job) => job.name === BillingJobName.UPDATE_CHECK); diff --git a/apps/decabill/backend-billing-manager/src/queue/job-registry.ts b/apps/decabill/backend-billing-manager/src/queue/job-registry.ts index 1737815ed..f065e5e73 100644 --- a/apps/decabill/backend-billing-manager/src/queue/job-registry.ts +++ b/apps/decabill/backend-billing-manager/src/queue/job-registry.ts @@ -43,6 +43,8 @@ export const BillingJobName = { PRICE_RECALC_COORDINATOR: 'price-recalc.coordinator', PRICE_RECALC_UNIT: 'price-recalc.unit', PLAN_PRICE_MIGRATE_UNIT: 'plan-price-migrate.unit', + METER_COLLECT_COORDINATOR: 'meter-collect.coordinator', + METER_COLLECT_UNIT: 'meter-collect.unit', VAT_ID_VALIDATION_UNIT: VatIdValidationJobName.UNIT, WEBHOOK_DELIVERY_RETENTION_COORDINATOR, UPDATE_CHECK: UPDATE_CHECK_JOB_NAME, @@ -172,6 +174,14 @@ export function getBillingRepeatableJobs(): BillingRepeatableJobDefinition[] { }); } + if (parseBooleanEnv('BILLING_METER_COLLECT_ENABLED', true)) { + jobs.push({ + name: BillingJobName.METER_COLLECT_COORDINATOR, + coordinatorJobId: buildCoordinatorJobId('meter-collect'), + everyMs: parseIntervalMs('BILLING_METER_COLLECT_INTERVAL', 60_000), + }); + } + jobs.push({ name: BillingJobName.UPDATE_CHECK, coordinatorJobId: buildCoordinatorJobId('update-check'), diff --git a/apps/decabill/backend-billing-manager/src/queue/processors/billing-jobs.processor.ts b/apps/decabill/backend-billing-manager/src/queue/processors/billing-jobs.processor.ts index c9204ea1b..31a3e3a0f 100644 --- a/apps/decabill/backend-billing-manager/src/queue/processors/billing-jobs.processor.ts +++ b/apps/decabill/backend-billing-manager/src/queue/processors/billing-jobs.processor.ts @@ -10,6 +10,7 @@ import { InvoiceOverdueJobHandler, OpenPositionInvoiceJobHandler, PriceRecalcJobHandler, + MeterCollectJobHandler, type PlanPriceMigrateUnitPayload, SubscriptionBillingJobHandler, SubscriptionConfigChangeJobHandler, @@ -74,6 +75,7 @@ export class BillingJobsProcessor extends WorkerHost { private readonly datevExportConfig: DatevExportConfigService, private readonly datevExportJobHandler: DatevExportJobHandler, private readonly priceRecalc: PriceRecalcJobHandler, + private readonly meterCollect: MeterCollectJobHandler, private readonly vatIdValidationJobHandler: VatIdValidationJobHandler, private readonly webhookDeliveryService: WebhookDeliveryService, private readonly webhookDeliveryRetentionService: WebhookDeliveryRetentionService, @@ -136,6 +138,9 @@ export class BillingJobsProcessor extends WorkerHost { case BillingJobName.PRICE_RECALC_COORDINATOR: await this.runPriceRecalcCoordinator(); break; + case BillingJobName.METER_COLLECT_COORDINATOR: + await this.runMeterCollectCoordinator(); + break; case BillingJobName.UPDATE_CHECK: case UPDATE_CHECK_JOB_NAME: await this.updateCheckService.runCheck(); @@ -228,6 +233,9 @@ export class BillingJobsProcessor extends WorkerHost { case BillingJobName.PRICE_RECALC_UNIT: await this.runPriceRecalcUnit(job.data as { tenantId: string; runDate: string }); break; + case BillingJobName.METER_COLLECT_UNIT: + await this.runMeterCollectUnit(job.data as { tenantId: string }); + break; case BillingJobName.PLAN_PRICE_MIGRATE_UNIT: await this.priceRecalc.processPlanCommercialMigrate(job.data as PlanPriceMigrateUnitPayload); break; @@ -652,6 +660,54 @@ export class BillingJobsProcessor extends WorkerHost { await this.priceRecalc.processTenant(data.tenantId, data.runDate); } + private isMeterCollectEnabled(): boolean { + const raw = process.env.BILLING_METER_COLLECT_ENABLED; + + if (raw === undefined || raw.trim() === '') { + return true; + } + + const normalized = raw.trim().toLowerCase(); + + if (normalized === 'true' || normalized === '1') { + return true; + } + + if (normalized === 'false' || normalized === '0') { + return false; + } + + return true; + } + + private async runMeterCollectCoordinator(): Promise { + if (!this.isMeterCollectEnabled()) { + this.logger.debug('Meter collect disabled — skipping coordinator'); + + return; + } + + await this.forEachConfiguredTenant(async (tenantId) => { + await this.enqueueBillingUnitJob({ + queue: this.billingQueue, + jobName: BillingJobName.METER_COLLECT_UNIT, + payload: { tenantId }, + jobIdNamespace: 'meter-collect', + jobIdParts: ['tenant', tenantId], + }); + }); + } + + private async runMeterCollectUnit(data: { tenantId: string }): Promise { + if (!this.isMeterCollectEnabled()) { + this.logger.debug('Meter collect disabled — skipping unit job'); + + return; + } + + await this.meterCollect.processTenant(data.tenantId); + } + private async runAdminBillNowCoordinator(data: AdminBillNowCoordinatorPayload): Promise { const tenantId = resolveBillingJobTenantId( data, diff --git a/apps/decabill/backend-billing-manager/src/typeorm.config.ts b/apps/decabill/backend-billing-manager/src/typeorm.config.ts index df838aeef..15c2960de 100644 --- a/apps/decabill/backend-billing-manager/src/typeorm.config.ts +++ b/apps/decabill/backend-billing-manager/src/typeorm.config.ts @@ -3,11 +3,13 @@ import { BackorderEntity, BillingAuditLogEntity, AddonEntity, + AddonMeterEntity, CloudInitConfigEntity, CustomerProfileEntity, DatevDebtorAccountEntity, DatevExportEntity, InvoiceEntity, + MeterEntity, InvoicePromotionApplicationEntity, InvoiceVoidDocumentEntity, InvoiceCreditDocumentEntity, @@ -28,7 +30,9 @@ import { ProviderPriceSnapshotEntity, ReservedHostnameEntity, ServicePlanEntity, + ServicePlanMeterEntity, ServiceTypeEntity, + ServiceTypeMeterEntity, SubscriptionEntity, SubscriptionAddonEntity, SubscriptionConfigChangeEntity, @@ -73,13 +77,17 @@ export const typeormConfig: DataSourceOptions = { entities: [ ServiceTypeEntity, ServicePlanEntity, + ServicePlanMeterEntity, + ServiceTypeMeterEntity, CloudInitConfigEntity, AddonEntity, + AddonMeterEntity, SubscriptionAddonEntity, SubscriptionEntity, SubscriptionConfigChangeEntity, SubscriptionItemEntity, ReservedHostnameEntity, + MeterEntity, UsageRecordEntity, InvoiceEntity, InvoicePromotionApplicationEntity, diff --git a/apps/decabill/frontend-billing-console/src/i18n/messages.de.xlf b/apps/decabill/frontend-billing-console/src/i18n/messages.de.xlf index a910effa4..8bfc905b3 100644 --- a/apps/decabill/frontend-billing-console/src/i18n/messages.de.xlf +++ b/apps/decabill/frontend-billing-console/src/i18n/messages.de.xlf @@ -5408,6 +5408,474 @@ Breaking changes Breaking Changes + + meters + Zähler + + + Usage meters + Nutzungszähler + + + Attach usage meters to this addon with optional unit price overrides. + Hängen Sie Nutzungszähler an dieses Add-on an, optional mit Stückpreis-Overrides. + + + Loading meters… + Zähler werden geladen… + + + Unit price override (net) + Stückpreis-Override (netto) + + + Update price + Preis aktualisieren + + + Detach meter + Zähler entfernen + + + Required + Erforderlich + + + No meters attached to this addon yet. + Noch keine Zähler an dieses Add-on angehängt. + + + Attach meter + Zähler anhängen + + + Select meter + Zähler auswählen + + + Override price (optional) + Override-Preis (optional) + + + Attach meter + Zähler anhängen + + + No active meters in the catalog. Create meters under Administration → Usage meters first. + Keine aktiven Zähler im Katalog. Legen Sie zuerst Zähler unter Administration → Nutzungszähler an. + + + All active catalog meters are already attached to this addon. + Alle aktiven Katalogzähler sind bereits an dieses Add-on angehängt. + + + Loading meter catalog… + Zählerkatalog wird geladen… + + + Has usage meters + Hat Nutzungszähler + + + Manage usage meters + Nutzungszähler verwalten + + + Instant cancel subscription + Abonnement sofort kündigen + + + Instant cancel subscription? + Abonnement sofort kündigen? + + + Force-remove this subscription immediately. Access ends now. Arrear plans are billed for used time; prepaid plans refund unused time like a statutory withdrawal. This cannot be resumed. + Dieses Abonnement sofort entfernen. Der Zugang endet jetzt. Nachträglich abgerechnete Tarife werden für die genutzte Zeit berechnet; im Voraus bezahlte Tarife erstatten ungenutzte Zeit wie bei einem gesetzlichen Widerruf. Eine Fortsetzung ist nicht möglich. + + + Instant cancel + Sofort kündigen + + + Usage meters + Nutzungszähler + + + Current period summaries + Zusammenfassungen der aktuellen Periode + + + Loading meters... + Zähler werden geladen... + + + No meter summaries. + Keine Zählerzusammenfassungen. + + + Meter entries + Zählereinträge + + + No entries yet. + Noch keine Einträge. + + + Record meter entry + Zählereintrag erfassen + + + Meter + Zähler + + + Select meter + Zähler auswählen + + + Value + Wert + + + Attachment type + Zuordnungstyp + + + Plan + Tarif + + + Addon + Add-on + + + Addon ID + Add-on-ID + + + Period start + Periodenbeginn + + + Period end + Periodenende + + + Save entry + Eintrag speichern + + + Delete meter entry? + Zählereintrag löschen? + + + Delete this meter entry? + Diesen Zählereintrag löschen? + + + Delete + Löschen + + + Usage meters + Nutzungszähler + + + Pending instant cancel + Sofortkündigung ausstehend + + + Updates need attention + Updates erfordern Aufmerksamkeit + + + Meters + Zähler + + + Meters + Zähler + + + Updates + Updates + + + Updates + Updates + + + Usage meters + Nutzungszähler + + + Add meter + Zähler hinzufügen + + + Search meters + Zähler suchen + + + Loading... + Laden... + + + No meters. + Keine Zähler. + + + Meter actions + Zähleraktionen + + + Edit meter + Zähler bearbeiten + + + Delete meter + Zähler löschen + + + Create meter + Zähler erstellen + + + Create + Erstellen + + + Edit meter + Zähler bearbeiten + + + Save + Speichern + + + Key + Schlüssel + + + Name + Name + + + Description + Beschreibung + + + Unit label + Einheit + + + Aggregator + Aggregator + + + Default unit price (net) + Standard-Stückpreis (netto) + + + Active + Aktiv + + + Delete meter + Zähler löschen + + + Delete meter ""? + Zähler "" löschen? + + + This action cannot be undone. + Diese Aktion kann nicht rückgängig gemacht werden. + + + Max + Maximum + + + Min + Minimum + + + Average + Durchschnitt + + + First + Erster + + + Last + Letzter + + + Sum + Summe + + + Sum of positive deltas + Summe positiver Deltas + + + meters + Zähler + + + None (no deployment) + Keine (kein Deployment) + + + Usage meters + Nutzungszähler + + + Attach usage meters billed in arrears. Optional unit price overrides the catalog default. + Hängen Sie nachträglich abgerechnete Nutzungszähler an. Ein optionaler Stückpreis überschreibt den Katalogstandard. + + + Prepaid plans cannot record or bill metered usage. Detach meters or disable bill-in-advance before using meters. + Im Voraus bezahlte Tarife können keine Zählernutzung erfassen oder abrechnen. Entfernen Sie Zähler oder deaktivieren Sie die Vorauszahlung, bevor Sie Zähler nutzen. + + + Loading meters… + Zähler werden geladen… + + + From service type + Vom Service-Typ + + + Unit price override (net) + Stückpreis-Override (netto) + + + Update price + Preis aktualisieren + + + Detach meter + Zähler entfernen + + + No meters attached to this plan yet. + Noch keine Zähler an diesen Tarif angehängt. + + + Attach meter + Zähler anhängen + + + Select meter + Zähler auswählen + + + Override price (optional) + Override-Preis (optional) + + + Attach meter + Zähler anhängen + + + No active meters in the catalog. Create meters under Administration → Usage meters first. + Keine aktiven Zähler im Katalog. Legen Sie zuerst Zähler unter Administration → Nutzungszähler an. + + + All active catalog meters are already attached to this plan. + Alle aktiven Katalogzähler sind bereits an diesen Tarif angehängt. + + + Loading meter catalog… + Zählerkatalog wird geladen… + + + Usage meters + Nutzungszähler + + + Provider-declared meters are required. Attach extra catalog meters with optional unit price overrides. + Vom Anbieter deklarierte Zähler sind erforderlich. Hängen Sie zusätzliche Katalogzähler mit optionalen Stückpreis-Overrides an. + + + Required + Erforderlich + + + Loading meters… + Zähler werden geladen… + + + Unit price override (net) + Stückpreis-Override (netto) + + + Update price + Preis aktualisieren + + + Detach meter + Zähler entfernen + + + No meters attached to this service type yet. + Noch keine Zähler an diesen Service-Typ angehängt. + + + Attach meter + Zähler anhängen + + + Select meter + Zähler auswählen + + + Override price (optional) + Override-Preis (optional) + + + Attach meter + Zähler anhängen + + + No active meters in the catalog. Create meters under Administration → Usage meters first. + Keine aktiven Zähler im Katalog. Legen Sie zuerst Zähler unter Administration → Nutzungszähler an. + + + Loading meter catalog… + Zählerkatalog wird geladen… + + + Usage meters + Nutzungszähler + + + Meter + Zähler + + + Plan usage meters + Tarif-Nutzungszähler + + + Usage for these meters is recorded in arrears and billed separately from the base plan price. + Die Nutzung dieser Zähler wird nachträglich erfasst und getrennt vom Grundpreis des Tarifs abgerechnet. + + + Addon meter + Add-on-Zähler + + + Plan meter + Tarif-Zähler + + + Back to login + Zurück zur Anmeldung + diff --git a/apps/decabill/frontend-billing-console/src/i18n/messages.xlf b/apps/decabill/frontend-billing-console/src/i18n/messages.xlf index 1f7e64fd0..4a7e5f274 100644 --- a/apps/decabill/frontend-billing-console/src/i18n/messages.xlf +++ b/apps/decabill/frontend-billing-console/src/i18n/messages.xlf @@ -2,6 +2,201 @@ + + Addons + + + Add addon + + + Search addons + + + Loading... + + + No addons. + + + config fields + + + meters + + + Addon actions + + + Edit addon + + + Delete addon + + + Add addon + + + Create + + + Edit addon + + + Save + + + Key + + + Name + + + Description + + + Implementation type + + + Compatible providers + + + Hold Ctrl/Cmd to select multiple. Leave empty for all addon-capable providers. + + + Module key + + + Module defaults + + + Defaults for module environment variables. Schema is defined by the registered module; leave blank when unused. + + + Uses a random default at provision time. + + + Cloud-init provision script template + + + Cloud-init deprovision script template + + + Environment variables + + + Add line + + + Key + + + Label + + + Description + + + Random + + + Default + + + Length + + + Special chars + + + Order + + + Remove + + + Base price + + + Price interval + + + Interval value + + + Usage meters + + + Attach usage meters to this addon with optional unit price overrides. + + + Loading meters… + + + Unit price override (net) + + + Update price + + + Detach meter + + + Required + + + No meters attached to this addon yet. + + + Attach meter + + + Select meter + + + Override price (optional) + + + Attach meter + + + No active meters in the catalog. Create meters under Administration → Usage meters first. + + + All active catalog meters are already attached to this addon. + + + Loading meter catalog… + + + Active + + + Delete addon + + + Delete addon ""? + + + This action cannot be undone. + + + Use {{env.KEY}} placeholders in the script for configured environment variables. + + + Leave empty for status-only mid-life removal (no SSH reverse script). + + + Module + + + Cloud-init script + + + Free + + + All providers + Billing @@ -236,47 +431,14 @@ Profile actions - - Edit profile - View trust score Edit custom data - - Custom data - - - Admin-only key/value pairs for linking this customer to external systems. Values are stored encrypted. - - - Custom data for - - - No custom data entries yet. - - - Key - - - Value - - - Key - - - Value - - - Remove entry - - - Add line - - - Save + + Edit profile Delete profile @@ -294,10 +456,10 @@ Computed: - Trust details are currently unavailable. + Trust details are currently unavailable. - Recompute + Recompute Create billing profile @@ -323,6 +485,45 @@ Delete + + Custom data + + + Admin-only key/value pairs for linking this customer to external systems. Values are stored encrypted. + + + Custom data for + + + Add line + + + No custom data entries yet. + + + Key + + + Key + + + Value + + + Value + + + Remove entry + + + Save + + + Consumer + + + Business + VAT ID @@ -332,18 +533,6 @@ Validated at: - - High trust - - - Needs attention - - - High risk - - - Not scored - DATEV exports @@ -599,24 +788,21 @@ No contracts found. - - Instant cancel subscription - - - Instant cancel subscription? - - - Force-remove this subscription immediately. Access ends now. Arrear plans are billed for used time; prepaid plans refund unused time like a statutory withdrawal. This cannot be resumed. - - - Instant cancel + + Has usage meters Subscription actions + + Manage usage meters + Withdraw subscription + + Instant cancel subscription + Cancel subscription @@ -647,6 +833,75 @@ Withdraw + + Instant cancel subscription? + + + Force-remove this subscription immediately. Access ends now. Arrear plans are billed for used time; prepaid plans refund unused time like a statutory withdrawal. This cannot be resumed. + + + Instant cancel + + + Usage meters + + + Current period summaries + + + Loading meters... + + + No meter summaries. + + + Meter entries + + + No entries yet. + + + Record meter entry + + + Meter + + + Select meter + + + Value + + + Attachment type + + + Plan + + + Addon + + + Addon ID + + + Period start + + + Period end + + + Save entry + + + Delete meter entry? + + + Delete this meter entry? + + + Delete + Resume subscription? @@ -704,6 +959,12 @@ Configs + + Addons + + + Usage meters + Service Plans @@ -752,6 +1013,9 @@ Pending instant cancel + + Applying configuration change + Canceled @@ -824,6 +1088,18 @@ Inactive + + High trust + + + Needs attention + + + High risk + + + Not scored + Billed @@ -863,12 +1139,6 @@ Incomplete - - Business - - - Consumer - Not validated @@ -938,69 +1208,6 @@ System - Addons - Add addon - Search addons - Loading... - No addons. - Edit addon - Delete addon - Add addon - Edit addon - Key - Name - Description - Implementation type - Compatible providers - Hold Ctrl/Cmd to select multiple. Leave empty for all addon-capable providers. - Module key - Cloud-init provision script template - Cloud-init deprovision script template - Base price - Price interval - Interval value - Active - Create - Save - Delete addon - Delete addon ""? - This action cannot be undone. - Module - Cloud-init script - Free - Module defaults - Defaults for module environment variables. Schema is defined by the registered module; leave blank when unused. - Uses a random default at provision time. - Use {{env.KEY}} placeholders in the script for configured environment variables. - Leave empty for status-only mid-life removal (no SSH reverse script). - Environment variables - Add line - Key - Label - Description - Random - Default - Length - Special chars - Order - - config fields - - All providers - Addon actions - Addons - Addons - Addons - Customer-selectable addons - Choose which addons customers may add when ordering this plan. - Loading addons… - Addons - Configuration for this addon - Addon configuration - Addon: - Product total (net) - Addons total - Plan and addons CloudInit Configs @@ -1058,9 +1265,6 @@ Add line - - Remove - Create @@ -1124,12 +1328,21 @@ Projects + + Access Tokens + + + Tokens + Admin Admin + + Updates need attention + Role @@ -1145,8 +1358,20 @@ Configs - - Plans + + Addons + + + Addons + + + Meters + + + Meters + + + Plans Plans @@ -1163,20 +1388,17 @@ Promotions - - Webhooks - - - Webhooks - Updates Updates - - Updates need attention + + Webhooks + + + Webhooks User Management @@ -1184,12 +1406,6 @@ Users - - Access Tokens - - - Tokens - Billing Profiles @@ -1218,7 +1434,7 @@ Billing - Payment submitted. Waiting for confirmation from the payment provider… + Payment submitted. Waiting for confirmation from the payment provider… Payment confirmed. Your invoice is marked as paid. @@ -1253,9 +1469,6 @@ No invoices match your search. - - Invoices - Invoice history @@ -1268,36 +1481,12 @@ No invoice history. - - Subscription - - - Choose a subscription - - - Search invoices - - - Select a subscription to view invoices. - - - Loading invoices... - - - No invoices for this subscription. - Invoice actions Preview invoice - - Pay invoice - - - Payment is only available for amounts of or more. - Download PDF @@ -1373,14 +1562,104 @@ Subscription is finalized; no further invoices can be created. + + Pay invoice + + + Payment is only available for amounts of or more. + Open & overdue History - - By subscription + + Usage meters + + + Add meter + + + Search meters + + + Loading... + + + No meters. + + + Meter actions + + + Edit meter + + + Delete meter + + + Create meter + + + Create + + + Edit meter + + + Save + + + Key + + + Name + + + Description + + + Unit label + + + Aggregator + + + Default unit price (net) + + + Active + + + Delete meter + + + Delete meter ""? + + + This action cannot be undone. + + + Max + + + Min + + + Average + + + First + + + Last + + + Sum + + + Sum of positive deltas Overview @@ -1448,6 +1727,12 @@ Server setup did not complete. Check backorders or contact support if this persists. + + SSH access granted + + + SSH access granted + Server actions @@ -1460,32 +1745,26 @@ Restart server - - Show SSH access key - - - SSH access key already revealed - - - SSH access granted + + Server status updating… - - SSH access granted + + Open console - Reveal SSH access key? + Reveal SSH access key? Viewing the SSH access key for this service may limit technical support for that specific service. The key can only be retrieved once. After you continue, this action cannot be repeated. - Cancel + Cancel - Reveal key + Reveal key - SSH access key + SSH access key Store this private key securely. It will not be shown again in the billing console. @@ -1493,26 +1772,20 @@ SSH private key - - Copy - Copied - - Close - - - The SSH access key has already been revealed for this service. + + Copy - - Could not retrieve the SSH access key. Please try again or contact support. + + Close - - Server status updating… + + Show SSH access key - - Open console + + SSH access key already revealed Custom application @@ -1526,6 +1799,12 @@ Updating + + The SSH access key has already been revealed for this service. + + + Could not retrieve the SSH access key. Please try again or contact support. + Board @@ -2199,6 +2478,9 @@ No service plans. + + meters + Plan actions @@ -2298,29 +2580,29 @@ Defaults for integrated stacks and CloudInit environment variables required by the selected customer options. + + Customer-selectable addons + + + Choose which addons customers may add when ordering this plan. + Cancel at period end - Cancellation will be scheduled for the end of the current billing period. Unchecking this allows immediate cancellation mid-period. + Cancellation will be scheduled for the end of the current billing period. Unchecking this allows immediate cancellation mid-period. Bill in advance - Charge the full period at its start (prepaid). Usage-based metering cannot be used with advance billing. Cancellation always takes effect at the end of the already-billed period. + Charge the full period at its start (prepaid). Usage-based metering cannot be used with advance billing. Cancellation always takes effect at the end of the already-billed period. Automatic daily price recalculation - Opt in only when required for EU compliance. When enabled, the package price is recalculated nightly at midnight from the provider catalog. Checkout must disclose this and the statutory withdrawal restart when the price changes. - - - Apply base price, margin, and VAT changes to existing subscriptions - - - When checked, changing base price, margins, or VAT rate queues a job that migrates eligible subscriptions (differential billing, statutory withdrawal restart, and customer email). Leave unchecked to update the catalog only for new orders. + Opt in only when required for EU compliance. When enabled, the package price is recalculated nightly at midnight from the provider catalog. Checkout must disclose this and the statutory withdrawal restart when the price changes. Active @@ -2331,6 +2613,12 @@ Edit service plan + + Apply base price, margin, and VAT changes to existing subscriptions + + + When checked, changing base price, margins, or VAT rate queues a job that migrates eligible subscriptions (differential billing, statutory withdrawal restart, and customer email). Leave unchecked to update the catalog only for new orders. + This plan referenced inactive CloudInit configs. They were removed from provisioning options. Save to apply the change. @@ -2349,6 +2637,54 @@ Delete + + Usage meters + + + Attach usage meters billed in arrears. Optional unit price overrides the catalog default. + + + Prepaid plans cannot record or bill metered usage. Detach meters or disable bill-in-advance before using meters. + + + Loading meters… + + + From service type + + + Unit price override (net) + + + Update price + + + Detach meter + + + No meters attached to this plan yet. + + + Attach meter + + + Select meter + + + Override price (optional) + + + Attach meter + + + No active meters in the catalog. Create meters under Administration → Usage meters first. + + + All active catalog meters are already attached to this plan. + + + Loading meter catalog… + Show @@ -2436,6 +2772,48 @@ Save + + Usage meters + + + Provider-declared meters are required. Attach extra catalog meters with optional unit price overrides. + + + Required + + + Loading meters… + + + Unit price override (net) + + + Update price + + + Detach meter + + + No meters attached to this service type yet. + + + Attach meter + + + Select meter + + + Override price (optional) + + + Attach meter + + + No active meters in the catalog. Create meters under Administration → Usage meters first. + + + Loading meter catalog… + Delete service provider? @@ -2505,6 +2883,12 @@ No backorders match your search. + + Usage meters + + + Upgrade or downgrade + Backorder actions @@ -2532,6 +2916,36 @@ Choose a plan + + Loading addons… + + + Addons + + + Meter + + + Plan usage meters + + + Usage for these meters is recorded in arrears and billed separately from the base plan price. + + + Automatic price adjustment + + + This plan may have its price adjusted automatically each day based on the provider catalog. + + + You will receive an email if the price changes. + + + From then on, you have + + + days to withdraw without negative impact. + Instance configuration @@ -2646,21 +3060,6 @@ Statutory withdrawal is only available before the service is provisioned. - - Automatic price adjustment - - - This plan may have its price adjusted automatically each day based on the provider catalog. - - - You will receive an email if the price changes. - - - From then on, you have - - - days to withdraw without negative impact. - Backorder if unavailable @@ -2682,8 +3081,14 @@ Loading price… - - Plan total (net) + + Product total (net) + + + Addons total + + + Plan and addons Promotion @@ -2703,9 +3108,6 @@ Back - - Plan & addons - Next @@ -2715,35 +3117,125 @@ Order - - Withdraw order? + + Upgrade or downgrade - - Are you sure you want to withdraw this order? + + Configuration change requested - - Edit customer profile + + We are applying your changes. You can close this dialog and track progress in your subscriptions list. The final amounts are settled with your next invoice. - - First name, last name, email, address line 1, city and country are required for invoicing. + + Checking available changes… - - First name + + Configuration change steps - - Last name + + Current server type - - Company + + Loading server types… - - Customer type + + New server type - - VAT ID + + Your server keeps its current location. Choosing a server type at the same price is not possible. - - VAT ID validation + + No alternative server types are available for this plan. + + + Active + + + Will be removed + + + The configuration of this addon is locked and cannot be changed here. + + + No addons are available for this plan. + + + Your changes + + + You have not selected any changes yet. Go back and pick a different server type or adjust your addons. + + + Server type + + + Billing information + + + Effective + + + All amounts shown are advisory. The final amounts are recalculated when the change is confirmed. + + + Price preview + + + Current period (net) + + + New period (net) + + + Difference per period + + + Settled now (net) + + + Select a change to see its effect on your price. + + + Active promotions + + + periods left + + + Configuration change in progress, please wait + + + Apply changes + + + Withdraw order? + + + Are you sure you want to withdraw this order? + + + Edit customer profile + + + First name, last name, email, address line 1, city and country are required for invoicing. + + + First name + + + Last name + + + Company + + + Customer type + + + VAT ID + + + VAT ID validation Validated at: @@ -2776,16 +3268,16 @@ Auto-billing - When enabled, open invoices are charged automatically. Manual payment is blocked until auto-payment succeeds or retries are exhausted. + When enabled, open invoices are charged automatically. Manual payment is blocked until auto-payment succeeds or retries are exhausted. - Payment method submitted. Waiting for confirmation from the payment provider… + Payment method submitted. Waiting for confirmation from the payment provider… - Payment method confirmed and saved. + Payment method confirmed and saved. - Payment method setup was canceled. + Payment method setup was canceled. Payment method: @@ -2793,17 +3285,14 @@ On file - - Not on file - - Add payment method + Add payment method - Enable auto-billing + Enable auto-billing - Disable auto-billing + Disable auto-billing Save @@ -2814,6 +3303,51 @@ Backorders + + Addon meter + + + Plan meter + + + Addon: + + + Plan & addons + + + Server type + + + Addons + + + Summary + + + This subscription cannot be reconfigured right now. Please try again once any pending change has finished. + + + Select at least one change before continuing. + + + This server type change is not supported for your service. + + + The selected server type costs the same as your current one. Choose a larger or a smaller server type. + + + One of the selected addons is not available for this subscription. + + + The configuration of an addon that is already active cannot be changed here. + + + The configuration change could not be applied. Please try again later. + + + The configuration change could not be processed. Please try again. + Admin @@ -2865,12 +3399,12 @@ Code must be exactly 6 characters (uppercase letters and numbers). - - Confirm Email - Back to login + + Confirm Email + Login @@ -3039,15 +3573,15 @@ Privacy Policy + + Back to login + Registering... Register - - Already have an account? Log in - Check Your Email @@ -3117,15 +3651,15 @@ Passwords do not match. + + Back to login + Resetting... Reset Password - - Back to login - Reset Your Password @@ -3150,15 +3684,15 @@ Please enter a valid email address. + + Back to login + Sending... Send Reset Link - - Back to login - Set New Password @@ -3216,14 +3750,102 @@ Passwords do not match. + + Back to login + Resetting... Reset Password - - Back to login + + Personal Access Tokens + + + Create token + + + Search tokens + + + Loading tokens... + + + No personal access tokens + + + scopes + + + No expiry + + + Edit token + + + Revoke token + + + Create personal access token + + + Close + + + Token value + + + Copy this token now. It will not be shown again. + + + Copied + + + Copy + + + Done + + + Name * + + + Expires at + + + Optional. Leave empty for a token that does not expire. + + + Scopes * + + + Loading scopes... + + + No scopes available + + + Create + + + Edit personal access token + + + Update + + + Revoke token + + + Are you sure you want to revoke the token ? + + + This action cannot be undone. + + + Revoke User Management @@ -3316,113 +3938,17 @@ Delete - - Personal Access Tokens + + Documentation - - Create token + + Documentation - - Search tokens + + Select language - - Loading tokens... - - - No personal access tokens - - - scopes - - - Created - - - Expires - - - No expiry - - - Last used - - - Revoke token - - - Edit token - - - Edit personal access token - - - Create personal access token - - - Close - - - Token value - - - Copy this token now. It will not be shown again. - - - Copied - - - Copy - - - Done - - - Name * - - - Expires at - - - Optional. Leave empty for a token that does not expire. - - - Scopes * - - - Loading scopes... - - - No scopes available - - - Create - - - Update - - - Revoke token - - - Are you sure you want to revoke the token ? - - - This action cannot be undone. - - - Revoke - - - Documentation - - - Documentation - - - Select language - - - GitHub + + GitHub View on GitHub @@ -3744,7 +4270,7 @@ Payload - Response + Response More deliveries exist than shown. @@ -3768,123 +4294,75 @@ Updates - Check now + Check now Loading updates... - - No update information available. - - - Checking for updates... - - - Update available - - - Up to date - - - Unknown - - Installed version + Installed version - Latest version + Latest version View release - - Frontend version - Last check - - Instances + + Checking for updates... - - Outdated instances + + Frontend version - Service instances - - - Instances - - - Product - - - Shared + Service instances Search instances - - Search product changelog - - - Search shared changelog - No service instances reported yet. - - Instance - - - Service - - - Role - - - Hostname - - - Installed - - - Update state + + Product changelog - - Last heartbeat + + Search product changelog - - Redis + + No product changelog entries. - - Queue + + Shared changelog - - Database + + Search shared changelog - - Product changelog + + No shared changelog entries. - - Shared changelog + + No update information available. - - No product changelog entries. + + Instances - - No shared changelog entries. + + Product - - Healthy + + Shared - - Degraded + + Update available - - N/A + + Up to date - + Unknown @@ -3944,6 +4422,18 @@ Breaking changes + + Healthy + + + Degraded + + + N/A + + + Unknown + This website uses cookies to ensure you get the best experience on our website. @@ -3959,135 +4449,6 @@ Learn more - - Applying configuration change - - - Upgrade or downgrade - - - Upgrade or downgrade - - - Configuration change requested - - - We are applying your changes. You can close this dialog and track progress in your subscriptions list. The final amounts are settled with your next invoice. - - - Checking available changes… - - - Configuration change steps - - - Current server type - - - Loading server types… - - - New server type - - - Your server keeps its current location. Choosing a server type at the same price is not possible. - - - No alternative server types are available for this plan. - - - Active - - - Will be removed - - - The configuration of this addon is locked and cannot be changed here. - - - No addons are available for this plan. - - - Your changes - - - You have not selected any changes yet. Go back and pick a different server type or adjust your addons. - - - Server type - - - Billing information - - - Effective - - - All amounts shown are advisory. The final amounts are recalculated when the change is confirmed. - - - Price preview - - - Current period (net) - - - New period (net) - - - Difference per period - - - Settled now (net) - - - Select a change to see its effect on your price. - - - Active promotions - - - periods left - - - Configuration change in progress, please wait - - - Apply changes - - - Server type - - - Addons - - - Summary - - - This subscription cannot be reconfigured right now. Please try again once any pending change has finished. - - - Select at least one change before continuing. - - - This server type change is not supported for your service. - - - The selected server type costs the same as your current one. Choose a larger or a smaller server type. - - - One of the selected addons is not available for this subscription. - - - The configuration of an addon that is already active cannot be changed here. - - - The configuration change could not be applied. Please try again later. - - - The configuration change could not be processed. Please try again. - diff --git a/apps/forepath/backend-communication/project.json b/apps/forepath/backend-communication/project.json index ad60e3d3f..527bb2b63 100644 --- a/apps/forepath/backend-communication/project.json +++ b/apps/forepath/backend-communication/project.json @@ -121,12 +121,12 @@ "{workspaceRoot}/dist/clients/forepath-communication-manager/js" ], "options": { - "command": "npx @openapitools/openapi-generator-cli generate -i libs/domains/forepath/backend/feature-communication-manager/spec/openapi.yaml -g typescript-axios -o dist/clients/forepath-communication-manager/js --additional-properties=npmName=@forepath/forepath-communication-manager-client,npmVersion=1.0.0,withInterfaces=true && cd dist/clients/forepath-communication-manager/js && npm install && npm run build" + "command": "npx @openapitools/openapi-generator-cli generate -i libs/domains/forepath/backend/feature-communication-manager/spec/openapi.yaml -g typescript-axios -o dist/clients/forepath-communication-manager/js --additional-properties=npmName=@forepath/forepath-communication-manager-client,npmVersion=1.0.0,withInterfaces=true,axiosVersion=1.18.1 && cd dist/clients/forepath-communication-manager/js && npm install && npm run build" }, "configurations": { "test": {}, "release": { - "command": "npx @openapitools/openapi-generator-cli generate -i libs/domains/forepath/backend/feature-communication-manager/spec/openapi.yaml -g typescript-axios -o dist/clients/forepath-communication-manager/js --additional-properties=npmName=@forepath/forepath-communication-manager-client,npmVersion=$VERSION,withInterfaces=true && cd dist/clients/forepath-communication-manager/js && node -e \"const fs=require('fs');const pkg=JSON.parse(fs.readFileSync('package.json'));pkg.publishConfig={registry:'https://npm.pkg.github.com'};fs.writeFileSync('package.json',JSON.stringify(pkg,null,2));\" && npm install && npm run build && npm publish" + "command": "npx @openapitools/openapi-generator-cli generate -i libs/domains/forepath/backend/feature-communication-manager/spec/openapi.yaml -g typescript-axios -o dist/clients/forepath-communication-manager/js --additional-properties=npmName=@forepath/forepath-communication-manager-client,npmVersion=$VERSION,withInterfaces=true,axiosVersion=1.18.1 && cd dist/clients/forepath-communication-manager/js && node -e \"const fs=require('fs');const pkg=JSON.parse(fs.readFileSync('package.json'));pkg.publishConfig={registry:'https://npm.pkg.github.com'};fs.writeFileSync('package.json',JSON.stringify(pkg,null,2));\" && npm install && npm run build && npm publish" } }, "defaultConfiguration": "test" diff --git a/docs/decabill/features/README.md b/docs/decabill/features/README.md index da026e541..ae06106ff 100644 --- a/docs/decabill/features/README.md +++ b/docs/decabill/features/README.md @@ -9,6 +9,7 @@ Decabill provides a complete set of capabilities for subscription billing, invoi - **Authentication** Keycloak OAuth2/OIDC, built-in users with JWT, or static API key - **Multi-tenancy** Tenant-scoped data with `X-Tenant` header and configurable tenant frontends - **Subscriptions** Order, cancel, and resume service plans with optional cloud provisioning +- **Usage meters** Tenant meter catalog attachable to plans and addons with attachment-scoped usage billing - **Advance billing and yearly interval** Prepaid period charges and `year` billing interval - **Invoices** ZUGFeRD PDFs, open positions, billing-day accumulation, and Stripe checkout - **Service Types and Plans** Admin-managed catalog with provider schemas and pricing @@ -67,6 +68,17 @@ Order service plans, manage lifecycle (cancel, resume), and provision cloud inst - Subscription items with provisioning status and hostname reservation - Usage records for usage-based pricing (arrear plans only) +### [Usage meters](./usage-meters.md) + +Reusable tenant meters attached to plans and addons with optional unit-price overrides. Usage is recorded per attachment and billed as separate invoice lines. + +**Key Capabilities**: + +- Meter catalog CRUD (`max` / `min` / `avg` / `first` / `last` aggregators) +- Plan and addon attachments with coalesce pricing +- Attachment-scoped recording and admin meter-entry CRUD +- Separate invoice lines per plan/addon attachment; legacy payload fallback when no meters + ### [Subscription Config Change](./subscription-config-change.md) Modify the configuration of an active subscription: in-place server type resize and addon add/remove, with prorated billing and async BullMQ processing. diff --git a/docs/decabill/features/addons.md b/docs/decabill/features/addons.md index 1ad710f10..2e39e76f2 100644 --- a/docs/decabill/features/addons.md +++ b/docs/decabill/features/addons.md @@ -97,10 +97,15 @@ Ops checklist for mid-life addon remove and related env knobs: [Subscription Con ## Admin API -| Method | Path | Purpose | -| --------------- | -------------- | --------------------- | -| GET/POST | `/addons` | List / create | -| GET/POST/DELETE | `/addons/{id}` | Get / update / delete | +| Method | Path | Purpose | +| --------------- | ------------------------------- | ------------------------------------ | +| GET/POST | `/addons` | List / create | +| GET/POST/DELETE | `/addons/{id}` | Get / update / delete | +| GET/POST | `/addons/{id}/meters` | List / attach usage meters | +| POST/DELETE | `/addons/{id}/meters/{meterId}` | Update override / detach | +| GET | `/addons/modules` | Registered modules + declared meters | + +Addon responses embed attached meters (optional unit-price override). Module addons sync declared meters as required (`source=module`) on create/update; required meters cannot be detached. See [Usage meters](./usage-meters.md). ## Customer API @@ -120,3 +125,4 @@ See [Webhooks](./webhooks.md) and [Email notifications](./email-notifications.md - [CloudInit configs](./cloud-init-configs.md) Env var and random default pattern - [Service types and plans](./service-types-and-plans.md) Catalog attachment of addons to plans - [Subscriptions](./subscriptions.md) Addon lifecycle on subscription orders +- [Usage meters](./usage-meters.md) Meter attachments on addons and scoped usage billing diff --git a/docs/decabill/features/advance-billing-and-yearly-interval.md b/docs/decabill/features/advance-billing-and-yearly-interval.md index 0c95465ce..7c6ebf7c3 100644 --- a/docs/decabill/features/advance-billing-and-yearly-interval.md +++ b/docs/decabill/features/advance-billing-and-yearly-interval.md @@ -30,7 +30,7 @@ Liability still flows **subscription → open position → billing-day accumulat ## Usage-based billing -**Not available** when `billInAdvance` is true. `POST /admin/usage/record` returns 400 for advance-billed subscriptions; invoice creation ignores usage costs for those plans. +**Not available** when `billInAdvance` is true. `POST /admin/usage/record` returns 400 for advance-billed subscriptions; invoice creation skips meter lines and legacy usage costs for those plans. See [Usage meters](./usage-meters.md). ## Withdrawal (advance) diff --git a/docs/decabill/features/dynamic-provider-plugins.md b/docs/decabill/features/dynamic-provider-plugins.md index 50f616a62..8d38b2985 100644 --- a/docs/decabill/features/dynamic-provider-plugins.md +++ b/docs/decabill/features/dynamic-provider-plugins.md @@ -10,11 +10,12 @@ This page covers **Decabill billing manager** registries only. ## Registries -| Env var | Criticality | Registers | -| ----------------------------------- | ----------- | --------------------------------------------------------------------------- | -| `DYNAMIC_PAYMENT_PROCESSORS` | critical | Payment processor implementations | -| `DYNAMIC_BILLING_PROVIDER_METADATA` | optional | Admin UI provider metadata (`providerMetadata` export) | -| `DYNAMIC_ADDON_MODULES` | optional | Addon lifecycle modules (`provision` / `teardown`, optional `configFields`) | +| Env var | Criticality | Registers | +| ----------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------- | +| `DYNAMIC_PAYMENT_PROCESSORS` | critical | Payment processor implementations | +| `DYNAMIC_BILLING_PROVIDER_METADATA` | optional | Admin UI provider metadata (`providerMetadata` export) | +| `DYNAMIC_BILLING_PROVIDER_MODULES` | optional | Runtime provider modules (`collectMeters`, optional `meters`) — distinct from metadata | +| `DYNAMIC_ADDON_MODULES` | optional | Addon lifecycle modules (`provision` / `teardown` / optional `collectMeters`, `configFields`, `meters`) | Provider metadata capability flags (all **fail closed** when omitted, treated as `false`): @@ -28,6 +29,13 @@ See [Addons](./addons.md) and [Subscription Config Change](./subscription-config Addon modules may declare `configFields` (CloudInit-style env metadata). Decabill persists that list onto the catalog addon’s `configSchema` at create/update; admins set encrypted defaults only. At order time, customer `addonConfigs` merge with defaults and random fills into `configSnapshot` for `provision` / `teardown`. +### Declared meters and collection + +- Metadata packages (`DYNAMIC_BILLING_PROVIDER_METADATA`) and addon modules may declare `meters` (including optional `collectionIntervalMs`) for catalog sync. +- Runtime **provider modules** (`DYNAMIC_BILLING_PROVIDER_MODULES`, plus built-in Hetzner/DigitalOcean stubs) implement `collectMeters(ctx)` and may also declare `meters` (runtime overrides metadata for the same key when resolving intervals). +- Addon modules may implement optional `collectMeters` for meters with `collectionIntervalMs`. +- See [Usage meters](./usage-meters.md) for the meter-collect BullMQ job. + Shared tuning: | Variable | Purpose | @@ -115,6 +123,10 @@ Built-in: `stripe` via `StripePaymentProcessor`. See [Payment Processing](./paym `DYNAMIC_BILLING_PROVIDER_METADATA` adds entries to `GET /service-types/providers` for admin UI dropdowns and config schema rendering without implementing full provisioning in the same package. +Optional `providerMetadata.meters` (and the same field on `ProviderDetail`) declares required usage meters. On service-type create/update, Decabill ensures catalog rows by `key` and sideloads them onto `billing_service_type_meters` as non-removable (`source=provider`, `required=true`). Products that register via the same metadata surface declare meters the same way. See [Usage meters](./usage-meters.md). + +Addon modules registered via `DYNAMIC_ADDON_MODULES` may declare `meters` the same way; they sync onto `billing_addon_meters` as `source=module`, `required=true`. + Built-in Hetzner and DigitalOcean providers register statically when API tokens are present. They set `supportsAddons`, `supportsServerTypeUpgrade`, and `supportsServerTypeDowngrade` to `true`. Dynamic metadata packages that implement (or wrap) in-place `changeServerType` must export the matching upgrade/downgrade flags. If both stay unset, mid-life server-type change stays disabled in eligibility/preview/submit even when other provider APIs work. diff --git a/docs/decabill/features/invoices.md b/docs/decabill/features/invoices.md index 67ffd63ac..2da7d6efe 100644 --- a/docs/decabill/features/invoices.md +++ b/docs/decabill/features/invoices.md @@ -100,7 +100,11 @@ Issued invoices (and credit notes) also enqueue customer emails via BullMQ; see ## Usage on Invoices -Usage records posted via `POST /admin/usage/record` (admin or API key only) appear on invoices when pricing includes usage cost or unit counts. Customers can read usage via `GET /usage/summary/{subscriptionId}` but cannot submit records. +When the subscription’s plan or billable addons have meter attachments, invoice creation emits a **separate line per attachment** (plan vs each addon) using that attachment’s in-window aggregate × effective unit price. Same catalog meter on plan and addons does not merge into one line. See [Usage meters](./usage-meters.md). + +Legacy: if there are no meter attachments, usage cost is taken from the latest `usagePayload` (`totalCost` / `usageCost` / `units`×`unitPrice`) on the subscription base line. + +Usage is posted via `POST /admin/usage/record` (admin or API key only). Customers can read usage via `GET /usage/summary/{subscriptionId}` and subscription meter summaries but cannot submit records. Prepaid plans skip meter and legacy usage costs. ## Payment diff --git a/docs/decabill/features/service-types-and-plans.md b/docs/decabill/features/service-types-and-plans.md index bd2f2184f..db30716b4 100644 --- a/docs/decabill/features/service-types-and-plans.md +++ b/docs/decabill/features/service-types-and-plans.md @@ -106,7 +106,9 @@ No dedicated webhook events are emitted for plan CRUD or for “non-provision fu - **`serviceTypeId`** UUID of a catalog service type, or **`null`** for no deployment - Title, description, and active flag - Billing interval (hourly, daily, monthly, **yearly**) -- **`billInAdvance`** when true, charge at period start (prepaid); default false (arrear). Incompatible with usage-based metering. See [Advance billing and yearly interval](./advance-billing-and-yearly-interval.md). +- **`billInAdvance`** when true, charge at period start (prepaid); default false (arrear). Incompatible with usage-based metering. See [Advance billing and yearly interval](./advance-billing-and-yearly-interval.md) and [Usage meters](./usage-meters.md). +- **Plan meters** attach via `GET/POST /service-plans/{id}/meters` (optional unit-price override). Embedded on `ServicePlanResponse.meters` as the effective set: explicit plan attachments plus meters inherited from the plan’s service type (`inherited=true`). Inherited meters are fixed in the plan UI. +- **Service-type meters** attach via `GET/POST /service-types/{id}/meters`. Provider-declared meters sync as required (`source=provider`); admins may attach extra manual meters. See [Usage meters](./usage-meters.md). - **`autoRecalculatePriceDaily`** when true, nightly job refreshes catalog base price from the provider and migrates eligible subscriptions (default false, opt-in; not allowed for null-`serviceTypeId` plans). See [Automatic daily price recalculation](./automatic-price-recalculation.md). - **Admin commercial migrate** on plan update, optional request field `migrateExistingSubscriptions` (not stored). When true **and** `basePrice`, `marginPercent`, `marginFixed`, or `taxCategory` actually change, a `plan-price-migrate.unit` job migrates eligible subscriptions with the same settlement, withdrawal restart, and consolidated price-change email as nightly recalc. Unchecked updates affect new orders only. - Base price, margin, and computed customer total diff --git a/docs/decabill/features/subscriptions.md b/docs/decabill/features/subscriptions.md index 6ed9675db..12b2ffb33 100644 --- a/docs/decabill/features/subscriptions.md +++ b/docs/decabill/features/subscriptions.md @@ -181,7 +181,9 @@ The **subscription-item-update** scheduler refreshes bundled agenstra-controller ## Usage Records -Usage-based plans accept metering via `POST /admin/usage/record` (billing admin or `STATIC_API_KEY` only; not customer self-service). Usage is included in invoice line items when `usagePayload` or `units` and `unitPrice` are present. Customers may read their summary at `GET /usage/summary/{subscriptionId}`. +Prefer first-class [usage meters](./usage-meters.md) attached to plans and addons. Metered records require `meterId`, `value`, and attachment scope (`plan` or `addon` + `addonId`). Invoice lines are emitted per attachment. + +Legacy payload-only recording (`usagePayload` with `totalCost` / `usageCost` / `units`×`unitPrice`) remains when the subscription has **no** plan or billable addon meter attachments. Usage is posted via `POST /admin/usage/record` (billing admin or `STATIC_API_KEY` only; not customer self-service). Customers may read summaries at `GET /usage/summary/{subscriptionId}` and `GET /subscriptions/{id}/meters`. **Advance-billed plans:** usage recording is rejected. Prepaid periods have no usage window before the charge. diff --git a/docs/decabill/features/usage-meters.md b/docs/decabill/features/usage-meters.md new file mode 100644 index 000000000..f5ceed56a --- /dev/null +++ b/docs/decabill/features/usage-meters.md @@ -0,0 +1,142 @@ +# Usage meters + +Tenant-scoped usage meters drive arrear usage billing for service plans and addons. + +## Catalog + +`billing_meters` stores reusable meters per tenant: + +- `key` (unique per tenant), `name`, optional `description` / `unit_label` +- `aggregator`: `max` | `min` | `avg` | `first` | `last` | `sum` | `sum_positive_deltas` + - **Gauge-style:** `max` / `min` / `avg` / `first` / `last` over in-window samples + - **`sum`:** total of sample values (volume / batch totals) + - **`sum_positive_deltas`:** sum of increases between consecutive samples (time-ordered); + on counter reset (value decreases), treat the new value as a fresh series and add it + (Prometheus-style). Useful for cumulative provider counters. +- `default_unit_price_net` +- `is_active` + +Admin API: `GET/POST /meters`, `GET/POST/DELETE /meters/{id}` (`catalog:read` / `catalog:write`). + +Hard-delete is blocked while plan, addon, service-type, or usage rows reference the meter. Prefer `isActive=false`. + +## Attachments + +Meters attach to plans, addons, and service types via join tables with an optional unit-price override: + +- `billing_service_plan_meters` — unique `(service_plan_id, meter_id)` +- `billing_addon_meters` — unique `(addon_id, meter_id)` +- `billing_service_type_meters` — unique `(service_type_id, meter_id)` + +Each link stores `source` (`manual` | `module` | `provider`) and `required` (boolean). Required links cannot be detached; price overrides remain allowed. + +**Effective price:** `coalesce(link.unit_price_net, meter.default_unit_price_net)`. + +The same catalog meter may be attached to a plan and to one or more addons. Each attachment keeps its own effective price. Usage and invoice lines are **attachment-scoped**, not merged by `meterId`. + +### Declared / required meters + +- **Addon modules** (`implementationType=module`) may declare `meters` on the registered module. On addon create/update, Decabill ensures a catalog row by `key` (create if missing; never clobber existing catalog defaults) and sideloads links as `source=module`, `required=true`. +- **Providers / products** declare meters on provider metadata (`GET /service-types/providers`). On service-type create/update (and on meter list), Decabill sideloads them onto `billing_service_type_meters` as `source=provider`, `required=true`. Admins may attach additional manual meters on the service type. +- **Effective plan meters** = explicit plan attachments ∪ meters on the plan’s `serviceTypeId`. Inherited service-type meters appear on plan list/embed with `inherited=true` (no detach in plan UI). Recording `attachmentType=plan` accepts either explicit or inherited meters. +- **Collection interval:** optional `collectionIntervalMs` on each declared meter. When set (> 0), the meter-collect job pulls samples on that interval. Omitted meters stay push-only (admin / API key). + +Nested APIs: + +- `GET/POST /service-plans/{id}/meters`, `POST/DELETE /service-plans/{id}/meters/{meterId}` +- `GET/POST /addons/{id}/meters`, `POST/DELETE /addons/{id}/meters/{meterId}` +- `GET/POST /service-types/{id}/meters`, `POST/DELETE /service-types/{id}/meters/{meterId}` +- `GET /addons/modules` — registered modules including declared meters (admin preview) + +`ServicePlanResponse` and `AddonResponse` embed attached meters (plans include inherited service-type meters). + +## Recording usage + +`POST /admin/usage/record` (admin or API key, `usage:write`): + +```json +{ + "subscriptionId": "...", + "periodStart": "...", + "periodEnd": "...", + "meterId": "...", + "value": 12.5, + "attachmentType": "plan", + "addonId": null, + "usagePayload": {} +} +``` + +Rules: + +- Prepaid plans (`billInAdvance`) reject metered recording. +- If the subscription has **no** plan-meter (including inherited service-type meters) and **no** billable addon-meter attachments, legacy payload-only bodies remain allowed (`totalCost` / `usageCost` / `units×unitPrice` on invoice). +- Otherwise `meterId` and `value` are required; `attachmentType` defaults to `plan`. +- `attachmentType=plan`: meter must be on the subscription’s plan or its service type. +- `attachmentType=addon`: `addonId` required; subscription must have that addon billable; meter must be on that addon. + +Admin entry CRUD: `/admin/billing/subscriptions/{id}/meter-entries`. + +## Collector job (pull) + +BullMQ `meter-collect.coordinator` runs every minute (`BILLING_METER_COLLECT_INTERVAL`, default `60000`; disable with `BILLING_METER_COLLECT_ENABLED=false`). It fans out per-tenant `meter-collect.unit` jobs. + +For each active arrear subscription (skip prepaid): + +1. **Plan / provider path:** provisioned subscription item → resolve declared meters with `collectionIntervalMs` from the provider runtime module (override) or metadata → due when no prior `usageSource=collector` row or `now - last.periodEnd >= interval` → call `BillingProviderModule.collectMeters` **once per due meter** with that meter’s `periodStart`/`periodEnd` → write via `UsageService.createUsage` with `usageSource: collector` and `attachmentType: plan`. +2. **Addon / module path:** billable module addons → same due logic from `BillingAddonModule.meters` → optional `collectMeters` (also per due meter) → `attachmentType: addon`. + +The tenant unit job pages through eligible subscriptions (`BILLING_METER_COLLECT_BATCH_SIZE`, default 500) until exhausted. + +Idempotency is soft: due-check against the latest collector `periodEnd` for `(subscriptionId, meterId, attachmentType, addonId)`. Fail-closed: meters with an interval but no `collectMeters` implementation are skipped (warn log; no zero invent). Per-subscription and per-addon errors are isolated so one failure does not abort the tenant job. + +Push (`POST /admin/usage/record`) and pull coexist; modules own their collection windows. + +Built-in Hetzner / DigitalOcean register stub collectors (empty samples) until real metrics APIs are wired. + +## Subscription views + +- `GET /subscriptions/{id}/meters` and admin twin under `/admin/billing/subscriptions/{id}/meters` +- `SubscriptionResponse.meters` embeds the same summaries (effective price, period aggregate, estimated charge) + +## Invoice charge path (arrear) + +For each charge period: + +1. Effective plan meters (plan links ∪ service-type links) → aggregate entries with `attachmentType=plan` for that `meterId` → separate plan-scoped line +2. Each billable subscription addon → each of that addon’s meter attachments → aggregate with `attachmentType=addon` and that `addonId` → separate addon-scoped line +3. Same catalog meter on plan + addon A + addon B can produce up to three lines +4. If there are no plan/addon meter attachments at all → legacy `extractUsageCost` on the subscription base line + +Period inclusion: a record settles into the charge period that contains its `periodEnd` +(left-open start, inclusive end). Spanning intervals are billed once, in the period where +the sample ends — not in both adjacent periods. Collector samples clamp `periodEnd` to the +subscription’s current `nextBillingAt` when collection runs after that boundary (before the +billing tick), and arrear schedule advances anchor the next `currentPeriodStart` at the prior +`nextBillingAt` so late ticks do not open a gap that drops samples. Invoice meter lines use +schedule-aligned `meterPeriodStart` (not lagged `invoice.createdAt`) so open-position +invoicing cannot skip post-boundary collector rows. + +Empty aggregates become `0` and omit the line when below the minimum billable amount. Prepaid plans skip meter costs. + +## Notifications + +| Event | When | +| ------------------------------------------------------------------ | ------------------------------- | +| `meter.created` / `updated` / `deleted` | Catalog | +| `service_plan.meter_attached` / `meter_updated` / `meter_detached` | Plan link | +| `addon.meter_attached` / `meter_updated` / `meter_detached` | Addon link | +| `service_type.meter_attached` / `meter_updated` / `meter_detached` | Service-type link | +| `usage.recorded` / `updated` / `deleted` | Entries (no email; high volume) | + +`subscription.period_charged` includes `meterCharges[]` with `attachmentType` and optional `addonId`. + +## Related docs + +- [Addons](./addons.md) +- [Service types and plans](./service-types-and-plans.md) +- [Dynamic provider plugins](./dynamic-provider-plugins.md) +- [Subscriptions](./subscriptions.md) +- [Invoices](./invoices.md) +- [Advance billing](./advance-billing-and-yearly-interval.md) +- [Webhooks](./webhooks.md) diff --git a/docs/decabill/features/webhooks.md b/docs/decabill/features/webhooks.md index a1c868678..16ba1bc7e 100644 --- a/docs/decabill/features/webhooks.md +++ b/docs/decabill/features/webhooks.md @@ -58,6 +58,12 @@ Events are published from the **billing** service after successful mutations. - `subscription.provisioned`, `subscription.provision_failed` (itemId plus hostname/service/providerReference or errorMessage; never secrets) - `subscription.ssh_access_granted` (metadata only: subscription/item ids, hostname, grantedAt — never the private key) - `addon.activated`, `addon.deactivated`, `addon.provision_failed`, `addon.teardown_failed` +- `meter.created`, `meter.updated`, `meter.deleted` +- `service_plan.meter_attached`, `service_plan.meter_updated`, `service_plan.meter_detached` +- `addon.meter_attached`, `addon.meter_updated`, `addon.meter_detached` +- `usage.recorded`, `usage.updated`, `usage.deleted` (high volume; no email) + +`subscription.period_charged` may include `meterCharges[]` (`meterId`, `attachmentType`, optional `addonId`, amounts). See [Usage meters](./usage-meters.md). Plans with `serviceTypeId: null` (billing-only, no deployment) do not emit `subscription.provisioned` / `provision_failed`; fulfillment is immediate and silent, same as other non-cloud providers. Plan catalog CRUD does not emit webhooks. Orders still emit `subscription.created`. See [Service Types and Plans](./service-types-and-plans.md#plans-without-a-service-type-null). diff --git a/graph/graph.json b/graph/graph.json index 5fd56ca1c..32b3d6791 100644 --- a/graph/graph.json +++ b/graph/graph.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-08-04T17:27:47.835Z", + "generatedAt": "2026-08-06T11:41:29.954Z", "nodes": [ { "id": "project:@forepath/test/mounted-plugin-fixture", @@ -2651,6 +2651,15 @@ "projectName": "decabill-frontend-data-access-billing-console" } }, + { + "id": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/meters.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/meters.service.ts", + "languageOrKind": "ts", + "projectName": "decabill-frontend-data-access-billing-console" + } + }, { "id": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/project-milestones.service.ts", "type": "service", @@ -2988,6 +2997,23 @@ ] } }, + { + "id": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters", + "type": "state", + "attrs": { + "path": "libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters", + "languageOrKind": "ts", + "projectName": "decabill-frontend-data-access-billing-console", + "sliceName": "meters", + "memberFiles": [ + "meters.actions.ts", + "meters.effects.ts", + "meters.facade.ts", + "meters.reducer.ts", + "meters.selectors.ts" + ] + } + }, { "id": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/project-board-socket", "type": "state", @@ -3164,6 +3190,23 @@ ] } }, + { + "id": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters", + "type": "state", + "attrs": { + "path": "libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters", + "languageOrKind": "ts", + "projectName": "decabill-frontend-data-access-billing-console", + "sliceName": "subscription-meters", + "memberFiles": [ + "subscription-meters.actions.ts", + "subscription-meters.effects.ts", + "subscription-meters.facade.ts", + "subscription-meters.reducer.ts", + "subscription-meters.selectors.ts" + ] + } + }, { "id": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-server-info", "type": "state", @@ -5678,6 +5721,15 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-subscription-meters.controller.ts", + "type": "controller", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-subscription-meters.controller.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-usage.controller.ts", "type": "controller", @@ -5741,6 +5793,15 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/meters.controller.ts", + "type": "controller", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/meters.controller.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/payments-webhook.controller.ts", "type": "controller", @@ -6056,6 +6117,15 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/declared-meter.dto.ts", + "type": "dto", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/declared-meter.dto.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/instant-cancel-subscription.dto.ts", "type": "dto", @@ -6092,6 +6162,24 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/meter-response.dto.ts", + "type": "dto", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/meter-response.dto.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/meter.dto.ts", + "type": "dto", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/meter.dto.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/order-provisioning-option.dto.ts", "type": "dto", @@ -6317,6 +6405,15 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/addon-meter.entity.ts", + "type": "entity", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/addon-meter.entity.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/addon.entity.ts", "type": "entity", @@ -6443,6 +6540,15 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/meter.entity.ts", + "type": "entity", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/meter.entity.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/open-position.entity.ts", "type": "entity", @@ -6533,6 +6639,15 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-plan-meter.entity.ts", + "type": "entity", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-plan-meter.entity.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-plan.entity.ts", "type": "entity", @@ -6542,6 +6657,15 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-type-meter.entity.ts", + "type": "entity", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-type-meter.entity.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-type.entity.ts", "type": "entity", @@ -6983,6 +7107,15 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/addon-meters.repository.ts", + "type": "repository", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/addon-meters.repository.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/addons.repository.ts", "type": "repository", @@ -7109,6 +7242,15 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/meters.repository.ts", + "type": "repository", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/meters.repository.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/open-positions.repository.ts", "type": "repository", @@ -7199,6 +7341,15 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plan-meters.repository.ts", + "type": "repository", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plan-meters.repository.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plans.repository.ts", "type": "repository", @@ -7208,6 +7359,15 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-type-meters.repository.ts", + "type": "repository", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-type-meters.repository.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-types.repository.ts", "type": "repository", @@ -7685,6 +7845,33 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-billing.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-billing.service.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts", + "type": "job", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/open-position-invoice.job-handler.ts", "type": "job", @@ -7775,6 +7962,15 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/provider-module-registry.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/services/provider-module-registry.service.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/provider-pricing.service.ts", "type": "service", @@ -11877,6 +12073,14 @@ "languageOrKind": "md" } }, + { + "id": "file:docs/decabill/features/usage-meters.md", + "type": "doc", + "attrs": { + "path": "docs/decabill/features/usage-meters.md", + "languageOrKind": "md" + } + }, { "id": "file:docs/decabill/features/vat-and-tax-treatment.md", "type": "doc", @@ -14645,6 +14849,50 @@ "specKind": "openapi" } }, + { + "id": "api:HTTP:GET:/service-types/{id}/meters", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/service-types/{id}/meters", + "operationId": "listServiceTypeMeters", + "summary": "List meters attached to a service type", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:POST:/service-types/{id}/meters", + "type": "endpoint", + "attrs": { + "method": "POST", + "pathOrChannel": "/service-types/{id}/meters", + "operationId": "attachServiceTypeMeter", + "summary": "Attach a catalog meter to a service type (admin only)", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:POST:/service-types/{id}/meters/{meterId}", + "type": "endpoint", + "attrs": { + "method": "POST", + "pathOrChannel": "/service-types/{id}/meters/{meterId}", + "operationId": "updateServiceTypeMeter", + "summary": "Update service type meter unit-price override (admin only)", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:DELETE:/service-types/{id}/meters/{meterId}", + "type": "endpoint", + "attrs": { + "method": "DELETE", + "pathOrChannel": "/service-types/{id}/meters/{meterId}", + "operationId": "detachServiceTypeMeter", + "summary": "Detach meter from service type (admin only)", + "specKind": "openapi" + } + }, { "id": "api:HTTP:GET:/service-types/{id}", "type": "endpoint", @@ -14799,6 +15047,61 @@ "specKind": "openapi" } }, + { + "id": "api:HTTP:GET:/meters", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/meters", + "operationId": "listMeters", + "summary": "List usage meters (admin only)", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:POST:/meters", + "type": "endpoint", + "attrs": { + "method": "POST", + "pathOrChannel": "/meters", + "operationId": "createMeter", + "summary": "Create usage meter (admin only)", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:GET:/meters/{id}", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/meters/{id}", + "operationId": "getMeter", + "summary": "Get usage meter (admin only)", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:POST:/meters/{id}", + "type": "endpoint", + "attrs": { + "method": "POST", + "pathOrChannel": "/meters/{id}", + "operationId": "updateMeter", + "summary": "Update usage meter (admin only)", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:DELETE:/meters/{id}", + "type": "endpoint", + "attrs": { + "method": "DELETE", + "pathOrChannel": "/meters/{id}", + "operationId": "deleteMeter", + "summary": "Delete usage meter (admin only)", + "specKind": "openapi" + } + }, { "id": "api:HTTP:GET:/addons", "type": "endpoint", @@ -14821,6 +15124,17 @@ "specKind": "openapi" } }, + { + "id": "api:HTTP:GET:/addons/modules", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/addons/modules", + "operationId": "listAddonModules", + "summary": "List registered addon modules (admin only)", + "specKind": "openapi" + } + }, { "id": "api:HTTP:GET:/addons/{id}", "type": "endpoint", @@ -14854,6 +15168,94 @@ "specKind": "openapi" } }, + { + "id": "api:HTTP:GET:/addons/{id}/meters", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/addons/{id}/meters", + "operationId": "listAddonMeters", + "summary": "List meters attached to an addon", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:POST:/addons/{id}/meters", + "type": "endpoint", + "attrs": { + "method": "POST", + "pathOrChannel": "/addons/{id}/meters", + "operationId": "attachAddonMeter", + "summary": "Attach a meter to an addon", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:POST:/addons/{id}/meters/{meterId}", + "type": "endpoint", + "attrs": { + "method": "POST", + "pathOrChannel": "/addons/{id}/meters/{meterId}", + "operationId": "updateAddonMeter", + "summary": "Update addon meter unit-price override", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:DELETE:/addons/{id}/meters/{meterId}", + "type": "endpoint", + "attrs": { + "method": "DELETE", + "pathOrChannel": "/addons/{id}/meters/{meterId}", + "operationId": "detachAddonMeter", + "summary": "Detach meter from addon", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:GET:/service-plans/{id}/meters", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/service-plans/{id}/meters", + "operationId": "listServicePlanMeters", + "summary": "List meters attached to a service plan", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:POST:/service-plans/{id}/meters", + "type": "endpoint", + "attrs": { + "method": "POST", + "pathOrChannel": "/service-plans/{id}/meters", + "operationId": "attachServicePlanMeter", + "summary": "Attach a meter to a service plan", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:POST:/service-plans/{id}/meters/{meterId}", + "type": "endpoint", + "attrs": { + "method": "POST", + "pathOrChannel": "/service-plans/{id}/meters/{meterId}", + "operationId": "updateServicePlanMeter", + "summary": "Update plan meter unit-price override", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:DELETE:/service-plans/{id}/meters/{meterId}", + "type": "endpoint", + "attrs": { + "method": "DELETE", + "pathOrChannel": "/service-plans/{id}/meters/{meterId}", + "operationId": "detachServicePlanMeter", + "summary": "Detach meter from service plan", + "specKind": "openapi" + } + }, { "id": "api:HTTP:GET:/service-plans/{id}/addons", "type": "endpoint", @@ -14920,6 +15322,17 @@ "specKind": "openapi" } }, + { + "id": "api:HTTP:GET:/subscriptions/{subscriptionId}/meters", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/subscriptions/{subscriptionId}/meters", + "operationId": "listSubscriptionMeters", + "summary": "List meter summaries for a subscription", + "specKind": "openapi" + } + }, { "id": "api:HTTP:POST:/subscriptions/{subscriptionId}/cancel", "type": "endpoint", @@ -15382,6 +15795,61 @@ "specKind": "openapi" } }, + { + "id": "api:HTTP:GET:/admin/billing/subscriptions/{subscriptionId}/meters", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/admin/billing/subscriptions/{subscriptionId}/meters", + "operationId": "adminListSubscriptionMeters", + "summary": "List subscription meter summaries (admin)", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:GET:/admin/billing/subscriptions/{subscriptionId}/meter-entries", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/admin/billing/subscriptions/{subscriptionId}/meter-entries", + "operationId": "adminListSubscriptionMeterEntries", + "summary": "List metered usage entries for a subscription", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:POST:/admin/billing/subscriptions/{subscriptionId}/meter-entries", + "type": "endpoint", + "attrs": { + "method": "POST", + "pathOrChannel": "/admin/billing/subscriptions/{subscriptionId}/meter-entries", + "operationId": "adminCreateSubscriptionMeterEntry", + "summary": "Create a metered usage entry for a subscription", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:POST:/admin/billing/subscriptions/{subscriptionId}/meter-entries/{entryId}", + "type": "endpoint", + "attrs": { + "method": "POST", + "pathOrChannel": "/admin/billing/subscriptions/{subscriptionId}/meter-entries/{entryId}", + "operationId": "adminUpdateSubscriptionMeterEntry", + "summary": "Update a metered usage entry", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:DELETE:/admin/billing/subscriptions/{subscriptionId}/meter-entries/{entryId}", + "type": "endpoint", + "attrs": { + "method": "DELETE", + "pathOrChannel": "/admin/billing/subscriptions/{subscriptionId}/meter-entries/{entryId}", + "operationId": "adminDeleteSubscriptionMeterEntry", + "summary": "Delete a metered usage entry", + "specKind": "openapi" + } + }, { "id": "api:HTTP:GET:/admin/billing/summary", "type": "endpoint", @@ -16434,6 +16902,33 @@ "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" } }, + { + "id": "webhook-event:decabill-backend-feature-billing-manager:addon.meter_attached", + "type": "webhook-event", + "attrs": { + "eventName": "addon.meter_attached", + "projectName": "decabill-backend-feature-billing-manager", + "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" + } + }, + { + "id": "webhook-event:decabill-backend-feature-billing-manager:addon.meter_detached", + "type": "webhook-event", + "attrs": { + "eventName": "addon.meter_detached", + "projectName": "decabill-backend-feature-billing-manager", + "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" + } + }, + { + "id": "webhook-event:decabill-backend-feature-billing-manager:addon.meter_updated", + "type": "webhook-event", + "attrs": { + "eventName": "addon.meter_updated", + "projectName": "decabill-backend-feature-billing-manager", + "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" + } + }, { "id": "webhook-event:decabill-backend-feature-billing-manager:addon.provision_failed", "type": "webhook-event", @@ -16605,6 +17100,33 @@ "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" } }, + { + "id": "webhook-event:decabill-backend-feature-billing-manager:meter.created", + "type": "webhook-event", + "attrs": { + "eventName": "meter.created", + "projectName": "decabill-backend-feature-billing-manager", + "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" + } + }, + { + "id": "webhook-event:decabill-backend-feature-billing-manager:meter.deleted", + "type": "webhook-event", + "attrs": { + "eventName": "meter.deleted", + "projectName": "decabill-backend-feature-billing-manager", + "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" + } + }, + { + "id": "webhook-event:decabill-backend-feature-billing-manager:meter.updated", + "type": "webhook-event", + "attrs": { + "eventName": "meter.updated", + "projectName": "decabill-backend-feature-billing-manager", + "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" + } + }, { "id": "webhook-event:decabill-backend-feature-billing-manager:milestone.created", "type": "webhook-event", @@ -16731,6 +17253,33 @@ "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" } }, + { + "id": "webhook-event:decabill-backend-feature-billing-manager:service_plan.meter_attached", + "type": "webhook-event", + "attrs": { + "eventName": "service_plan.meter_attached", + "projectName": "decabill-backend-feature-billing-manager", + "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" + } + }, + { + "id": "webhook-event:decabill-backend-feature-billing-manager:service_plan.meter_detached", + "type": "webhook-event", + "attrs": { + "eventName": "service_plan.meter_detached", + "projectName": "decabill-backend-feature-billing-manager", + "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" + } + }, + { + "id": "webhook-event:decabill-backend-feature-billing-manager:service_plan.meter_updated", + "type": "webhook-event", + "attrs": { + "eventName": "service_plan.meter_updated", + "projectName": "decabill-backend-feature-billing-manager", + "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" + } + }, { "id": "webhook-event:decabill-backend-feature-billing-manager:service_plan.price_recalculated", "type": "webhook-event", @@ -16740,6 +17289,33 @@ "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" } }, + { + "id": "webhook-event:decabill-backend-feature-billing-manager:service_type.meter_attached", + "type": "webhook-event", + "attrs": { + "eventName": "service_type.meter_attached", + "projectName": "decabill-backend-feature-billing-manager", + "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" + } + }, + { + "id": "webhook-event:decabill-backend-feature-billing-manager:service_type.meter_detached", + "type": "webhook-event", + "attrs": { + "eventName": "service_type.meter_detached", + "projectName": "decabill-backend-feature-billing-manager", + "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" + } + }, + { + "id": "webhook-event:decabill-backend-feature-billing-manager:service_type.meter_updated", + "type": "webhook-event", + "attrs": { + "eventName": "service_type.meter_updated", + "projectName": "decabill-backend-feature-billing-manager", + "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" + } + }, { "id": "webhook-event:decabill-backend-feature-billing-manager:subscription.cancel_scheduled", "type": "webhook-event", @@ -16920,6 +17496,33 @@ "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" } }, + { + "id": "webhook-event:decabill-backend-feature-billing-manager:usage.deleted", + "type": "webhook-event", + "attrs": { + "eventName": "usage.deleted", + "projectName": "decabill-backend-feature-billing-manager", + "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" + } + }, + { + "id": "webhook-event:decabill-backend-feature-billing-manager:usage.recorded", + "type": "webhook-event", + "attrs": { + "eventName": "usage.recorded", + "projectName": "decabill-backend-feature-billing-manager", + "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" + } + }, + { + "id": "webhook-event:decabill-backend-feature-billing-manager:usage.updated", + "type": "webhook-event", + "attrs": { + "eventName": "usage.updated", + "projectName": "decabill-backend-feature-billing-manager", + "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" + } + }, { "id": "webhook-event:decabill-backend-feature-billing-manager:vat_id.validation_failed", "type": "webhook-event", @@ -26221,6 +26824,86 @@ "domain": "decabill" } }, + { + "id": "concept:decabill-usage-meters", + "type": "concept", + "attrs": { + "title": "Usage meters", + "docPath": "docs/decabill/features/usage-meters.md", + "sectionAnchor": "usage-meters", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-catalog", + "type": "concept", + "attrs": { + "title": "Catalog", + "docPath": "docs/decabill/features/usage-meters.md", + "sectionAnchor": "catalog", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-attachments", + "type": "concept", + "attrs": { + "title": "Attachments", + "docPath": "docs/decabill/features/usage-meters.md", + "sectionAnchor": "attachments", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-recording-usage", + "type": "concept", + "attrs": { + "title": "Recording usage", + "docPath": "docs/decabill/features/usage-meters.md", + "sectionAnchor": "recording-usage", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-collector-job-pull", + "type": "concept", + "attrs": { + "title": "Collector job (pull)", + "docPath": "docs/decabill/features/usage-meters.md", + "sectionAnchor": "collector-job-pull", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-subscription-views", + "type": "concept", + "attrs": { + "title": "Subscription views", + "docPath": "docs/decabill/features/usage-meters.md", + "sectionAnchor": "subscription-views", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-invoice-charge-path-arrear", + "type": "concept", + "attrs": { + "title": "Invoice charge path (arrear)", + "docPath": "docs/decabill/features/usage-meters.md", + "sectionAnchor": "invoice-charge-path-arrear", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-related-docs", + "type": "concept", + "attrs": { + "title": "Related docs", + "docPath": "docs/decabill/features/usage-meters.md", + "sectionAnchor": "related-docs", + "domain": "decabill" + } + }, { "id": "concept:decabill-vat-and-tax-treatment", "type": "concept", @@ -30563,6 +31246,11 @@ "to": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/invoices.service.ts", "type": "contains" }, + { + "from": "project:decabill-frontend-data-access-billing-console", + "to": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/meters.service.ts", + "type": "contains" + }, { "from": "project:decabill-frontend-data-access-billing-console", "to": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/project-milestones.service.ts", @@ -30688,6 +31376,11 @@ "to": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/invoices", "type": "contains" }, + { + "from": "project:decabill-frontend-data-access-billing-console", + "to": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters", + "type": "contains" + }, { "from": "project:decabill-frontend-data-access-billing-console", "to": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/project-board-socket", @@ -30738,6 +31431,11 @@ "to": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-config-change", "type": "contains" }, + { + "from": "project:decabill-frontend-data-access-billing-console", + "to": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters", + "type": "contains" + }, { "from": "project:decabill-frontend-data-access-billing-console", "to": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-server-info", @@ -32018,6 +32716,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-promotions.controller.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-subscription-meters.controller.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-usage.controller.ts", @@ -32053,6 +32756,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/invoices.controller.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/meters.controller.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/payments-webhook.controller.ts", @@ -32228,6 +32936,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/customer-trust-score.dto.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/declared-meter.dto.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/instant-cancel-subscription.dto.ts", @@ -32248,6 +32961,16 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/manual-invoice.dto.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/meter-response.dto.ts", + "type": "contains" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/meter.dto.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/order-provisioning-option.dto.ts", @@ -32373,6 +33096,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/withdrawal-policy.dto.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/addon-meter.entity.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/addon.entity.ts", @@ -32443,6 +33171,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/invoice.entity.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/meter.entity.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/open-position.entity.ts", @@ -32493,11 +33226,21 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/reserved-hostname.entity.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-plan-meter.entity.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-plan.entity.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-type-meter.entity.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-type.entity.ts", @@ -32743,6 +33486,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/projects.service.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/addon-meters.repository.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/addons.repository.ts", @@ -32813,6 +33561,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/invoices.repository.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/meters.repository.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/open-positions.repository.ts", @@ -32863,11 +33616,21 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/reserved-hostnames.repository.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plan-meters.repository.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plans.repository.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-type-meters.repository.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-types.repository.ts", @@ -33133,6 +33896,21 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/manual-invoice.service.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-billing.service.ts", + "type": "contains" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts", + "type": "contains" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/open-position-invoice.job-handler.ts", @@ -33183,6 +33961,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/provider-locations.service.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/provider-module-registry.service.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/provider-pricing.service.ts", @@ -36138,6 +36921,26 @@ "to": "api:HTTP:GET:/service-types/providers/{providerId}/locations", "type": "contains" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/service-types/{id}/meters", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:POST:/service-types/{id}/meters", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:POST:/service-types/{id}/meters/{meterId}", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:DELETE:/service-types/{id}/meters/{meterId}", + "type": "contains" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", "to": "api:HTTP:GET:/service-types/{id}", @@ -36208,6 +37011,31 @@ "to": "api:HTTP:DELETE:/cloud-init-configs/{id}", "type": "contains" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/meters", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:POST:/meters", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/meters/{id}", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:POST:/meters/{id}", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:DELETE:/meters/{id}", + "type": "contains" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", "to": "api:HTTP:GET:/addons", @@ -36218,6 +37046,11 @@ "to": "api:HTTP:POST:/addons", "type": "contains" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/addons/modules", + "type": "contains" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", "to": "api:HTTP:GET:/addons/{id}", @@ -36233,6 +37066,46 @@ "to": "api:HTTP:DELETE:/addons/{id}", "type": "contains" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/addons/{id}/meters", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:POST:/addons/{id}/meters", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:POST:/addons/{id}/meters/{meterId}", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:DELETE:/addons/{id}/meters/{meterId}", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/service-plans/{id}/meters", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:POST:/service-plans/{id}/meters", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:POST:/service-plans/{id}/meters/{meterId}", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:DELETE:/service-plans/{id}/meters/{meterId}", + "type": "contains" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", "to": "api:HTTP:GET:/service-plans/{id}/addons", @@ -36363,6 +37236,11 @@ "to": "api:HTTP:GET:/subscriptions/{subscriptionId}", "type": "contains" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/subscriptions/{subscriptionId}/meters", + "type": "contains" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", "to": "api:HTTP:POST:/subscriptions/{subscriptionId}/cancel", @@ -36573,6 +37451,31 @@ "to": "api:HTTP:POST:/admin/usage/record", "type": "contains" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/admin/billing/subscriptions/{subscriptionId}/meters", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/admin/billing/subscriptions/{subscriptionId}/meter-entries", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:POST:/admin/billing/subscriptions/{subscriptionId}/meter-entries", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:POST:/admin/billing/subscriptions/{subscriptionId}/meter-entries/{entryId}", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:DELETE:/admin/billing/subscriptions/{subscriptionId}/meter-entries/{entryId}", + "type": "contains" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", "to": "api:HTTP:GET:/admin/billing/summary", @@ -37058,6 +37961,21 @@ "to": "webhook-event:decabill-backend-feature-billing-manager:addon.deactivated", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "webhook-event:decabill-backend-feature-billing-manager:addon.meter_attached", + "type": "contains" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "webhook-event:decabill-backend-feature-billing-manager:addon.meter_detached", + "type": "contains" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "webhook-event:decabill-backend-feature-billing-manager:addon.meter_updated", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "webhook-event:decabill-backend-feature-billing-manager:addon.provision_failed", @@ -37153,6 +38071,21 @@ "to": "webhook-event:decabill-backend-feature-billing-manager:invoice.voided", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "webhook-event:decabill-backend-feature-billing-manager:meter.created", + "type": "contains" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "webhook-event:decabill-backend-feature-billing-manager:meter.deleted", + "type": "contains" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "webhook-event:decabill-backend-feature-billing-manager:meter.updated", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "webhook-event:decabill-backend-feature-billing-manager:milestone.created", @@ -37223,11 +38156,41 @@ "to": "webhook-event:decabill-backend-feature-billing-manager:project.updated", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "webhook-event:decabill-backend-feature-billing-manager:service_plan.meter_attached", + "type": "contains" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "webhook-event:decabill-backend-feature-billing-manager:service_plan.meter_detached", + "type": "contains" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "webhook-event:decabill-backend-feature-billing-manager:service_plan.meter_updated", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "webhook-event:decabill-backend-feature-billing-manager:service_plan.price_recalculated", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "webhook-event:decabill-backend-feature-billing-manager:service_type.meter_attached", + "type": "contains" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "webhook-event:decabill-backend-feature-billing-manager:service_type.meter_detached", + "type": "contains" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "webhook-event:decabill-backend-feature-billing-manager:service_type.meter_updated", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "webhook-event:decabill-backend-feature-billing-manager:subscription.cancel_scheduled", @@ -37328,6 +38291,21 @@ "to": "webhook-event:decabill-backend-feature-billing-manager:time_entry.updated", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "webhook-event:decabill-backend-feature-billing-manager:usage.deleted", + "type": "contains" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "webhook-event:decabill-backend-feature-billing-manager:usage.recorded", + "type": "contains" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "webhook-event:decabill-backend-feature-billing-manager:usage.updated", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "webhook-event:decabill-backend-feature-billing-manager:vat_id.validation_failed", @@ -43713,6 +44691,51 @@ "to": "concept:decabill-related-documentation", "type": "contains" }, + { + "from": "file:docs/decabill/features/usage-meters.md", + "to": "concept:decabill-usage-meters", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/usage-meters.md", + "to": "concept:decabill-catalog", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/usage-meters.md", + "to": "concept:decabill-attachments", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/usage-meters.md", + "to": "concept:decabill-recording-usage", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/usage-meters.md", + "to": "concept:decabill-collector-job-pull", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/usage-meters.md", + "to": "concept:decabill-subscription-views", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/usage-meters.md", + "to": "concept:decabill-invoice-charge-path-arrear", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/usage-meters.md", + "to": "concept:decabill-notifications", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/usage-meters.md", + "to": "concept:decabill-related-docs", + "type": "contains" + }, { "from": "file:docs/decabill/features/vat-and-tax-treatment.md", "to": "concept:decabill-vat-and-tax-treatment", @@ -45788,6 +46811,16 @@ "to": "api:HTTP:POST:/addons", "type": "implements" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/addons.controller.ts", + "to": "api:HTTP:GET:/addons/modules", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:GET:/addons/modules", + "type": "implements" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/addons.controller.ts", "to": "api:HTTP:GET:/addons/{id}", @@ -45818,6 +46851,96 @@ "to": "api:HTTP:DELETE:/addons/{id}", "type": "implements" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/addons.controller.ts", + "to": "api:HTTP:GET:/addons/{id}/meters", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:GET:/addons/{id}/meters", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/addons.controller.ts", + "to": "api:HTTP:POST:/addons/{id}/meters", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:POST:/addons/{id}/meters", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/addons.controller.ts", + "to": "api:HTTP:POST:/addons/{id}/meters/{meterId}", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:POST:/addons/{id}/meters/{meterId}", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/addons.controller.ts", + "to": "api:HTTP:DELETE:/addons/{id}/meters/{meterId}", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:DELETE:/addons/{id}/meters/{meterId}", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts", + "to": "api:HTTP:GET:/admin/billing/subscriptions/{subscriptionId}/meters", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:GET:/admin/billing/subscriptions/{subscriptionId}/meters", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts", + "to": "api:HTTP:GET:/admin/billing/subscriptions/{subscriptionId}/meter-entries", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:GET:/admin/billing/subscriptions/{subscriptionId}/meter-entries", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts", + "to": "api:HTTP:POST:/admin/billing/subscriptions/{subscriptionId}/meter-entries", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:POST:/admin/billing/subscriptions/{subscriptionId}/meter-entries", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts", + "to": "api:HTTP:POST:/admin/billing/subscriptions/{subscriptionId}/meter-entries/{entryId}", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:POST:/admin/billing/subscriptions/{subscriptionId}/meter-entries/{entryId}", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts", + "to": "api:HTTP:DELETE:/admin/billing/subscriptions/{subscriptionId}/meter-entries/{entryId}", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:DELETE:/admin/billing/subscriptions/{subscriptionId}/meter-entries/{entryId}", + "type": "implements" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts", "to": "api:HTTP:GET:/admin/billing/summary", @@ -46633,6 +47756,56 @@ "to": "api:HTTP:GET:/admin/billing/promotions/{id}/redemptions", "type": "implements" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-subscription-meters.controller.ts", + "to": "api:HTTP:GET:/admin/billing/subscriptions/{subscriptionId}/meters", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-subscription-meters.controller.ts", + "to": "api:HTTP:GET:/admin/billing/subscriptions/{subscriptionId}/meter-entries", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-subscription-meters.controller.ts", + "to": "api:HTTP:POST:/admin/billing/subscriptions/{subscriptionId}/meter-entries", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-subscription-meters.controller.ts", + "to": "api:HTTP:POST:/admin/billing/subscriptions/{subscriptionId}/meter-entries/{entryId}", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-subscription-meters.controller.ts", + "to": "api:HTTP:DELETE:/admin/billing/subscriptions/{subscriptionId}/meter-entries/{entryId}", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-subscription-meters.controller.ts", + "to": "api:HTTP:GET:/admin/billing/subscriptions", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-subscription-meters.controller.ts", + "to": "api:HTTP:POST:/admin/billing/subscriptions/{subscriptionId}/cancel", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-subscription-meters.controller.ts", + "to": "api:HTTP:POST:/admin/billing/subscriptions/{subscriptionId}/withdraw", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-subscription-meters.controller.ts", + "to": "api:HTTP:POST:/admin/billing/subscriptions/{subscriptionId}/instant-cancel", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-subscription-meters.controller.ts", + "to": "api:HTTP:POST:/admin/billing/subscriptions/{subscriptionId}/resume", + "type": "implements" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-usage.controller.ts", "to": "api:HTTP:POST:/admin/usage/record", @@ -46978,6 +48151,56 @@ "to": "api:HTTP:POST:/invoices/{subscriptionId}/ref/{invoiceRefId}/void", "type": "implements" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/meters.controller.ts", + "to": "api:HTTP:GET:/meters", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:GET:/meters", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/meters.controller.ts", + "to": "api:HTTP:POST:/meters", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:POST:/meters", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/meters.controller.ts", + "to": "api:HTTP:GET:/meters/{id}", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:GET:/meters/{id}", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/meters.controller.ts", + "to": "api:HTTP:POST:/meters/{id}", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:POST:/meters/{id}", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/meters.controller.ts", + "to": "api:HTTP:DELETE:/meters/{id}", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:DELETE:/meters/{id}", + "type": "implements" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/payments-webhook.controller.ts", "to": "api:HTTP:POST:/webhooks/payments/stripe", @@ -47168,6 +48391,46 @@ "to": "api:HTTP:DELETE:/service-plans/{id}", "type": "implements" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.ts", + "to": "api:HTTP:GET:/service-plans/{id}/meters", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:GET:/service-plans/{id}/meters", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.ts", + "to": "api:HTTP:POST:/service-plans/{id}/meters", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:POST:/service-plans/{id}/meters", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.ts", + "to": "api:HTTP:POST:/service-plans/{id}/meters/{meterId}", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:POST:/service-plans/{id}/meters/{meterId}", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.ts", + "to": "api:HTTP:DELETE:/service-plans/{id}/meters/{meterId}", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:DELETE:/service-plans/{id}/meters/{meterId}", + "type": "implements" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.ts", "to": "api:HTTP:GET:/service-plans/{id}/addons", @@ -47228,6 +48491,46 @@ "to": "api:HTTP:GET:/service-types/providers/{providerId}/locations", "type": "implements" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.ts", + "to": "api:HTTP:GET:/service-types/{id}/meters", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:GET:/service-types/{id}/meters", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.ts", + "to": "api:HTTP:POST:/service-types/{id}/meters", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:POST:/service-types/{id}/meters", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.ts", + "to": "api:HTTP:POST:/service-types/{id}/meters/{meterId}", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:POST:/service-types/{id}/meters/{meterId}", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.ts", + "to": "api:HTTP:DELETE:/service-types/{id}/meters/{meterId}", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:DELETE:/service-types/{id}/meters/{meterId}", + "type": "implements" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.ts", "to": "api:HTTP:GET:/service-types/{id}", @@ -47288,6 +48591,16 @@ "to": "api:HTTP:GET:/subscriptions/{subscriptionId}", "type": "implements" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/subscriptions.controller.ts", + "to": "api:HTTP:GET:/subscriptions/{subscriptionId}/meters", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:GET:/subscriptions/{subscriptionId}/meters", + "type": "implements" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/subscriptions.controller.ts", "to": "api:HTTP:POST:/subscriptions/{subscriptionId}/cancel", @@ -50378,6 +51691,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/addon.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/addons.controller.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/billing-admin.service.ts", @@ -50453,6 +51771,21 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/promotion-admin.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-subscription-meters.controller.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscriptions.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-subscription-meters.controller.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/usage.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-subscription-meters.controller.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-billing.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-usage.controller.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/usage.service.ts", @@ -50533,6 +51866,16 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscriptions.repository.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/meters.controller.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/meters.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/meters.controller.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/payments-webhook.controller.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/payment-orchestration.service.ts", @@ -50648,6 +51991,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/addon.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/addons.repository.ts", @@ -50683,6 +52031,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/provider-locations.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/subscription-items.controller.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-item-server.service.ts", @@ -50698,6 +52051,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-config-change.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/subscriptions.controller.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-billing.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/usage.controller.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/usage.service.ts", @@ -51568,6 +52926,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/billing-schedule.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-creation.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-billing.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-issuance.service.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/invoices.repository.ts", @@ -51818,6 +53181,131 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-tax-context.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-billing.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plans.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-billing.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plan-meters.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-billing.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-type-meters.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-billing.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/addon-meters.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-billing.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscription-addons.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-billing.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/usage-records.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscriptions.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plans.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscription-items.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscription-addons.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/addons.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/addon-meters.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/usage-records.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/usage.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/provider-module-registry.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/provider-registry.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/addon-module-registry.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/meters.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plan-meters.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/addon-meters.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-type-meters.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/usage-records.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/addon-module-registry.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/provider-registry.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/open-position-invoice.job-handler.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/users-billing-day.repository.ts", @@ -52343,6 +53831,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/billing-schedule.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-period-charge.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-billing.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-provisioning.job-handler.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscription-items.repository.ts", @@ -52558,6 +54051,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/ssh-executor.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-billing.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/tax-calculation.service.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/tax-rate-config.service.ts", @@ -52593,6 +54091,31 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plans.repository.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/usage.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plan-meters.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/usage.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-type-meters.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/usage.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/addon-meters.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/usage.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscription-addons.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/usage.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-billing.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/vat-id-validation.job-handler.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/vat-id-validation.service.ts", @@ -53298,6 +54821,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/price-recalc.job-handler.ts", "type": "injects" }, + { + "from": "file:apps/decabill/backend-billing-manager/src/queue/processors/billing-jobs.processor.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts", + "type": "injects" + }, { "from": "file:apps/decabill/backend-billing-manager/src/queue/processors/billing-jobs.processor.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/vat-id-validation.job-handler.ts", @@ -53738,6 +55266,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/addons.controller.ts", "type": "provides" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/meters.controller.ts", + "type": "provides" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/public-service-plan-offerings.controller.ts", @@ -53798,6 +55331,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts", "type": "provides" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-subscription-meters.controller.ts", + "type": "provides" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-customer-profiles.controller.ts", @@ -53918,6 +55456,16 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/addon.service.ts", "type": "provides" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.ts", + "type": "provides" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-billing.service.ts", + "type": "provides" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/addon-lifecycle.service.ts", @@ -53928,6 +55476,16 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/addon-module-registry.service.ts", "type": "provides" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/provider-module-registry.service.ts", + "type": "provides" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts", + "type": "provides" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/cloudflare-dns.service.ts", @@ -54468,6 +56026,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/addons.repository.ts", "type": "provides" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/addon-meters.repository.ts", + "type": "provides" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscription-addons.repository.ts", @@ -54483,6 +56046,16 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plans.repository.ts", "type": "provides" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plan-meters.repository.ts", + "type": "provides" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-type-meters.repository.ts", + "type": "provides" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-types.repository.ts", @@ -54508,6 +56081,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/public-withdrawal-requests.repository.ts", "type": "provides" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/meters.repository.ts", + "type": "provides" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/usage-records.repository.ts", @@ -54953,6 +56531,11 @@ "to": "api:HTTP:GET:/addons", "type": "calls" }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/addons.service.ts", + "to": "api:HTTP:GET:/addons/modules", + "type": "calls" + }, { "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/addons.service.ts", "to": "api:HTTP:GET:/addons/{id}", @@ -54973,6 +56556,26 @@ "to": "api:HTTP:DELETE:/addons/{id}", "type": "calls" }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/addons.service.ts", + "to": "api:HTTP:GET:/addons/{id}/meters", + "type": "calls" + }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/addons.service.ts", + "to": "api:HTTP:POST:/addons/{id}/meters", + "type": "calls" + }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/addons.service.ts", + "to": "api:HTTP:POST:/addons/{id}/meters/{meterId}", + "type": "calls" + }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/addons.service.ts", + "to": "api:HTTP:DELETE:/addons/{id}/meters/{meterId}", + "type": "calls" + }, { "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.ts", "to": "api:HTTP:GET:/admin/billing/summary", @@ -55118,6 +56721,26 @@ "to": "api:HTTP:GET:/admin/billing/datev-exports/{exportId}/download", "type": "calls" }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.ts", + "to": "api:HTTP:GET:/admin/billing/subscriptions/{subscriptionId}/meter-entries", + "type": "calls" + }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.ts", + "to": "api:HTTP:POST:/admin/billing/subscriptions/{subscriptionId}/meter-entries", + "type": "calls" + }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.ts", + "to": "api:HTTP:POST:/admin/billing/subscriptions/{subscriptionId}/meter-entries/{entryId}", + "type": "calls" + }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.ts", + "to": "api:HTTP:DELETE:/admin/billing/subscriptions/{subscriptionId}/meter-entries/{entryId}", + "type": "calls" + }, { "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-customer-profiles.service.ts", "to": "api:HTTP:GET:/admin/billing/customer-profiles", @@ -55358,6 +56981,31 @@ "to": "api:HTTP:POST:/invoices/{subscriptionId}", "type": "calls" }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/meters.service.ts", + "to": "api:HTTP:GET:/meters", + "type": "calls" + }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/meters.service.ts", + "to": "api:HTTP:GET:/meters/{id}", + "type": "calls" + }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/meters.service.ts", + "to": "api:HTTP:POST:/meters", + "type": "calls" + }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/meters.service.ts", + "to": "api:HTTP:POST:/meters/{id}", + "type": "calls" + }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/meters.service.ts", + "to": "api:HTTP:DELETE:/meters/{id}", + "type": "calls" + }, { "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/projects.service.ts", "to": "api:HTTP:GET:/projects", @@ -55448,6 +57096,26 @@ "to": "api:HTTP:DELETE:/service-plans/{id}", "type": "calls" }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-plans.service.ts", + "to": "api:HTTP:GET:/service-plans/{id}/meters", + "type": "calls" + }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-plans.service.ts", + "to": "api:HTTP:POST:/service-plans/{id}/meters", + "type": "calls" + }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-plans.service.ts", + "to": "api:HTTP:POST:/service-plans/{id}/meters/{meterId}", + "type": "calls" + }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-plans.service.ts", + "to": "api:HTTP:DELETE:/service-plans/{id}/meters/{meterId}", + "type": "calls" + }, { "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-types.service.ts", "to": "api:HTTP:GET:/service-types/providers", @@ -55483,6 +57151,26 @@ "to": "api:HTTP:DELETE:/service-types/{id}", "type": "calls" }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-types.service.ts", + "to": "api:HTTP:GET:/service-types/{id}/meters", + "type": "calls" + }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-types.service.ts", + "to": "api:HTTP:POST:/service-types/{id}/meters", + "type": "calls" + }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-types.service.ts", + "to": "api:HTTP:POST:/service-types/{id}/meters/{meterId}", + "type": "calls" + }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-types.service.ts", + "to": "api:HTTP:DELETE:/service-types/{id}/meters/{meterId}", + "type": "calls" + }, { "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/subscription-items.service.ts", "to": "api:HTTP:GET:/subscriptions/{subscriptionId}/items", @@ -55568,6 +57256,11 @@ "to": "api:HTTP:POST:/admin/usage/record", "type": "calls" }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/usage.service.ts", + "to": "api:HTTP:GET:/subscriptions/{subscriptionId}/meters", + "type": "calls" + }, { "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/agents.service.ts", "to": "api:HTTP:GET:/clients/{id}/agents", @@ -56268,6 +57961,11 @@ "to": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/invoices.service.ts", "type": "contains" }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters", + "to": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/meters.service.ts", + "type": "contains" + }, { "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/project-milestones", "to": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/project-milestones.service.ts", @@ -61448,6 +63146,31 @@ "to": "api:HTTP:POST:/service-types", "type": "documents" }, + { + "from": "concept:decabill-service-plans", + "to": "api:HTTP:GET:/service-types/{id}/meters", + "type": "documents" + }, + { + "from": "concept:decabill-service-plans", + "to": "api:HTTP:POST:/service-types/{id}/meters", + "type": "documents" + }, + { + "from": "concept:decabill-service-plans", + "to": "api:HTTP:GET:/service-types/{id}", + "type": "documents" + }, + { + "from": "concept:decabill-service-plans", + "to": "api:HTTP:POST:/service-types/{id}", + "type": "documents" + }, + { + "from": "concept:decabill-service-plans", + "to": "api:HTTP:DELETE:/service-types/{id}", + "type": "documents" + }, { "from": "concept:decabill-service-plans", "to": "api:HTTP:GET:/service-plans", @@ -61483,6 +63206,26 @@ "to": "api:HTTP:POST:/cloud-init-configs", "type": "documents" }, + { + "from": "concept:decabill-service-plans", + "to": "api:HTTP:GET:/meters", + "type": "documents" + }, + { + "from": "concept:decabill-service-plans", + "to": "api:HTTP:POST:/meters", + "type": "documents" + }, + { + "from": "concept:decabill-service-plans", + "to": "api:HTTP:GET:/service-plans/{id}/meters", + "type": "documents" + }, + { + "from": "concept:decabill-service-plans", + "to": "api:HTTP:POST:/service-plans/{id}/meters", + "type": "documents" + }, { "from": "concept:decabill-service-plans", "to": "api:HTTP:GET:/subscriptions", @@ -61708,6 +63451,16 @@ "to": "api:HTTP:GET:/subscriptions/{subscriptionId}/items/{itemId}/server-info", "type": "documents" }, + { + "from": "concept:decabill-usage-records", + "to": "api:HTTP:GET:/meters", + "type": "documents" + }, + { + "from": "concept:decabill-usage-records", + "to": "api:HTTP:POST:/meters", + "type": "documents" + }, { "from": "concept:decabill-usage-records", "to": "api:HTTP:GET:/subscriptions", @@ -61773,6 +63526,276 @@ "to": "api:HTTP:POST:/subscriptions", "type": "documents" }, + { + "from": "concept:decabill-catalog", + "to": "api:HTTP:GET:/meters", + "type": "documents" + }, + { + "from": "concept:decabill-catalog", + "to": "api:HTTP:POST:/meters", + "type": "documents" + }, + { + "from": "concept:decabill-catalog", + "to": "api:HTTP:GET:/meters/{id}", + "type": "documents" + }, + { + "from": "concept:decabill-catalog", + "to": "api:HTTP:POST:/meters/{id}", + "type": "documents" + }, + { + "from": "concept:decabill-catalog", + "to": "api:HTTP:DELETE:/meters/{id}", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:POST:/auth/register", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:GET:/service-types", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:POST:/service-types", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:GET:/service-types/providers", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:GET:/service-types/{id}/meters", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:POST:/service-types/{id}/meters", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:POST:/service-types/{id}/meters/{meterId}", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:DELETE:/service-types/{id}/meters/{meterId}", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:GET:/service-types/{id}", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:POST:/service-types/{id}", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:DELETE:/service-types/{id}", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:GET:/service-plans", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:POST:/service-plans", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:GET:/service-plans/{id}", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:POST:/service-plans/{id}", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:DELETE:/service-plans/{id}", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:GET:/meters", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:POST:/meters", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:GET:/addons", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:POST:/addons", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:GET:/addons/modules", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:GET:/addons/{id}", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:POST:/addons/{id}", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:DELETE:/addons/{id}", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:GET:/addons/{id}/meters", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:POST:/addons/{id}/meters", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:POST:/addons/{id}/meters/{meterId}", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:DELETE:/addons/{id}/meters/{meterId}", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:GET:/service-plans/{id}/meters", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:POST:/service-plans/{id}/meters", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:POST:/service-plans/{id}/meters/{meterId}", + "type": "documents" + }, + { + "from": "concept:decabill-attachments", + "to": "api:HTTP:DELETE:/service-plans/{id}/meters/{meterId}", + "type": "documents" + }, + { + "from": "concept:decabill-recording-usage", + "to": "api:HTTP:GET:/subscriptions", + "type": "documents" + }, + { + "from": "concept:decabill-recording-usage", + "to": "api:HTTP:POST:/subscriptions", + "type": "documents" + }, + { + "from": "concept:decabill-recording-usage", + "to": "api:HTTP:POST:/admin/usage/record", + "type": "documents" + }, + { + "from": "concept:decabill-recording-usage", + "to": "api:HTTP:GET:/admin/billing/subscriptions", + "type": "documents" + }, + { + "from": "concept:decabill-collector-job-pull", + "to": "api:HTTP:POST:/auth/register", + "type": "documents" + }, + { + "from": "concept:decabill-collector-job-pull", + "to": "api:HTTP:POST:/admin/usage/record", + "type": "documents" + }, + { + "from": "concept:decabill-subscription-views", + "to": "api:HTTP:GET:/meters", + "type": "documents" + }, + { + "from": "concept:decabill-subscription-views", + "to": "api:HTTP:POST:/meters", + "type": "documents" + }, + { + "from": "concept:decabill-subscription-views", + "to": "api:HTTP:GET:/subscriptions", + "type": "documents" + }, + { + "from": "concept:decabill-subscription-views", + "to": "api:HTTP:POST:/subscriptions", + "type": "documents" + }, + { + "from": "concept:decabill-subscription-views", + "to": "api:HTTP:GET:/admin/billing/subscriptions", + "type": "documents" + }, + { + "from": "concept:decabill-related-docs", + "to": "api:HTTP:GET:/service-types", + "type": "documents" + }, + { + "from": "concept:decabill-related-docs", + "to": "api:HTTP:POST:/service-types", + "type": "documents" + }, + { + "from": "concept:decabill-related-docs", + "to": "api:HTTP:GET:/addons", + "type": "documents" + }, + { + "from": "concept:decabill-related-docs", + "to": "api:HTTP:POST:/addons", + "type": "documents" + }, + { + "from": "concept:decabill-related-docs", + "to": "api:HTTP:GET:/subscriptions", + "type": "documents" + }, + { + "from": "concept:decabill-related-docs", + "to": "api:HTTP:POST:/subscriptions", + "type": "documents" + }, { "from": "concept:decabill-tax-modes", "to": "api:HTTP:POST:/admin/billing/tax/preview", @@ -63638,6 +65661,11 @@ "to": "domain:decabill", "type": "belongs_to" }, + { + "from": "file:docs/decabill/features/usage-meters.md", + "to": "domain:decabill", + "type": "belongs_to" + }, { "from": "file:docs/decabill/features/vat-and-tax-treatment.md", "to": "domain:decabill", @@ -67883,6 +69911,46 @@ "to": "domain:decabill", "type": "belongs_to" }, + { + "from": "concept:decabill-usage-meters", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-catalog", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-attachments", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-recording-usage", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-collector-job-pull", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-subscription-views", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-invoice-charge-path-arrear", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-related-docs", + "to": "domain:decabill", + "type": "belongs_to" + }, { "from": "concept:decabill-vat-and-tax-treatment", "to": "domain:decabill", diff --git a/libs/domains/decabill/backend/feature-billing-manager/README.md b/libs/domains/decabill/backend/feature-billing-manager/README.md index 64dbe511f..2b7e2d5c6 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/README.md +++ b/libs/domains/decabill/backend/feature-billing-manager/README.md @@ -86,9 +86,12 @@ Use `GET /customer-profile` to retrieve the profile and `POST /customer-profile` profile. The backend returns 400 if the profile is missing or incomplete. Required fields: first name, last name, email, address line, city, country. See `docs/billing-profile-required-for-order-spec.md` and `docs/sequence-subscription-order.mmd`. -Usage records can be posted to `POST /admin/usage/record` (admin JWT or `STATIC_API_KEY` only; customers cannot -self-report) and will be included in invoice creation if a `usagePayload` with `totalCost` or `usageCost` is present, -or when `units` and `unitPrice` are provided. +Usage meters (catalog + plan/addon attachments) are documented in `docs/decabill/features/usage-meters.md`. +Records are posted to `POST /admin/usage/record` (admin JWT or `STATIC_API_KEY` only; customers cannot +self-report). With meter attachments, provide `meterId`/`value`/`attachmentType`/`addonId`. Without attachments, +legacy `usagePayload` with `totalCost` or `usageCost` (or `units` and `unitPrice`) still applies. +Declared meters may set `collectionIntervalMs`; the `meter-collect` BullMQ job (every minute by default) pulls +samples via provider/addon `collectMeters` into usage history with `usageSource: collector`. ## Provider details @@ -98,6 +101,10 @@ or when `units` and `unitPrice` are provided. - `DYNAMIC_PAYMENT_PROCESSORS` - Comma-separated extra payment processor packages (critical; use with `DYNAMIC_PROVIDERS_FAIL_FAST=true` in production) - `DYNAMIC_BILLING_PROVIDER_METADATA` - Comma-separated packages exporting `providerMetadata` for the billing UI registry +- `DYNAMIC_BILLING_PROVIDER_MODULES` - Comma-separated runtime provider modules implementing `collectMeters` (optional `meters`) +- `DYNAMIC_ADDON_MODULES` - Addon lifecycle modules (`provision` / `teardown` / optional `collectMeters`) +- `BILLING_METER_COLLECT_ENABLED` - When `false`, disables the meter-collect coordinator (default `true`) +- `BILLING_METER_COLLECT_INTERVAL` - Coordinator interval ms (default `60000`) - `DYNAMIC_PROVIDERS_FAIL_FAST` - When `true`, abort startup if critical dynamic providers fail to load - `DYNAMIC_PROVIDER_PLUGIN_PATH` - Plugin root for post-build loading (compose default: `/var/lib/forepath/provider-plugins`) - `DYNAMIC_PROVIDER_PLUGIN_INSTALL` - Startup `npm install` targets into the plugin path diff --git a/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml b/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml index ac583139a..756b3f8c9 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml +++ b/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml @@ -347,6 +347,144 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + /service-types/{id}/meters: + get: + summary: List meters attached to a service type + operationId: listServiceTypeMeters + description: | + Returns attached meters for the service type. Provider-declared meters are synced + on read (ensured in catalog and sideloaded as required). Includes manual attachments. + parameters: + - $ref: '#/components/parameters/ServiceTypeId' + responses: + '200': + description: Attached meters + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AttachedMeterResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Service type not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + post: + summary: Attach a catalog meter to a service type (admin only) + operationId: attachServiceTypeMeter + parameters: + - $ref: '#/components/parameters/ServiceTypeId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AttachMeterDto' + responses: + '200': + description: Attached meter + content: + application/json: + schema: + $ref: '#/components/schemas/AttachedMeterResponse' + '400': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /service-types/{id}/meters/{meterId}: + post: + summary: Update service type meter unit-price override (admin only) + operationId: updateServiceTypeMeter + parameters: + - $ref: '#/components/parameters/ServiceTypeId' + - name: meterId + in: path + required: true + schema: { type: string, format: uuid } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateAttachedMeterDto' + responses: + '200': + description: Updated attachment + content: + application/json: + schema: + $ref: '#/components/schemas/AttachedMeterResponse' + '400': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + summary: Detach meter from service type (admin only) + description: Required (provider-sourced) meters cannot be detached. + operationId: detachServiceTypeMeter + parameters: + - $ref: '#/components/parameters/ServiceTypeId' + - name: meterId + in: path + required: true + schema: { type: string, format: uuid } + responses: + '204': + description: Detached + '400': + description: Required meter cannot be detached + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /service-types/{id}: get: summary: Get service type @@ -806,6 +944,91 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + /meters: + get: + summary: List usage meters (admin only) + operationId: listMeters + parameters: + - name: limit + in: query + schema: { type: integer, default: 10 } + - name: offset + in: query + schema: { type: integer, default: 0 } + responses: + '200': + description: Meters + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/MeterResponse' + post: + summary: Create usage meter (admin only) + operationId: createMeter + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateMeterDto' + responses: + '201': + description: Meter created + content: + application/json: + schema: + $ref: '#/components/schemas/MeterResponse' + /meters/{id}: + get: + summary: Get usage meter (admin only) + operationId: getMeter + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: Meter + content: + application/json: + schema: + $ref: '#/components/schemas/MeterResponse' + post: + summary: Update usage meter (admin only) + operationId: updateMeter + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateMeterDto' + responses: + '200': + description: Meter updated + content: + application/json: + schema: + $ref: '#/components/schemas/MeterResponse' + delete: + summary: Delete usage meter (admin only) + description: Blocked while plan links, addon links, or usage rows reference the meter. Prefer isActive=false. + operationId: deleteMeter + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + responses: + '204': + description: Meter deleted /addons: get: summary: List addons (admin only) @@ -860,10 +1083,172 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + /addons/modules: + get: + summary: List registered addon modules (admin only) + operationId: listAddonModules + description: | + Returns registered addon modules and their declared required meters. + Used by the admin UI to preview fixed meters before saving a module addon. + responses: + '200': + description: Registered addon modules + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AddonModuleDetail' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /addons/{id}: get: - summary: Get addon (admin only) - operationId: getAddon + summary: Get addon (admin only) + operationId: getAddon + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: Addon + content: + application/json: + schema: + $ref: '#/components/schemas/AddonResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + post: + summary: Update addon (admin only) + operationId: updateAddon + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateAddonDto' + responses: + '200': + description: Addon updated + content: + application/json: + schema: + $ref: '#/components/schemas/AddonResponse' + delete: + summary: Delete addon (admin only) + operationId: deleteAddon + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + responses: + '204': + description: Addon deleted + /addons/{id}/meters: + get: + summary: List meters attached to an addon + operationId: listAddonMeters + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: Attached meters + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AttachedMeterResponse' + post: + summary: Attach a meter to an addon + operationId: attachAddonMeter + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AttachMeterDto' + responses: + '200': + description: Attached meter + content: + application/json: + schema: + $ref: '#/components/schemas/AttachedMeterResponse' + /addons/{id}/meters/{meterId}: + post: + summary: Update addon meter unit-price override + operationId: updateAddonMeter + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + - name: meterId + in: path + required: true + schema: { type: string, format: uuid } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateAttachedMeterDto' + responses: + '200': + description: Updated attachment + content: + application/json: + schema: + $ref: '#/components/schemas/AttachedMeterResponse' + delete: + summary: Detach meter from addon + operationId: detachAddonMeter + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + - name: meterId + in: path + required: true + schema: { type: string, format: uuid } + responses: + '204': + description: Detached + /service-plans/{id}/meters: + get: + summary: List meters attached to a service plan + operationId: listServicePlanMeters parameters: - name: id in: path @@ -871,49 +1256,75 @@ paths: schema: { type: string, format: uuid } responses: '200': - description: Addon + description: Attached meters content: application/json: schema: - $ref: '#/components/schemas/AddonResponse' - '404': - description: Not found + type: array + items: + $ref: '#/components/schemas/AttachedMeterResponse' + post: + summary: Attach a meter to a service plan + operationId: attachServicePlanMeter + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AttachMeterDto' + responses: + '200': + description: Attached meter content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/AttachedMeterResponse' + /service-plans/{id}/meters/{meterId}: post: - summary: Update addon (admin only) - operationId: updateAddon + summary: Update plan meter unit-price override + operationId: updateServicePlanMeter parameters: - name: id in: path required: true schema: { type: string, format: uuid } + - name: meterId + in: path + required: true + schema: { type: string, format: uuid } requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/UpdateAddonDto' + $ref: '#/components/schemas/UpdateAttachedMeterDto' responses: '200': - description: Addon updated + description: Updated attachment content: application/json: schema: - $ref: '#/components/schemas/AddonResponse' + $ref: '#/components/schemas/AttachedMeterResponse' delete: - summary: Delete addon (admin only) - operationId: deleteAddon + summary: Detach meter from service plan + operationId: detachServicePlanMeter parameters: - name: id in: path required: true schema: { type: string, format: uuid } + - name: meterId + in: path + required: true + schema: { type: string, format: uuid } responses: '204': - description: Addon deleted + description: Detached /service-plans/{id}/addons: get: summary: List orderable addons for a plan @@ -1606,6 +2017,27 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + /subscriptions/{subscriptionId}/meters: + get: + summary: List meter summaries for a subscription + operationId: listSubscriptionMeters + parameters: + - $ref: '#/components/parameters/SubscriptionId' + responses: + '200': + description: Plan- and addon-scoped meter summaries for the current period + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SubscriptionMeterSummary' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /subscriptions/{subscriptionId}/cancel: post: summary: Cancel subscription @@ -2795,6 +3227,94 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + /admin/billing/subscriptions/{subscriptionId}/meters: + get: + tags: [Admin Billing] + summary: List subscription meter summaries (admin) + operationId: adminListSubscriptionMeters + parameters: + - $ref: '#/components/parameters/SubscriptionId' + responses: + '200': + description: Meter summaries + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SubscriptionMeterSummary' + /admin/billing/subscriptions/{subscriptionId}/meter-entries: + get: + tags: [Admin Billing] + summary: List metered usage entries for a subscription + operationId: adminListSubscriptionMeterEntries + parameters: + - $ref: '#/components/parameters/SubscriptionId' + responses: + '200': + description: Meter entries + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsageMeterEntryResponse' + post: + tags: [Admin Billing] + summary: Create a metered usage entry for a subscription + operationId: adminCreateSubscriptionMeterEntry + parameters: + - $ref: '#/components/parameters/SubscriptionId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSubscriptionMeterEntryDto' + responses: + '200': + description: Entry created + content: + application/json: + schema: + $ref: '#/components/schemas/UsageRecordResponse' + /admin/billing/subscriptions/{subscriptionId}/meter-entries/{entryId}: + post: + tags: [Admin Billing] + summary: Update a metered usage entry + operationId: adminUpdateSubscriptionMeterEntry + parameters: + - $ref: '#/components/parameters/SubscriptionId' + - name: entryId + in: path + required: true + schema: { type: string, format: uuid } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateUsageMeterEntryDto' + responses: + '200': + description: Updated entry + content: + application/json: + schema: + $ref: '#/components/schemas/UsageMeterEntryResponse' + delete: + tags: [Admin Billing] + summary: Delete a metered usage entry + operationId: adminDeleteSubscriptionMeterEntry + parameters: + - $ref: '#/components/parameters/SubscriptionId' + - name: entryId + in: path + required: true + schema: { type: string, format: uuid } + responses: + '204': + description: Entry deleted /admin/billing/summary: get: tags: [Admin Billing] @@ -5054,6 +5574,45 @@ components: description: | When true, plans for this provider may offer customer-selectable addons. Omitted or false means the provider lacks required provision/teardown or cloud-init append hooks (fail closed). + supportsServerTypeUpgrade: + type: boolean + description: When true, in-place server type upgrades are supported for this provider. + supportsServerTypeDowngrade: + type: boolean + description: When true, in-place server type downgrades are supported for this provider. + meters: + type: array + description: | + Required usage meters declared by the provider (or product) implementation. + Sideloaded onto service types as non-removable attachments (`source=provider`, `required=true`). + items: + $ref: '#/components/schemas/DeclaredMeterDefinition' + DeclaredMeterDefinition: + type: object + required: [key, name, aggregator, defaultUnitPriceNet] + description: Fixed meter declaration from a provider or addon module. + properties: + key: { type: string } + name: { type: string } + description: { type: string } + unitLabel: { type: string } + aggregator: + { + type: string, + enum: [max, min, avg, first, last, sum, sum_positive_deltas], + } + defaultUnitPriceNet: { type: number } + AddonModuleDetail: + type: object + required: [key, displayName, meters] + description: Registered addon module with optional declared required meters. + properties: + key: { type: string } + displayName: { type: string } + meters: + type: array + items: + $ref: '#/components/schemas/DeclaredMeterDefinition' ProviderEnvDefaultField: type: object required: [envKey, label, sensitive, type] @@ -5526,6 +6085,10 @@ components: updatedAt: { type: string, format: date-time } withdrawalPolicy: $ref: '#/components/schemas/WithdrawalPolicy' + meters: + type: array + items: + $ref: '#/components/schemas/AttachedMeterResponse' AvailabilityCheckDto: type: object required: [serviceTypeId, region, serverType] @@ -5694,6 +6257,11 @@ components: periodTotalPrice: type: number description: Total gross price charged per billing period for this subscription + meters: + type: array + description: Plan- and addon-scoped meter summaries for the current billing period + items: + $ref: '#/components/schemas/SubscriptionMeterSummary' createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } WithdrawSubscriptionDto: @@ -6080,9 +6648,14 @@ components: isActive: { type: boolean } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } + meters: + type: array + items: + $ref: '#/components/schemas/AttachedMeterResponse' PlanAddonOption: type: object - required: [id, key, name, implementationType, periodPrice, orderFields] + required: + [id, key, name, implementationType, periodPrice, orderFields, meters] properties: id: { type: string, format: uuid } key: { type: string } @@ -6098,6 +6671,10 @@ components: type: array items: $ref: '#/components/schemas/CloudInitConfigOrderField' + meters: + type: array + items: + $ref: '#/components/schemas/AttachedMeterResponse' CreateAddonDto: type: object required: [key, name, implementationType] @@ -6392,12 +6969,217 @@ components: usagePayload: { type: object } CreateUsageRecordDto: type: object - required: [subscriptionId, periodStart, periodEnd, usagePayload] + required: [subscriptionId, periodStart, periodEnd] + properties: + subscriptionId: { type: string, format: uuid } + periodStart: { type: string, format: date-time } + periodEnd: { type: string, format: date-time } + usagePayload: + type: object + description: Legacy payload cost fields when the subscription has no meter attachments + meterId: + type: string + format: uuid + description: Required when the subscription has plan or addon meter attachments + value: + type: number + description: Metered value; required when meterId is set + attachmentType: + type: string + enum: [plan, addon] + default: plan + addonId: + type: string + format: uuid + description: Required when attachmentType is addon + UpdateUsageMeterEntryDto: + type: object + properties: + periodStart: { type: string, format: date-time } + periodEnd: { type: string, format: date-time } + value: { type: number, minimum: 0 } + usagePayload: { type: object } + attachmentType: { type: string, enum: [plan, addon] } + meterId: { type: string, format: uuid } + addonId: { type: string, format: uuid, nullable: true } + CreateSubscriptionMeterEntryDto: + type: object + required: [periodStart, periodEnd] + properties: + periodStart: { type: string, format: date-time } + periodEnd: { type: string, format: date-time } + usagePayload: { type: object } + meterId: { type: string, format: uuid } + value: { type: number } + attachmentType: { type: string, enum: [plan, addon], default: plan } + addonId: { type: string, format: uuid } + MeterResponse: + type: object + required: + [ + id, + key, + name, + aggregator, + defaultUnitPriceNet, + isActive, + createdAt, + updatedAt, + ] + properties: + id: { type: string, format: uuid } + key: { type: string } + name: { type: string } + description: { type: string, nullable: true } + unitLabel: { type: string, nullable: true } + aggregator: + { + type: string, + enum: [max, min, avg, first, last, sum, sum_positive_deltas], + } + defaultUnitPriceNet: { type: number } + isActive: { type: boolean } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + CreateMeterDto: + type: object + required: [key, name, aggregator, defaultUnitPriceNet] + properties: + key: { type: string } + name: { type: string } + description: { type: string } + unitLabel: { type: string } + aggregator: + { + type: string, + enum: [max, min, avg, first, last, sum, sum_positive_deltas], + } + defaultUnitPriceNet: { type: number } + isActive: { type: boolean } + UpdateMeterDto: + type: object + properties: + name: { type: string } + description: { type: string, nullable: true } + unitLabel: { type: string, nullable: true } + aggregator: + { + type: string, + enum: [max, min, avg, first, last, sum, sum_positive_deltas], + } + defaultUnitPriceNet: { type: number } + isActive: { type: boolean } + AttachMeterDto: + type: object + required: [meterId] + properties: + meterId: { type: string, format: uuid } + unitPriceNet: { type: number, nullable: true } + UpdateAttachedMeterDto: + type: object + properties: + unitPriceNet: { type: number, nullable: true } + AttachedMeterResponse: + type: object + required: + [ + meterId, + key, + name, + aggregator, + defaultUnitPriceNet, + effectiveUnitPriceNet, + isActive, + source, + required, + ] + properties: + meterId: { type: string, format: uuid } + key: { type: string } + name: { type: string } + description: { type: string, nullable: true } + unitLabel: { type: string, nullable: true } + aggregator: + { + type: string, + enum: [max, min, avg, first, last, sum, sum_positive_deltas], + } + defaultUnitPriceNet: { type: number } + unitPriceNetOverride: { type: number, nullable: true } + effectiveUnitPriceNet: { type: number } + isActive: { type: boolean } + source: + type: string + enum: [manual, module, provider] + description: How the attachment was created. + required: + type: boolean + description: When true, detach is rejected. + inherited: + type: boolean + description: | + True when listed on a plan but sourced from the plan's service type + (not a direct plan attachment). + SubscriptionMeterSummary: + type: object + required: + [ + meterId, + key, + name, + aggregator, + attachmentType, + effectiveUnitPriceNet, + aggregatedValue, + estimatedChargeNet, + entryCount, + ] + properties: + meterId: { type: string, format: uuid } + key: { type: string } + name: { type: string } + unitLabel: { type: string, nullable: true } + aggregator: + { + type: string, + enum: [max, min, avg, first, last, sum, sum_positive_deltas], + } + attachmentType: { type: string, enum: [plan, addon] } + addonId: { type: string, format: uuid, nullable: true } + addonName: { type: string, nullable: true } + effectiveUnitPriceNet: { type: number } + aggregatedValue: { type: number } + estimatedChargeNet: { type: number } + entryCount: { type: integer } + periodStart: { type: string, format: date-time, nullable: true } + periodEnd: { type: string, format: date-time, nullable: true } + UsageMeterEntryResponse: + type: object + required: + [ + id, + subscriptionId, + meterId, + value, + attachmentType, + periodStart, + periodEnd, + usageSource, + usagePayload, + createdAt, + ] properties: + id: { type: string, format: uuid } subscriptionId: { type: string, format: uuid } + meterId: { type: string, format: uuid } + value: { type: number } + attachmentType: { type: string, enum: [plan, addon] } + addonId: { type: string, format: uuid, nullable: true } periodStart: { type: string, format: date-time } periodEnd: { type: string, format: date-time } + usageSource: { type: string } usagePayload: { type: object } + createdAt: { type: string, format: date-time } UsageRecordResponse: type: object properties: diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/index.ts b/libs/domains/decabill/backend/feature-billing-manager/src/index.ts index deaa512fa..6151a6d77 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/index.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/index.ts @@ -24,6 +24,8 @@ export * from './lib/controllers/public-withdrawal.controller'; export * from './lib/controllers/service-plans.controller'; export * from './lib/controllers/cloud-init-configs.controller'; export * from './lib/controllers/addons.controller'; +export * from './lib/controllers/meters.controller'; +export * from './lib/controllers/admin-subscription-meters.controller'; export * from './lib/controllers/service-types.controller'; export * from './lib/controllers/subscriptions.controller'; export * from './lib/controllers/usage.controller'; @@ -44,6 +46,8 @@ export * from './lib/dto/update-cloud-init-config.dto'; export * from './lib/dto/addon-response.dto'; export * from './lib/dto/create-addon.dto'; export * from './lib/dto/update-addon.dto'; +export * from './lib/dto/meter.dto'; +export * from './lib/dto/meter-response.dto'; export * from './lib/dto/create-service-plan.dto'; export * from './lib/dto/service-plan-ordering-highlight.dto'; export * from './lib/dto/create-service-type.dto'; @@ -90,8 +94,15 @@ export * from './lib/entities/provider-price-snapshot.entity'; export * from './lib/entities/reserved-hostname.entity'; export * from './lib/entities/cloud-init-config.entity'; export * from './lib/entities/addon.entity'; +export * from './lib/entities/addon-meter.entity'; +export * from './lib/entities/service-type-meter.entity'; +export * from './lib/constants/meter-attachment.constants'; +export * from './lib/dto/declared-meter.dto'; +export * from './lib/dto/meter-collect.types'; export * from './lib/entities/subscription-addon.entity'; export * from './lib/entities/subscription-config-change.entity'; +export * from './lib/entities/meter.entity'; +export * from './lib/entities/service-plan-meter.entity'; export * from './lib/entities/service-plan.entity'; export * from './lib/entities/service-type.entity'; export * from './lib/entities/subscription-item.entity'; @@ -154,8 +165,12 @@ export * from './lib/repositories/reserved-hostnames.repository'; export * from './lib/repositories/users-billing-day.repository'; export * from './lib/repositories/cloud-init-configs.repository'; export * from './lib/repositories/addons.repository'; +export * from './lib/repositories/addon-meters.repository'; export * from './lib/repositories/subscription-addons.repository'; export * from './lib/repositories/subscription-config-changes.repository'; +export * from './lib/repositories/meters.repository'; +export * from './lib/repositories/service-plan-meters.repository'; +export * from './lib/repositories/service-type-meters.repository'; export * from './lib/repositories/service-plans.repository'; export * from './lib/repositories/service-types.repository'; export * from './lib/repositories/subscription-items.repository'; @@ -167,8 +182,12 @@ export * from './lib/services/cloud-init-config.service'; export * from './lib/services/addon.service'; export * from './lib/services/addon-lifecycle.service'; export * from './lib/services/addon-module-registry.service'; +export * from './lib/services/provider-module-registry.service'; +export * from './lib/services/meter.service'; +export * from './lib/services/meter-billing.service'; export * from './lib/utils/plan-addons.utils'; export * from './lib/utils/addon-pricing.util'; +export * from './lib/utils/meter-aggregation.util'; export * from './lib/services/backorder.service'; export * from './lib/services/billing-schedule.service'; export * from './lib/services/cancellation-policy.service'; @@ -212,6 +231,7 @@ export * from './lib/constants/datev-export.constants'; export * from './lib/services/datev-export-config.service'; export * from './lib/services/datev-export.job-handler'; export * from './lib/services/price-recalc.job-handler'; +export * from './lib/services/meter-collect.job-handler'; export * from './lib/services/vat-id-validation.job-handler'; export * from './lib/queue/billing-queue.constants'; export * from './lib/notifications/billing-notification.events'; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts index 889259e33..3d4740346 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts @@ -85,6 +85,8 @@ import { ServiceTypesController } from './controllers/service-types.controller'; import { SubscriptionItemsController } from './controllers/subscription-items.controller'; import { SubscriptionsController } from './controllers/subscriptions.controller'; import { AdminUsageController } from './controllers/admin-usage.controller'; +import { AdminSubscriptionMetersController } from './controllers/admin-subscription-meters.controller'; +import { MetersController } from './controllers/meters.controller'; import { UsageController } from './controllers/usage.controller'; import { AvailabilitySnapshotEntity } from './entities/availability-snapshot.entity'; import { BackorderEntity } from './entities/backorder.entity'; @@ -109,13 +111,17 @@ import { ProviderPriceSnapshotEntity } from './entities/provider-price-snapshot. import { PublicWithdrawalRequestEntity } from './entities/public-withdrawal-request.entity'; import { ReservedHostnameEntity } from './entities/reserved-hostname.entity'; import { AddonEntity } from './entities/addon.entity'; +import { AddonMeterEntity } from './entities/addon-meter.entity'; import { CloudInitConfigEntity } from './entities/cloud-init-config.entity'; import { SubscriptionAddonEntity } from './entities/subscription-addon.entity'; import { SubscriptionConfigChangeEntity } from './entities/subscription-config-change.entity'; import { ServicePlanEntity } from './entities/service-plan.entity'; +import { ServicePlanMeterEntity } from './entities/service-plan-meter.entity'; +import { ServiceTypeMeterEntity } from './entities/service-type-meter.entity'; import { ServiceTypeEntity } from './entities/service-type.entity'; import { SubscriptionItemEntity } from './entities/subscription-item.entity'; import { SubscriptionEntity } from './entities/subscription.entity'; +import { MeterEntity } from './entities/meter.entity'; import { UsageRecordEntity } from './entities/usage-record.entity'; import { BillingStatusGateway } from './gateways/billing-status.gateway'; import { DatevExportEnabledGuard } from './guards/datev-export-enabled.guard'; @@ -153,14 +159,18 @@ import { PaymentWebhookEventsRepository } from './repositories/payment-webhook-e import { ProviderPriceSnapshotsRepository } from './repositories/provider-price-snapshots.repository'; import { ReservedHostnamesRepository } from './repositories/reserved-hostnames.repository'; import { AddonsRepository } from './repositories/addons.repository'; +import { AddonMetersRepository } from './repositories/addon-meters.repository'; import { CloudInitConfigsRepository } from './repositories/cloud-init-configs.repository'; import { SubscriptionAddonsRepository } from './repositories/subscription-addons.repository'; import { SubscriptionConfigChangesRepository } from './repositories/subscription-config-changes.repository'; import { ServicePlansRepository } from './repositories/service-plans.repository'; +import { ServicePlanMetersRepository } from './repositories/service-plan-meters.repository'; +import { ServiceTypeMetersRepository } from './repositories/service-type-meters.repository'; import { ServiceTypesRepository } from './repositories/service-types.repository'; import { SubscriptionItemsRepository } from './repositories/subscription-items.repository'; import { SubscriptionsRepository } from './repositories/subscriptions.repository'; import { PublicWithdrawalRequestsRepository } from './repositories/public-withdrawal-requests.repository'; +import { MetersRepository } from './repositories/meters.repository'; import { UsageRecordsRepository } from './repositories/usage-records.repository'; import { UsersBillingDayRepository } from './repositories/users-billing-day.repository'; import { AdminBillNowService } from './services/admin-bill-now.service'; @@ -179,10 +189,14 @@ import { WithdrawalPolicyService } from './services/withdrawal-policy.service'; import { WithdrawalRefundService } from './services/withdrawal-refund.service'; import { SubscriptionTeardownService } from './services/subscription-teardown.service'; import { AddonModuleRegistryService } from './services/addon-module-registry.service'; +import { createBuiltinProviderModules } from './services/builtin-provider-modules'; +import { MeterCollectJobHandler } from './services/meter-collect.job-handler'; +import { ProviderModuleRegistryService } from './services/provider-module-registry.service'; import { AddonLifecycleService } from './services/addon-lifecycle.service'; import { AddonService } from './services/addon.service'; import { CloudInitConfigService } from './services/cloud-init-config.service'; import type { BillingAddonModule } from './services/addon-module-registry.service'; +import type { BillingProviderModule } from './services/provider-module-registry.service'; import { CloudflareDnsService } from './services/cloudflare-dns.service'; import { CustomerProfilesService } from './services/customer-profiles.service'; import { CustomerProfilesAdminService } from './services/customer-profiles-admin.service'; @@ -251,6 +265,8 @@ import { OssThresholdService } from './services/oss-threshold.service'; import { InvoiceTaxContextService } from './services/invoice-tax-context.service'; import { TaxPreviewService } from './services/tax-preview.service'; import { UsageService } from './services/usage.service'; +import { MeterService } from './services/meter.service'; +import { MeterBillingService } from './services/meter-billing.service'; import { applyProviderConfigFieldScopes } from './utils/provider-config-schema.utils'; import { DIGITALOCEAN_ENV_DEFAULT_FIELDS, HETZNER_ENV_DEFAULT_FIELDS } from './utils/provider-env-defaults.utils'; import { CustomerTrustScoreService } from './trust-score/customer-trust-score.service'; @@ -451,13 +467,17 @@ const DIGITALOCEAN_CONFIG_SCHEMA: Record = { TypeOrmModule.forFeature([ ServiceTypeEntity, ServicePlanEntity, + ServicePlanMeterEntity, + ServiceTypeMeterEntity, CloudInitConfigEntity, AddonEntity, + AddonMeterEntity, SubscriptionAddonEntity, SubscriptionEntity, SubscriptionConfigChangeEntity, SubscriptionItemEntity, ReservedHostnameEntity, + MeterEntity, UsageRecordEntity, InvoiceEntity, InvoicePromotionApplicationEntity, @@ -498,6 +518,7 @@ const DIGITALOCEAN_CONFIG_SCHEMA: Record = { ServiceTypesController, CloudInitConfigsController, AddonsController, + MetersController, PublicServicePlanOfferingsController, PublicWithdrawalController, ServicePlansController, @@ -510,6 +531,7 @@ const DIGITALOCEAN_CONFIG_SCHEMA: Record = { PromotionsController, AdminPromotionsController, AdminBillingController, + AdminSubscriptionMetersController, AdminCustomerProfilesController, AdminCustomerAutoBillingController, AdminDatevExportsController, @@ -536,8 +558,12 @@ const DIGITALOCEAN_CONFIG_SCHEMA: Record = { SubscriptionPeriodChargeService, CloudInitConfigService, AddonService, + MeterService, + MeterBillingService, AddonLifecycleService, AddonModuleRegistryService, + ProviderModuleRegistryService, + MeterCollectJobHandler, CloudflareDnsService, DigitaloceanProvisioningService, HostnameReservationService, @@ -709,14 +735,18 @@ const DIGITALOCEAN_CONFIG_SCHEMA: Record = { ProviderPriceSnapshotsRepository, CloudInitConfigsRepository, AddonsRepository, + AddonMetersRepository, SubscriptionAddonsRepository, SubscriptionConfigChangesRepository, ServicePlansRepository, + ServicePlanMetersRepository, + ServiceTypeMetersRepository, ServiceTypesRepository, ReservedHostnamesRepository, SubscriptionItemsRepository, SubscriptionsRepository, PublicWithdrawalRequestsRepository, + MetersRepository, UsageRecordsRepository, CustomerProfilesRepository, DatevExportRepository, @@ -784,6 +814,7 @@ const DIGITALOCEAN_CONFIG_SCHEMA: Record = { SubscriptionProvisioningJobHandler, SubscriptionConfigChangeJobHandler, PriceRecalcJobHandler, + MeterCollectJobHandler, SubscriptionRenewalReminderJobHandler, OpenPositionInvoiceJobHandler, SubscriptionItemUpdateJobHandler, @@ -808,6 +839,7 @@ const DIGITALOCEAN_CONFIG_SCHEMA: Record = { VatIdValidationJobHandler, DatevExportConfigService, ProviderRegistryService, + ProviderModuleRegistryService, BillingIdentityEmailBridgeModule, BillingIdentityNotificationBridgeModule, BillingUpdatesModule, @@ -816,6 +848,7 @@ const DIGITALOCEAN_CONFIG_SCHEMA: Record = { export class BillingModule implements OnModuleInit { constructor( private readonly providerRegistry: ProviderRegistryService, + private readonly providerModuleRegistry: ProviderModuleRegistryService, private readonly dynamicLoader: DynamicProviderLoaderService, private readonly trustScoreProviderRegistry: TrustScoreProviderRegistry, private readonly internalBillingTrustScoreProvider: InternalBillingTrustScoreProvider, @@ -842,6 +875,10 @@ export class BillingModule implements OnModuleInit { supportsServerTypeDowngrade: true, }); + for (const module of createBuiltinProviderModules()) { + this.providerModuleRegistry.register(module); + } + await registerDynamicProviderMetadata({ envKey: 'DYNAMIC_BILLING_PROVIDER_METADATA', criticality: 'optional', @@ -850,6 +887,14 @@ export class BillingModule implements OnModuleInit { loggerContext: 'ProviderRegistryService', }); + await registerDynamicProviders({ + envKey: 'DYNAMIC_BILLING_PROVIDER_MODULES', + criticality: 'optional', + register: (module) => this.providerModuleRegistry.register(module), + dynamicLoader: this.dynamicLoader, + loggerContext: 'ProviderModuleRegistryService', + }); + await registerDynamicProviders({ envKey: 'DYNAMIC_ADDON_MODULES', criticality: 'optional', diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/meter-attachment.constants.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/meter-attachment.constants.ts new file mode 100644 index 000000000..71e92f09d --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/meter-attachment.constants.ts @@ -0,0 +1,3 @@ +export type MeterAttachmentSource = 'manual' | 'module' | 'provider'; + +export const METER_ATTACHMENT_SOURCES: MeterAttachmentSource[] = ['manual', 'module', 'provider']; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/addons.controller.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/addons.controller.spec.ts index 73fab8ae4..1dad14f6a 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/addons.controller.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/addons.controller.spec.ts @@ -3,6 +3,7 @@ import { Test } from '@nestjs/testing'; import { AddonsRepository } from '../repositories/addons.repository'; import { AddonService } from '../services/addon.service'; +import { MeterService } from '../services/meter.service'; import { BillingIntervalType } from '../entities/service-plan.entity'; import { AddonsController } from './addons.controller'; @@ -36,6 +37,16 @@ describe('AddonsController', () => { providers: [ { provide: AddonsRepository, useValue: repository }, { provide: AddonService, useValue: addonService }, + { + provide: MeterService, + useValue: { + listAddonMeters: jest.fn().mockResolvedValue([]), + attachAddonMeter: jest.fn(), + updateAddonMeter: jest.fn(), + detachAddonMeter: jest.fn(), + syncAddonModuleMeters: jest.fn().mockResolvedValue([]), + }, + }, ], }).compile(); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/addons.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/addons.controller.ts index 26c27fdb2..7b1e0d6f4 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/addons.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/addons.controller.ts @@ -16,11 +16,14 @@ import { import { AddonResponseDto } from '../dto/addon-response.dto'; import { CreateAddonDto } from '../dto/create-addon.dto'; +import { AttachMeterDto, UpdateAttachedMeterDto } from '../dto/meter.dto'; +import { AttachedMeterResponseDto } from '../dto/meter-response.dto'; import { UpdateAddonDto } from '../dto/update-addon.dto'; import { AddonEntity } from '../entities/addon.entity'; import { BillingIntervalType } from '../entities/service-plan.entity'; import { AddonsRepository } from '../repositories/addons.repository'; import { AddonService } from '../services/addon.service'; +import { MeterService } from '../services/meter.service'; import { interpolateAddonScriptTemplate, parseAddonConfigFields } from '../utils/addon-config.utils'; @Controller('addons') @@ -29,6 +32,7 @@ export class AddonsController { constructor( private readonly addonsRepository: AddonsRepository, private readonly addonService: AddonService, + private readonly meterService: MeterService, ) {} @Get() @@ -40,7 +44,75 @@ export class AddonsController { ): Promise { const rows = await this.addonsRepository.findAll(limit ?? 10, offset ?? 0); - return rows.map((row) => this.mapToResponse(row, false)); + return await Promise.all(rows.map((row) => this.mapToResponse(row, false))); + } + + @Get('modules') + @KeycloakRoles(UserRole.ADMIN) + @UsersRoles(UserRole.ADMIN) + @RequireScopes('catalog:read') + async listModules(): Promise< + Array<{ + key: string; + displayName: string; + meters: Array<{ + key: string; + name: string; + description?: string; + unitLabel?: string; + aggregator: string; + defaultUnitPriceNet: number; + }>; + }> + > { + return this.addonService.listRegisteredModules(); + } + + @Get(':id/meters') + @KeycloakRoles(UserRole.ADMIN) + @UsersRoles(UserRole.ADMIN) + @RequireScopes('catalog:read') + async listMeters(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string): Promise { + await this.addonsRepository.findByIdOrThrow(id); + + return await this.meterService.listAddonMeters(id); + } + + @Post(':id/meters') + @KeycloakRoles(UserRole.ADMIN) + @UsersRoles(UserRole.ADMIN) + async attachMeter( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: AttachMeterDto, + ): Promise { + await this.addonsRepository.findByIdOrThrow(id); + + return await this.meterService.attachAddonMeter(id, dto.meterId, dto.unitPriceNet); + } + + @Post(':id/meters/:meterId') + @KeycloakRoles(UserRole.ADMIN) + @UsersRoles(UserRole.ADMIN) + async updateMeterAttachment( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Param('meterId', new ParseUUIDPipe({ version: '4' })) meterId: string, + @Body() dto: UpdateAttachedMeterDto, + ): Promise { + await this.addonsRepository.findByIdOrThrow(id); + + return await this.meterService.updateAddonMeter(id, meterId, dto.unitPriceNet); + } + + @Delete(':id/meters/:meterId') + @KeycloakRoles(UserRole.ADMIN) + @UsersRoles(UserRole.ADMIN) + @HttpCode(HttpStatus.NO_CONTENT) + async detachMeter( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Param('meterId', new ParseUUIDPipe({ version: '4' })) meterId: string, + ): Promise { + await this.addonsRepository.findByIdOrThrow(id); + await this.meterService.detachAddonMeter(id, meterId); } @Get(':id') @@ -49,7 +121,7 @@ export class AddonsController { async get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string): Promise { const row = await this.addonsRepository.findByIdOrThrow(id); - return this.mapToResponse(row, true); + return await this.mapToResponse(row, true); } @Post() @@ -89,7 +161,9 @@ export class AddonsController { isActive: dto.isActive ?? true, }); - return this.mapToResponse(row, true); + await this.meterService.syncAddonModuleMeters(row); + + return await this.mapToResponse(row, true); } @Post(':id') @@ -163,7 +237,7 @@ export class AddonsController { } if (dto.priceIntervalType !== undefined) { - updatePayload.priceIntervalType = dto.priceIntervalType; + updatePayload.priceIntervalType = (dto.priceIntervalType as BillingIntervalType) ?? null; } if (dto.priceIntervalValue !== undefined) { @@ -180,7 +254,9 @@ export class AddonsController { const row = await this.addonsRepository.update(id, updatePayload); - return this.mapToResponse(row, true); + await this.meterService.syncAddonModuleMeters(row); + + return await this.mapToResponse(row, true); } @Delete(':id') @@ -212,7 +288,7 @@ export class AddonsController { } } - private mapToResponse(row: AddonEntity, includeDefaults: boolean): AddonResponseDto { + private async mapToResponse(row: AddonEntity, includeDefaults: boolean): Promise { return { id: row.id, key: row.key, @@ -228,6 +304,7 @@ export class AddonsController { basePrice: row.basePrice ?? null, priceIntervalType: row.priceIntervalType ?? null, priceIntervalValue: row.priceIntervalValue ?? null, + meters: await this.meterService.listAddonMeters(row.id), isActive: row.isActive, createdAt: row.createdAt, updatedAt: row.updatedAt, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.spec.ts index 49d155cf4..32535e50b 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.spec.ts @@ -207,6 +207,7 @@ describe('AdminBillingController', () => { cancelRequestedAt: null, cancelEffectiveAt: null, resumedAt: null, + meters: [], createdAt, updatedAt: createdAt, }, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts index a94f0d40d..2a4f18314 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts @@ -47,7 +47,6 @@ import type { UpdateManualInvoiceDto, } from '../dto/manual-invoice.dto'; import { SubscriptionResponseDto } from '../dto/subscription-response.dto'; -import type { SubscriptionEntity } from '../entities/subscription.entity'; import { AdminBillNowService } from '../services/admin-bill-now.service'; import { BillingAdminService } from '../services/billing-admin.service'; import { BillingAuditLogService } from '../services/billing-audit-log.service'; @@ -120,9 +119,7 @@ export class AdminBillingController { @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, @Query('offset', new ParseIntPipe({ optional: true })) offset?: number, ): Promise { - const rows = await this.billingAdminService.listUserSubscriptions(userId, limit ?? 100, offset ?? 0); - - return rows.map((row) => this.mapSubscriptionToResponse(row)); + return await this.billingAdminService.listUserSubscriptions(userId, limit ?? 100, offset ?? 0); } @RequireScopes('billing_admin:read') @@ -453,24 +450,6 @@ export class AdminBillingController { }); } - private mapSubscriptionToResponse(row: SubscriptionEntity): SubscriptionResponseDto { - return { - id: row.id, - number: row.number, - planId: row.planId, - userId: row.userId, - status: row.status, - currentPeriodStart: row.currentPeriodStart, - currentPeriodEnd: row.currentPeriodEnd, - nextBillingAt: row.nextBillingAt, - cancelRequestedAt: row.cancelRequestedAt, - cancelEffectiveAt: row.cancelEffectiveAt, - resumedAt: row.resumedAt, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; - } - private parseDateRange(from?: string, to?: string): { fromDate: Date; toDate: Date } { const now = new Date(); const defaultTo = now.toISOString().slice(0, 10); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-subscription-meters.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-subscription-meters.controller.ts new file mode 100644 index 000000000..ea4a9b1ab --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-subscription-meters.controller.ts @@ -0,0 +1,122 @@ +import { KeycloakRoles, RequireScopes, UserRole, UsersRoles } from '@forepath/identity/backend'; +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Post, + Req, +} from '@nestjs/common'; + +import { CreateUsageRecordDto, UpdateUsageMeterEntryDto } from '../dto/create-usage-record.dto'; +import { SubscriptionMeterSummaryDto, UsageMeterEntryResponseDto } from '../dto/meter-response.dto'; +import { SubscriptionsRepository } from '../repositories/subscriptions.repository'; +import { MeterBillingService } from '../services/meter-billing.service'; +import { UsageService } from '../services/usage.service'; +import { ensureAdmin, getUserFromRequest, type RequestWithUser } from '../utils/billing-access.utils'; + +@Controller('admin/billing/subscriptions') +@KeycloakRoles(UserRole.ADMIN) +@UsersRoles(UserRole.ADMIN) +export class AdminSubscriptionMetersController { + constructor( + private readonly subscriptionsRepository: SubscriptionsRepository, + private readonly usageService: UsageService, + private readonly meterBillingService: MeterBillingService, + ) {} + + @Get(':id/meters') + @RequireScopes('usage:read') + async listMeters( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Req() req?: RequestWithUser, + ): Promise { + const userInfo = getUserFromRequest(req || ({} as RequestWithUser)); + ensureAdmin(userInfo); + const subscription = await this.subscriptionsRepository.findByIdOrThrow(id); + + return await this.meterBillingService.buildSubscriptionMeterSummaries({ + subscription, + periodStart: subscription.currentPeriodStart, + periodEnd: subscription.currentPeriodEnd, + }); + } + + @Get(':id/meter-entries') + @RequireScopes('usage:read') + async listEntries( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Req() req?: RequestWithUser, + ): Promise { + const userInfo = getUserFromRequest(req || ({} as RequestWithUser)); + ensureAdmin(userInfo); + await this.subscriptionsRepository.findByIdOrThrow(id); + + return await this.usageService.listMeterEntries(id); + } + + @Post(':id/meter-entries') + @RequireScopes('usage:write') + async createEntry( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() body: Omit, + @Req() req?: RequestWithUser, + ): Promise<{ id: string }> { + const userInfo = getUserFromRequest(req || ({} as RequestWithUser)); + + if (!userInfo.userId && !userInfo.isApiKeyAuth) { + throw new BadRequestException('User not authenticated'); + } + + ensureAdmin(userInfo); + await this.subscriptionsRepository.findByIdOrThrow(id); + + const record = await this.usageService.createUsage({ + subscriptionId: id, + periodStart: new Date(body.periodStart), + periodEnd: new Date(body.periodEnd), + usagePayload: body.usagePayload ?? {}, + usageSource: userInfo.isApiKeyAuth ? 'api-key' : 'admin', + meterId: body.meterId, + value: body.value, + attachmentType: body.attachmentType, + addonId: body.addonId, + }); + + return { id: record.id }; + } + + @Post(':id/meter-entries/:entryId') + @RequireScopes('usage:write') + async updateEntry( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Param('entryId', new ParseUUIDPipe({ version: '4' })) entryId: string, + @Body() body: UpdateUsageMeterEntryDto, + @Req() req?: RequestWithUser, + ): Promise { + const userInfo = getUserFromRequest(req || ({} as RequestWithUser)); + ensureAdmin(userInfo); + await this.subscriptionsRepository.findByIdOrThrow(id); + + return await this.usageService.updateMeterEntry(id, entryId, body); + } + + @Delete(':id/meter-entries/:entryId') + @RequireScopes('usage:write') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteEntry( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Param('entryId', new ParseUUIDPipe({ version: '4' })) entryId: string, + @Req() req?: RequestWithUser, + ): Promise { + const userInfo = getUserFromRequest(req || ({} as RequestWithUser)); + ensureAdmin(userInfo); + await this.subscriptionsRepository.findByIdOrThrow(id); + await this.usageService.deleteMeterEntry(id, entryId); + } +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-usage.controller.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-usage.controller.spec.ts index b171f9d46..a53b45983 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-usage.controller.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-usage.controller.spec.ts @@ -52,6 +52,10 @@ describe('AdminUsageController', () => { periodEnd: new Date(recordBody.periodEnd), usagePayload: recordBody.usagePayload, usageSource: 'admin', + meterId: undefined, + value: undefined, + attachmentType: undefined, + addonId: undefined, }); expect(result).toEqual({ id: 'record-1' }); }); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-usage.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-usage.controller.ts index 2db1d47f1..e46193c07 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-usage.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-usage.controller.ts @@ -33,6 +33,10 @@ export class AdminUsageController { periodEnd: new Date(body.periodEnd), usagePayload: body.usagePayload ?? {}, usageSource: userInfo.isApiKeyAuth ? 'api-key' : 'admin', + meterId: body.meterId, + value: body.value, + attachmentType: body.attachmentType, + addonId: body.addonId, }); return { id: record.id }; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/meters.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/meters.controller.ts new file mode 100644 index 000000000..81a05749a --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/meters.controller.ts @@ -0,0 +1,80 @@ +import { KeycloakRoles, RequireScopes, UserRole, UsersRoles } from '@forepath/identity/backend'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseIntPipe, + ParseUUIDPipe, + Post, + Query, +} from '@nestjs/common'; + +import { MeterResponseDto } from '../dto/meter-response.dto'; +import { CreateMeterDto, UpdateMeterDto } from '../dto/meter.dto'; +import { MetersRepository } from '../repositories/meters.repository'; +import { MeterService } from '../services/meter.service'; + +@Controller('meters') +@RequireScopes('catalog:write') +export class MetersController { + constructor( + private readonly metersRepository: MetersRepository, + private readonly meterService: MeterService, + ) {} + + @Get() + @KeycloakRoles(UserRole.ADMIN) + @UsersRoles(UserRole.ADMIN) + @RequireScopes('catalog:read') + async list( + @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, + @Query('offset', new ParseIntPipe({ optional: true })) offset?: number, + ): Promise { + const rows = await this.metersRepository.findAll(limit ?? 10, offset ?? 0); + + return rows.map((row) => this.meterService.mapMeterToResponse(row)); + } + + @Get(':id') + @KeycloakRoles(UserRole.ADMIN) + @UsersRoles(UserRole.ADMIN) + @RequireScopes('catalog:read') + async get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string): Promise { + const row = await this.metersRepository.findByIdOrThrow(id); + + return this.meterService.mapMeterToResponse(row); + } + + @Post() + @KeycloakRoles(UserRole.ADMIN) + @UsersRoles(UserRole.ADMIN) + async create(@Body() dto: CreateMeterDto): Promise { + const row = await this.meterService.createMeter(dto); + + return this.meterService.mapMeterToResponse(row); + } + + @Post(':id') + @KeycloakRoles(UserRole.ADMIN) + @UsersRoles(UserRole.ADMIN) + async update( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: UpdateMeterDto, + ): Promise { + const row = await this.meterService.updateMeter(id, dto); + + return this.meterService.mapMeterToResponse(row); + } + + @Delete(':id') + @KeycloakRoles(UserRole.ADMIN) + @UsersRoles(UserRole.ADMIN) + @HttpCode(HttpStatus.NO_CONTENT) + async delete(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string): Promise { + await this.meterService.deleteMeter(id); + } +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.spec.ts index a78023442..fefed4a9b 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.spec.ts @@ -10,6 +10,7 @@ import { ServiceTypesRepository } from '../repositories/service-types.repository import { ProviderRegistryService } from '../services/provider-registry.service'; import { AddonService } from '../services/addon.service'; import { CloudInitConfigService } from '../services/cloud-init-config.service'; +import { MeterService } from '../services/meter.service'; import { WithdrawalPolicyService } from '../services/withdrawal-policy.service'; import { AddonsRepository } from '../repositories/addons.repository'; import { PLAN_PRICE_MIGRATE_ENQUEUE } from '../queue/plan-price-migrate-enqueue.token'; @@ -113,6 +114,17 @@ describe('ServicePlansController', () => { { provide: ProviderRegistryService, useValue: providerRegistryStub }, { provide: CloudInitConfigService, useValue: cloudInitConfigServiceStub }, { provide: AddonService, useValue: addonServiceStub }, + { + provide: MeterService, + useValue: { + listPlanMeters: jest.fn().mockResolvedValue([]), + listEffectivePlanMeters: jest.fn().mockResolvedValue([]), + listAddonMeters: jest.fn().mockResolvedValue([]), + attachPlanMeter: jest.fn(), + updatePlanMeter: jest.fn(), + detachPlanMeter: jest.fn(), + }, + }, { provide: AddonsRepository, useValue: addonsRepositoryStub }, { provide: SubscriptionsRepository, useValue: subscriptionsRepositoryStub }, { provide: WithdrawalPolicyService, useValue: new WithdrawalPolicyService() }, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.ts index b997a6e75..3e45fa132 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.ts @@ -33,8 +33,11 @@ import { ServiceTypesRepository } from '../repositories/service-types.repository import { SubscriptionsRepository } from '../repositories/subscriptions.repository'; import { AddonService } from '../services/addon.service'; import { CloudInitConfigService } from '../services/cloud-init-config.service'; +import { MeterService } from '../services/meter.service'; import { ProviderRegistryService } from '../services/provider-registry.service'; import { WithdrawalPolicyService } from '../services/withdrawal-policy.service'; +import { AttachMeterDto, UpdateAttachedMeterDto } from '../dto/meter.dto'; +import { AttachedMeterResponseDto } from '../dto/meter-response.dto'; import { PLAN_PRICE_MIGRATE_ENQUEUE, type PlanPriceMigrateEnqueuePort, @@ -60,6 +63,7 @@ export class ServicePlansController { private readonly providerRegistry: ProviderRegistryService, private readonly cloudInitConfigService: CloudInitConfigService, private readonly addonService: AddonService, + private readonly meterService: MeterService, private readonly addonsRepository: AddonsRepository, private readonly subscriptionsRepository: SubscriptionsRepository, private readonly withdrawalPolicyService: WithdrawalPolicyService, @@ -99,6 +103,56 @@ export class ServicePlansController { return this.cloudInitConfigService.buildOrderProvisioningOptions(row.providerConfigDefaults ?? {}); } + @RequireScopes('subscriptions:read') + @Get(':id/meters') + async listPlanMeters( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + ): Promise { + const plan = await this.servicePlansRepository.findByIdOrThrow(id); + + return await this.meterService.listEffectivePlanMeters(id, plan.serviceTypeId); + } + + @RequireScopes('catalog:write') + @Post(':id/meters') + @KeycloakRoles(UserRole.ADMIN) + @UsersRoles(UserRole.ADMIN) + async attachPlanMeter( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: AttachMeterDto, + ): Promise { + await this.servicePlansRepository.findByIdOrThrow(id); + + return await this.meterService.attachPlanMeter(id, dto.meterId, dto.unitPriceNet); + } + + @RequireScopes('catalog:write') + @Post(':id/meters/:meterId') + @KeycloakRoles(UserRole.ADMIN) + @UsersRoles(UserRole.ADMIN) + async updatePlanMeter( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Param('meterId', new ParseUUIDPipe({ version: '4' })) meterId: string, + @Body() dto: UpdateAttachedMeterDto, + ): Promise { + await this.servicePlansRepository.findByIdOrThrow(id); + + return await this.meterService.updatePlanMeter(id, meterId, dto.unitPriceNet); + } + + @RequireScopes('catalog:write') + @Delete(':id/meters/:meterId') + @KeycloakRoles(UserRole.ADMIN) + @UsersRoles(UserRole.ADMIN) + @HttpCode(HttpStatus.NO_CONTENT) + async detachPlanMeter( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Param('meterId', new ParseUUIDPipe({ version: '4' })) meterId: string, + ): Promise { + await this.servicePlansRepository.findByIdOrThrow(id); + await this.meterService.detachPlanMeter(id, meterId); + } + @RequireScopes('subscriptions:read') @Get(':id/addons') async listOrderAddons(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string): Promise { @@ -122,12 +176,14 @@ export class ServicePlansController { const addons = await this.addonsRepository.findByIds(allowedIds); - return addons + const compatible = addons .filter((addon) => addon.isActive) .filter( (addon) => addon.compatibleProviders.length === 0 || addon.compatibleProviders.includes(serviceType.provider), - ) - .map((addon) => ({ + ); + + return await Promise.all( + compatible.map(async (addon) => ({ id: addon.id, key: addon.key, name: addon.name, @@ -138,7 +194,9 @@ export class ServicePlansController { priceIntervalValue: addon.priceIntervalValue ?? null, periodPrice: convertAddonPriceToPlanPeriod(addon, plan), orderFields: this.addonService.getOrderFieldsForAddon(addon), - })); + meters: await this.meterService.listAddonMeters(addon.id), + })), + ); } @RequireScopes('subscriptions:read') @@ -400,6 +458,7 @@ export class ServicePlansController { allowedServerTypes: normalizeAllowedServerTypes(row.allowedServerTypes), taxCategory: row.taxCategory ?? TaxCategory.STANDARD, withdrawalPolicy, + meters: await this.meterService.listEffectivePlanMeters(row.id, row.serviceTypeId), isActive: row.isActive, createdAt: row.createdAt, updatedAt: row.updatedAt, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.spec.ts index e3744117f..2104dfbd3 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.spec.ts @@ -2,6 +2,7 @@ import { Test } from '@nestjs/testing'; import { ServiceTypeEntity } from '../entities/service-type.entity'; import { ServiceTypesRepository } from '../repositories/service-types.repository'; +import { MeterService } from '../services/meter.service'; import { ProviderRegistryService } from '../services/provider-registry.service'; import { ProviderLocationsService } from '../services/provider-locations.service'; import { ProviderServerTypesService } from '../services/provider-server-types.service'; @@ -15,6 +16,13 @@ describe('ServiceTypesController', () => { const mockProviderLocations = { getLocations: jest.fn().mockResolvedValue([]), }; + const mockMeterService = { + syncServiceTypeProviderMeters: jest.fn().mockResolvedValue([]), + listServiceTypeMeters: jest.fn().mockResolvedValue([]), + attachServiceTypeMeter: jest.fn(), + updateServiceTypeMeter: jest.fn(), + detachServiceTypeMeter: jest.fn(), + }; const mockServiceTypeRow: ServiceTypeEntity = { id: '11111111-1111-4111-8111-111111111111', @@ -30,6 +38,12 @@ describe('ServiceTypesController', () => { updatedAt: new Date('2024-01-01T00:00:00Z'), }; + const meterProvider = { provide: MeterService, useValue: mockMeterService }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('getProviderServerTypes', () => { it('should return server types for a provider', async () => { const serverTypes = [{ id: 'cax11', name: 'CAX11', cores: 2, memory: 4, disk: 40, priceMonthly: 4.51 }]; @@ -43,6 +57,7 @@ describe('ServiceTypesController', () => { { provide: ProviderRegistryService, useValue: { getProviders: jest.fn() } }, { provide: ProviderServerTypesService, useValue: serverTypesService }, { provide: ProviderLocationsService, useValue: mockProviderLocations }, + meterProvider, ], }).compile(); const controller = moduleRef.get(ServiceTypesController); @@ -66,6 +81,7 @@ describe('ServiceTypesController', () => { { provide: ProviderRegistryService, useValue: { getProviders: jest.fn() } }, { provide: ProviderServerTypesService, useValue: serverTypesService }, { provide: ProviderLocationsService, useValue: mockProviderLocations }, + meterProvider, ], }).compile(); const controller = moduleRef.get(ServiceTypesController); @@ -91,6 +107,7 @@ describe('ServiceTypesController', () => { { provide: ProviderRegistryService, useValue: { getProviders: jest.fn() } }, { provide: ProviderServerTypesService, useValue: mockProviderServerTypes }, { provide: ProviderLocationsService, useValue: locationsService }, + meterProvider, ], }).compile(); const controller = moduleRef.get(ServiceTypesController); @@ -116,6 +133,7 @@ describe('ServiceTypesController', () => { { provide: ProviderRegistryService, useValue: providerRegistry }, { provide: ProviderServerTypesService, useValue: mockProviderServerTypes }, { provide: ProviderLocationsService, useValue: mockProviderLocations }, + meterProvider, ], }).compile(); const controller = moduleRef.get(ServiceTypesController); @@ -146,6 +164,7 @@ describe('ServiceTypesController', () => { { provide: ProviderRegistryService, useValue: providerRegistry }, { provide: ProviderServerTypesService, useValue: mockProviderServerTypes }, { provide: ProviderLocationsService, useValue: mockProviderLocations }, + meterProvider, ], }).compile(); const controller = moduleRef.get(ServiceTypesController); @@ -175,6 +194,7 @@ describe('ServiceTypesController', () => { { provide: ProviderRegistryService, useValue: { getProviders: jest.fn() } }, { provide: ProviderServerTypesService, useValue: mockProviderServerTypes }, { provide: ProviderLocationsService, useValue: mockProviderLocations }, + meterProvider, ], }).compile(); const controller = moduleRef.get(ServiceTypesController); @@ -190,6 +210,7 @@ describe('ServiceTypesController', () => { providerDefaults: { HETZNER_API_TOKEN: 'new-secret' }, }), ); + expect(mockMeterService.syncServiceTypeProviderMeters).toHaveBeenCalledWith(createdRow); expect(response.providerDefaultsConfigured).toEqual({ HETZNER_API_TOKEN: true }); expect(response).not.toHaveProperty('providerDefaults'); }); @@ -210,6 +231,7 @@ describe('ServiceTypesController', () => { { provide: ProviderRegistryService, useValue: { getProviders: jest.fn() } }, { provide: ProviderServerTypesService, useValue: mockProviderServerTypes }, { provide: ProviderLocationsService, useValue: mockProviderLocations }, + meterProvider, ], }).compile(); const controller = moduleRef.get(ServiceTypesController); @@ -221,6 +243,7 @@ describe('ServiceTypesController', () => { mockServiceTypeRow.id, expect.objectContaining({ providerDefaults: {} }), ); + expect(mockMeterService.syncServiceTypeProviderMeters).toHaveBeenCalledWith(updatedRow); expect(response.providerDefaultsConfigured).toEqual({ HETZNER_API_TOKEN: false }); expect(response).not.toHaveProperty('providerDefaults'); }); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.ts index 512c8f06c..db25f3834 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.ts @@ -14,6 +14,8 @@ import { } from '@nestjs/common'; import { CreateServiceTypeDto } from '../dto/create-service-type.dto'; +import { AttachMeterDto, UpdateAttachedMeterDto } from '../dto/meter.dto'; +import { AttachedMeterResponseDto } from '../dto/meter-response.dto'; import { ProviderDetailDto } from '../dto/provider-detail.dto'; import { ProviderLocationDto } from '../dto/provider-location.dto'; import { ServerTypeDto } from '../dto/server-type.dto'; @@ -21,6 +23,7 @@ import { ServiceTypeResponseDto } from '../dto/service-type-response.dto'; import { UpdateServiceTypeDto } from '../dto/update-service-type.dto'; import { ServiceTypeEntity } from '../entities/service-type.entity'; import { ServiceTypesRepository } from '../repositories/service-types.repository'; +import { MeterService } from '../services/meter.service'; import { ProviderRegistryService } from '../services/provider-registry.service'; import { ProviderLocationsService } from '../services/provider-locations.service'; import { ProviderServerTypesService } from '../services/provider-server-types.service'; @@ -39,6 +42,7 @@ export class ServiceTypesController { private readonly providerRegistry: ProviderRegistryService, private readonly providerServerTypesService: ProviderServerTypesService, private readonly providerLocationsService: ProviderLocationsService, + private readonly meterService: MeterService, ) {} /** @@ -137,9 +141,60 @@ export class ServiceTypesController { providerDefaults, }); + await this.meterService.syncServiceTypeProviderMeters(row); + return this.mapToResponse(row); } + @RequireScopes('subscriptions:read') + @Get(':id/meters') + async listMeters(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string): Promise { + const row = await this.serviceTypesRepository.findByIdOrThrow(id); + await this.meterService.syncServiceTypeProviderMeters(row); + + return await this.meterService.listServiceTypeMeters(id); + } + + @RequireScopes('catalog:write') + @Post(':id/meters') + @KeycloakRoles(UserRole.ADMIN) + @UsersRoles(UserRole.ADMIN) + async attachMeter( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: AttachMeterDto, + ): Promise { + await this.serviceTypesRepository.findByIdOrThrow(id); + + return await this.meterService.attachServiceTypeMeter(id, dto.meterId, dto.unitPriceNet); + } + + @RequireScopes('catalog:write') + @Post(':id/meters/:meterId') + @KeycloakRoles(UserRole.ADMIN) + @UsersRoles(UserRole.ADMIN) + async updateMeterAttachment( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Param('meterId', new ParseUUIDPipe({ version: '4' })) meterId: string, + @Body() dto: UpdateAttachedMeterDto, + ): Promise { + await this.serviceTypesRepository.findByIdOrThrow(id); + + return await this.meterService.updateServiceTypeMeter(id, meterId, dto.unitPriceNet); + } + + @RequireScopes('catalog:write') + @Delete(':id/meters/:meterId') + @KeycloakRoles(UserRole.ADMIN) + @UsersRoles(UserRole.ADMIN) + @HttpCode(HttpStatus.NO_CONTENT) + async detachMeter( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Param('meterId', new ParseUUIDPipe({ version: '4' })) meterId: string, + ): Promise { + await this.serviceTypesRepository.findByIdOrThrow(id); + await this.meterService.detachServiceTypeMeter(id, meterId); + } + @RequireScopes('catalog:write') @Post(':id') @KeycloakRoles(UserRole.ADMIN) @@ -166,6 +221,8 @@ export class ServiceTypesController { ...(providerDefaults !== undefined ? { providerDefaults } : {}), }); + await this.meterService.syncServiceTypeProviderMeters(row); + return this.mapToResponse(row); } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/subscriptions.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/subscriptions.controller.ts index 0ec8035f1..c12285fa3 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/subscriptions.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/subscriptions.controller.ts @@ -17,9 +17,11 @@ import { ConfigChangeEligibilityDto, ConfigChangePreviewResponseDto } from '../d import { ConfigChangeRequestDto } from '../dto/config-change-request.dto'; import { ConfigChangeResponseDto } from '../dto/config-change-response.dto'; import { CreateSubscriptionDto } from '../dto/create-subscription.dto'; +import { SubscriptionMeterSummaryDto } from '../dto/meter-response.dto'; import { ResumeSubscriptionDto } from '../dto/resume-subscription.dto'; import { SubscriptionResponseDto } from '../dto/subscription-response.dto'; import { WithdrawSubscriptionDto } from '../dto/withdraw-subscription.dto'; +import { MeterBillingService } from '../services/meter-billing.service'; import { SubscriptionConfigChangeService } from '../services/subscription-config-change.service'; import { SubscriptionService } from '../services/subscription.service'; import { getUserFromRequest, type RequestWithUser } from '../utils/billing-access.utils'; @@ -29,6 +31,7 @@ export class SubscriptionsController { constructor( private readonly subscriptionService: SubscriptionService, private readonly subscriptionConfigChangeService: SubscriptionConfigChangeService, + private readonly meterBillingService: MeterBillingService, ) {} @RequireScopes('subscriptions:write') @@ -72,6 +75,27 @@ export class SubscriptionsController { return await this.subscriptionService.mapManyToResponses(rows); } + @RequireScopes('subscriptions:read') + @Get(':id/meters') + async listMeters( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Req() req?: RequestWithUser, + ): Promise { + const userInfo = getUserFromRequest(req || ({} as RequestWithUser)); + + if (!userInfo.userId) { + throw new BadRequestException('User not authenticated'); + } + + const subscription = await this.subscriptionService.getSubscription(id, userInfo.userId); + + return await this.meterBillingService.buildSubscriptionMeterSummaries({ + subscription, + periodStart: subscription.currentPeriodStart, + periodEnd: subscription.currentPeriodEnd, + }); + } + @RequireScopes('subscriptions:read') @Get(':id') async get( diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/addon-response.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/addon-response.dto.ts index bd712fb68..c0cf52016 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/addon-response.dto.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/addon-response.dto.ts @@ -1,6 +1,7 @@ import type { AddonImplementationType } from '../entities/addon.entity'; import type { BillingIntervalType } from '../entities/service-plan.entity'; import type { CloudInitConfigOrderFieldDto } from './cloud-init-config-response.dto'; +import type { AttachedMeterResponseDto } from './meter-response.dto'; export class AddonResponseDto { id!: string; @@ -18,6 +19,7 @@ export class AddonResponseDto { basePrice?: string | null; priceIntervalType?: BillingIntervalType | null; priceIntervalValue?: number | null; + meters!: AttachedMeterResponseDto[]; isActive!: boolean; createdAt!: Date; updatedAt!: Date; @@ -37,4 +39,6 @@ export class PlanAddonOptionDto { periodPrice!: number; /** Customer-visible config fields (no secret values). */ orderFields!: CloudInitConfigOrderFieldDto[]; + /** Attached usage meters (read-only on customer order). */ + meters!: AttachedMeterResponseDto[]; } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/create-usage-record.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/create-usage-record.dto.ts index 72a75e057..ebbe14bac 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/create-usage-record.dto.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/create-usage-record.dto.ts @@ -1,4 +1,6 @@ -import { IsISO8601, IsObject, IsUUID } from 'class-validator'; +import { IsIn, IsISO8601, IsNumber, IsObject, IsOptional, IsUUID, Min, ValidateIf } from 'class-validator'; + +import type { UsageAttachmentType } from '../entities/usage-record.entity'; export class CreateUsageRecordDto { @IsUUID('4', { message: 'Subscription ID must be a UUID' }) @@ -10,6 +12,55 @@ export class CreateUsageRecordDto { @IsISO8601({}, { message: 'periodEnd must be an ISO date string' }) periodEnd!: string; + @IsOptional() @IsObject({ message: 'usagePayload must be an object' }) - usagePayload!: Record; + usagePayload?: Record; + + @IsOptional() + @IsUUID('4', { message: 'meterId must be a UUID' }) + meterId?: string; + + @ValidateIf((dto: CreateUsageRecordDto) => dto.meterId != null) + @IsNumber({}, { message: 'value must be a number when meterId is set' }) + @Min(0, { message: 'value must be greater than or equal to 0 when meterId is set' }) + value?: number; + + @IsOptional() + @IsIn(['plan', 'addon']) + attachmentType?: UsageAttachmentType; + + @ValidateIf((dto: CreateUsageRecordDto) => (dto.attachmentType ?? 'plan') === 'addon') + @IsUUID('4', { message: 'addonId must be a UUID when attachmentType is addon' }) + addonId?: string; +} + +export class UpdateUsageMeterEntryDto { + @IsOptional() + @IsISO8601({}, { message: 'periodStart must be an ISO date string' }) + periodStart?: string; + + @IsOptional() + @IsISO8601({}, { message: 'periodEnd must be an ISO date string' }) + periodEnd?: string; + + @IsOptional() + @IsNumber() + @Min(0) + value?: number; + + @IsOptional() + @IsObject() + usagePayload?: Record; + + @IsOptional() + @IsIn(['plan', 'addon']) + attachmentType?: UsageAttachmentType; + + @IsOptional() + @IsUUID('4') + meterId?: string; + + @IsOptional() + @IsUUID('4') + addonId?: string | null; } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/declared-meter.dto.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/declared-meter.dto.spec.ts new file mode 100644 index 000000000..1a7552c51 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/declared-meter.dto.spec.ts @@ -0,0 +1,29 @@ +import { BadRequestException } from '@nestjs/common'; + +import { assertDeclaredMeterCollectionInterval } from './declared-meter.dto'; + +describe('assertDeclaredMeterCollectionInterval', () => { + const base = { + key: 'cpu', + name: 'CPU', + aggregator: 'max' as const, + defaultUnitPriceNet: 1, + }; + + it('allows omitted collectionIntervalMs', () => { + expect(() => assertDeclaredMeterCollectionInterval(base)).not.toThrow(); + }); + + it('allows positive collectionIntervalMs', () => { + expect(() => assertDeclaredMeterCollectionInterval({ ...base, collectionIntervalMs: 60_000 })).not.toThrow(); + }); + + it('rejects non-positive collectionIntervalMs', () => { + expect(() => assertDeclaredMeterCollectionInterval({ ...base, collectionIntervalMs: 0 })).toThrow( + BadRequestException, + ); + expect(() => assertDeclaredMeterCollectionInterval({ ...base, collectionIntervalMs: -1 })).toThrow( + BadRequestException, + ); + }); +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/declared-meter.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/declared-meter.dto.ts new file mode 100644 index 000000000..f6430cfc7 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/declared-meter.dto.ts @@ -0,0 +1,31 @@ +import { BadRequestException } from '@nestjs/common'; + +import type { MeterAggregator } from '../entities/meter.entity'; + +/** Meter declared by a provider package or addon module implementation. */ +export interface DeclaredMeterDefinition { + key: string; + name: string; + description?: string; + unitLabel?: string; + aggregator: MeterAggregator; + defaultUnitPriceNet: number; + /** + * When set (> 0), the meter-collect job pulls samples on this interval. + * Omitted meters stay push-only (admin / API key recording). + */ + collectionIntervalMs?: number; +} + +/** Validates optional collectionIntervalMs on a declared meter definition. */ +export function assertDeclaredMeterCollectionInterval(def: DeclaredMeterDefinition): void { + if (def.collectionIntervalMs === undefined || def.collectionIntervalMs === null) { + return; + } + + if (!Number.isFinite(def.collectionIntervalMs) || def.collectionIntervalMs <= 0) { + throw new BadRequestException( + `Declared meter '${def.key}' collectionIntervalMs must be a positive number when set`, + ); + } +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/meter-collect.types.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/meter-collect.types.ts new file mode 100644 index 000000000..f745e196e --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/meter-collect.types.ts @@ -0,0 +1,24 @@ +/** Context passed to provider / addon module collectMeters implementations. */ +export interface MeterCollectContext { + subscriptionId: string; + subscriptionItemId?: string; + provider: string; + providerReference?: string; + addonId?: string; + addonKey?: string; + configSnapshot?: Record; + hostname?: string; + meterKeys: string[]; + periodStart: Date; + periodEnd: Date; +} + +/** One sample returned by collectMeters for a declared meter key. */ +export interface MeterCollectSample { + meterKey: string; + value: number; + usagePayload?: Record; +} + +/** usage_source value written by the meter-collect job. */ +export const METER_COLLECT_USAGE_SOURCE = 'collector' as const; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/meter-response.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/meter-response.dto.ts new file mode 100644 index 000000000..17f08a1b9 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/meter-response.dto.ts @@ -0,0 +1,63 @@ +import type { MeterAggregator } from '../entities/meter.entity'; +import type { UsageAttachmentType } from '../entities/usage-record.entity'; + +export class MeterResponseDto { + id!: string; + key!: string; + name!: string; + description?: string | null; + unitLabel?: string | null; + aggregator!: MeterAggregator; + defaultUnitPriceNet!: number; + isActive!: boolean; + createdAt!: Date; + updatedAt!: Date; +} + +export class AttachedMeterResponseDto { + meterId!: string; + key!: string; + name!: string; + description?: string | null; + unitLabel?: string | null; + aggregator!: MeterAggregator; + defaultUnitPriceNet!: number; + unitPriceNetOverride?: number | null; + effectiveUnitPriceNet!: number; + isActive!: boolean; + source!: 'manual' | 'module' | 'provider'; + required!: boolean; + /** True when the meter comes from the plan's service type (not a direct plan link). */ + inherited?: boolean; +} + +export class SubscriptionMeterSummaryDto { + meterId!: string; + key!: string; + name!: string; + unitLabel?: string | null; + aggregator!: MeterAggregator; + attachmentType!: UsageAttachmentType; + addonId?: string | null; + addonName?: string | null; + effectiveUnitPriceNet!: number; + aggregatedValue!: number; + estimatedChargeNet!: number; + entryCount!: number; + periodStart?: Date | null; + periodEnd?: Date | null; +} + +export class UsageMeterEntryResponseDto { + id!: string; + subscriptionId!: string; + meterId!: string; + value!: number; + attachmentType!: UsageAttachmentType; + addonId?: string | null; + periodStart!: Date; + periodEnd!: Date; + usageSource!: string; + usagePayload!: Record; + createdAt!: Date; +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/meter.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/meter.dto.ts new file mode 100644 index 000000000..1165f1c02 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/meter.dto.ts @@ -0,0 +1,92 @@ +import { IsBoolean, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min, MinLength } from 'class-validator'; + +import { METER_AGGREGATORS, type MeterAggregator } from '../entities/meter.entity'; + +export class CreateMeterDto { + @IsString() + @MinLength(1) + @MaxLength(100) + key!: string; + + @IsString() + @MinLength(1) + @MaxLength(255) + name!: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(64) + unitLabel?: string; + + @IsIn(METER_AGGREGATORS) + aggregator!: MeterAggregator; + + @IsNumber() + @Min(0) + defaultUnitPriceNet!: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateMeterDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(255) + name?: string; + + @IsOptional() + @IsString() + description?: string | null; + + @IsOptional() + @IsString() + @MaxLength(64) + unitLabel?: string | null; + + @IsOptional() + @IsIn(METER_AGGREGATORS) + aggregator?: MeterAggregator; + + @IsOptional() + @IsNumber() + @Min(0) + defaultUnitPriceNet?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class AttachMeterDto { + @IsUUID('4') + meterId!: string; + + @IsOptional() + @IsNumber() + @Min(0) + unitPriceNet?: number | null; +} + +export class UpdateAttachedMeterDto { + @IsOptional() + @IsNumber() + @Min(0) + unitPriceNet?: number | null; +} + +export class MeterAttachmentInputDto { + @IsUUID('4') + meterId!: string; + + @IsOptional() + @IsNumber() + @Min(0) + unitPriceNet?: number | null; +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/provider-detail.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/provider-detail.dto.ts index 38f253670..d588032ad 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/provider-detail.dto.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/provider-detail.dto.ts @@ -1,3 +1,5 @@ +import type { DeclaredMeterDefinition } from './declared-meter.dto'; + export interface ProviderEnvDefaultField { envKey: string; label: string; @@ -47,4 +49,10 @@ export class ProviderDetailDto { * Fail-closed: omitted / false means downgrades are not offered. */ supportsServerTypeDowngrade?: boolean; + + /** + * Required usage meters declared by the provider (or product) implementation. + * Sideloaded onto service types as non-removable attachments. + */ + meters?: DeclaredMeterDefinition[]; } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/service-plan-response.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/service-plan-response.dto.ts index 8e549cbf7..251ae84f9 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/service-plan-response.dto.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/service-plan-response.dto.ts @@ -1,6 +1,7 @@ import { TaxCategory } from '../constants/tax-category.constants'; import { BillingIntervalType, ServicePlanOrderingHighlight } from '../entities/service-plan.entity'; +import { AttachedMeterResponseDto } from './meter-response.dto'; import { WithdrawalPolicyDto } from './withdrawal-policy.dto'; export class ServicePlanResponseDto { @@ -34,6 +35,7 @@ export class ServicePlanResponseDto { allowedServerTypes!: string[]; taxCategory!: TaxCategory; withdrawalPolicy!: WithdrawalPolicyDto; + meters!: AttachedMeterResponseDto[]; isActive!: boolean; createdAt!: Date; updatedAt!: Date; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/subscription-response.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/subscription-response.dto.ts index 364df897d..1422983b8 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/subscription-response.dto.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/subscription-response.dto.ts @@ -1,5 +1,6 @@ import { SubscriptionStatus } from '../entities/subscription.entity'; +import { SubscriptionMeterSummaryDto } from './meter-response.dto'; import { WithdrawalEligibilityDto, WithdrawalResultDto } from './withdrawal-policy.dto'; export class SubscriptionResponseDto { @@ -20,6 +21,7 @@ export class SubscriptionResponseDto { withdrawalEligibility?: WithdrawalEligibilityDto; withdrawalResult?: WithdrawalResultDto; periodTotalPrice?: number; + meters!: SubscriptionMeterSummaryDto[]; createdAt!: Date; updatedAt!: Date; } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/addon-meter.entity.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/addon-meter.entity.ts new file mode 100644 index 000000000..92b68a3ba --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/addon-meter.entity.ts @@ -0,0 +1,51 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; + +import type { MeterAttachmentSource } from '../constants/meter-attachment.constants'; +import { AddonEntity } from './addon.entity'; +import { MeterEntity } from './meter.entity'; + +@Entity('billing_addon_meters') +@Unique('uq_billing_addon_meters_addon_meter', ['addonId', 'meterId']) +export class AddonMeterEntity { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ type: 'uuid', name: 'addon_id' }) + addonId!: string; + + @ManyToOne(() => AddonEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'addon_id' }) + addon?: AddonEntity; + + @Column({ type: 'uuid', name: 'meter_id' }) + meterId!: string; + + @ManyToOne(() => MeterEntity, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'meter_id' }) + meter?: MeterEntity; + + /** Null ⇒ use catalog default_unit_price_net. */ + @Column({ type: 'numeric', precision: 12, scale: 4, nullable: true, name: 'unit_price_net' }) + unitPriceNet?: string | null; + + @Column({ type: 'varchar', length: 16, name: 'source', default: 'manual' }) + source!: MeterAttachmentSource; + + @Column({ type: 'boolean', name: 'required', default: false }) + required!: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/meter.entity.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/meter.entity.ts new file mode 100644 index 000000000..2279c4378 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/meter.entity.ts @@ -0,0 +1,50 @@ +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, Unique, UpdateDateColumn } from 'typeorm'; + +export type MeterAggregator = 'max' | 'min' | 'avg' | 'first' | 'last' | 'sum' | 'sum_positive_deltas'; + +export const METER_AGGREGATORS: MeterAggregator[] = [ + 'max', + 'min', + 'avg', + 'first', + 'last', + 'sum', + 'sum_positive_deltas', +]; + +@Entity('billing_meters') +@Unique('uq_billing_meters_tenant_key', ['tenantId', 'key']) +export class MeterEntity { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ type: 'varchar', length: 64, name: 'tenant_id', default: 'default' }) + tenantId!: string; + + @Column({ type: 'varchar', length: 100, name: 'key' }) + key!: string; + + @Column({ type: 'varchar', length: 255, name: 'name' }) + name!: string; + + @Column({ type: 'text', nullable: true, name: 'description' }) + description?: string | null; + + @Column({ type: 'varchar', length: 64, nullable: true, name: 'unit_label' }) + unitLabel?: string | null; + + @Column({ type: 'varchar', length: 32, name: 'aggregator' }) + aggregator!: MeterAggregator; + + @Column({ type: 'numeric', precision: 12, scale: 4, name: 'default_unit_price_net', default: 0 }) + defaultUnitPriceNet!: string; + + @Column({ type: 'boolean', name: 'is_active', default: true }) + isActive!: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-plan-meter.entity.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-plan-meter.entity.ts new file mode 100644 index 000000000..a2e8ae2ef --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-plan-meter.entity.ts @@ -0,0 +1,51 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; + +import type { MeterAttachmentSource } from '../constants/meter-attachment.constants'; +import { MeterEntity } from './meter.entity'; +import { ServicePlanEntity } from './service-plan.entity'; + +@Entity('billing_service_plan_meters') +@Unique('uq_billing_service_plan_meters_plan_meter', ['servicePlanId', 'meterId']) +export class ServicePlanMeterEntity { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ type: 'uuid', name: 'service_plan_id' }) + servicePlanId!: string; + + @ManyToOne(() => ServicePlanEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'service_plan_id' }) + servicePlan?: ServicePlanEntity; + + @Column({ type: 'uuid', name: 'meter_id' }) + meterId!: string; + + @ManyToOne(() => MeterEntity, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'meter_id' }) + meter?: MeterEntity; + + /** Null ⇒ use catalog default_unit_price_net. */ + @Column({ type: 'numeric', precision: 12, scale: 4, nullable: true, name: 'unit_price_net' }) + unitPriceNet?: string | null; + + @Column({ type: 'varchar', length: 16, name: 'source', default: 'manual' }) + source!: MeterAttachmentSource; + + @Column({ type: 'boolean', name: 'required', default: false }) + required!: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-type-meter.entity.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-type-meter.entity.ts new file mode 100644 index 000000000..72af0d5b5 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-type-meter.entity.ts @@ -0,0 +1,51 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; + +import type { MeterAttachmentSource } from '../constants/meter-attachment.constants'; +import { MeterEntity } from './meter.entity'; +import { ServiceTypeEntity } from './service-type.entity'; + +@Entity('billing_service_type_meters') +@Unique('uq_billing_service_type_meters_type_meter', ['serviceTypeId', 'meterId']) +export class ServiceTypeMeterEntity { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ type: 'uuid', name: 'service_type_id' }) + serviceTypeId!: string; + + @ManyToOne(() => ServiceTypeEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'service_type_id' }) + serviceType?: ServiceTypeEntity; + + @Column({ type: 'uuid', name: 'meter_id' }) + meterId!: string; + + @ManyToOne(() => MeterEntity, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'meter_id' }) + meter?: MeterEntity; + + /** Null ⇒ use catalog default_unit_price_net. */ + @Column({ type: 'numeric', precision: 12, scale: 4, nullable: true, name: 'unit_price_net' }) + unitPriceNet?: string | null; + + @Column({ type: 'varchar', length: 16, name: 'source', default: 'manual' }) + source!: MeterAttachmentSource; + + @Column({ type: 'boolean', name: 'required', default: false }) + required!: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/usage-record.entity.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/usage-record.entity.ts index 868d6a413..429fc54c5 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/usage-record.entity.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/usage-record.entity.ts @@ -1,7 +1,11 @@ import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { AddonEntity } from './addon.entity'; +import { MeterEntity } from './meter.entity'; import { SubscriptionEntity } from './subscription.entity'; +export type UsageAttachmentType = 'plan' | 'addon'; + @Entity('billing_usage_records') export class UsageRecordEntity { @PrimaryGeneratedColumn('uuid', { name: 'id' }) @@ -26,6 +30,26 @@ export class UsageRecordEntity { @Column({ type: 'jsonb', name: 'usage_payload', default: () => "'{}'::jsonb" }) usagePayload!: Record; + @Column({ type: 'uuid', nullable: true, name: 'meter_id' }) + meterId?: string | null; + + @ManyToOne(() => MeterEntity, { onDelete: 'RESTRICT', nullable: true }) + @JoinColumn({ name: 'meter_id' }) + meter?: MeterEntity | null; + + @Column({ type: 'numeric', precision: 18, scale: 6, nullable: true, name: 'value' }) + value?: string | null; + + @Column({ type: 'varchar', length: 16, nullable: true, name: 'attachment_type' }) + attachmentType?: UsageAttachmentType | null; + + @Column({ type: 'uuid', nullable: true, name: 'addon_id' }) + addonId?: string | null; + + @ManyToOne(() => AddonEntity, { onDelete: 'RESTRICT', nullable: true }) + @JoinColumn({ name: 'addon_id' }) + addon?: AddonEntity | null; + @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.spec.ts index 400dcb2c3..e526d5a4b 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.spec.ts @@ -42,6 +42,22 @@ describe('BILLING_NOTIFICATION_EVENTS', () => { 'subscription.provision_failed', 'service_plan.price_recalculated', 'subscription.price_changed', + 'addon.activated', + 'meter.created', + 'meter.updated', + 'meter.deleted', + 'service_plan.meter_attached', + 'service_plan.meter_updated', + 'service_plan.meter_detached', + 'addon.meter_attached', + 'addon.meter_updated', + 'addon.meter_detached', + 'service_type.meter_attached', + 'service_type.meter_updated', + 'service_type.meter_detached', + 'usage.recorded', + 'usage.updated', + 'usage.deleted', 'application.update_available', 'application.update_check_failed', 'application.instance_outdated', diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts index 2ee4248d8..2a7f015bd 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts @@ -42,6 +42,21 @@ export const BILLING_NOTIFICATION_EVENTS = [ 'addon.deactivated', 'addon.provision_failed', 'addon.teardown_failed', + 'meter.created', + 'meter.updated', + 'meter.deleted', + 'service_plan.meter_attached', + 'service_plan.meter_updated', + 'service_plan.meter_detached', + 'addon.meter_attached', + 'addon.meter_updated', + 'addon.meter_detached', + 'service_type.meter_attached', + 'service_type.meter_updated', + 'service_type.meter_detached', + 'usage.recorded', + 'usage.updated', + 'usage.deleted', 'project.created', 'project.updated', 'project.deleted', diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.publisher.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.publisher.ts index e025c8e37..b74002d51 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.publisher.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.publisher.ts @@ -318,6 +318,7 @@ export class BillingNotificationPublisher implements IIdentityNotificationPublis billUntil: Date, periodStart: Date, periodEnd: Date, + meterCharges?: Array>, ): void { this.publish( 'subscription.period_charged', @@ -326,6 +327,7 @@ export class BillingNotificationPublisher implements IIdentityNotificationPublis billUntil: billUntil.toISOString(), chargedPeriodStart: periodStart.toISOString(), chargedPeriodEnd: periodEnd.toISOString(), + meterCharges: meterCharges ?? [], }, subscription.userId, ); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/addon-meters.repository.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/addon-meters.repository.ts new file mode 100644 index 000000000..a0c5a19cf --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/addon-meters.repository.ts @@ -0,0 +1,97 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; + +import { AddonMeterEntity } from '../entities/addon-meter.entity'; + +@Injectable() +export class AddonMetersRepository { + constructor( + @InjectRepository(AddonMeterEntity) + private readonly repository: Repository, + ) {} + + async findByAddonId(addonId: string): Promise { + return await this.repository.find({ + where: { addonId }, + relations: ['meter'], + order: { createdAt: 'ASC' }, + }); + } + + async findByAddonIds(addonIds: string[]): Promise { + if (addonIds.length === 0) { + return []; + } + + return await this.repository.find({ + where: { addonId: In(addonIds) }, + relations: ['meter'], + order: { createdAt: 'ASC' }, + }); + } + + async findByAddonAndMeter(addonId: string, meterId: string): Promise { + return await this.repository.findOne({ + where: { addonId, meterId }, + relations: ['meter'], + }); + } + + async findByAddonAndMeterOrThrow(addonId: string, meterId: string): Promise { + const row = await this.findByAddonAndMeter(addonId, meterId); + + if (!row) { + throw new NotFoundException('Addon meter attachment not found'); + } + + return row; + } + + async countByMeterId(meterId: string): Promise { + return await this.repository.count({ where: { meterId } }); + } + + async create(dto: Partial): Promise { + const entity = this.repository.create(dto); + + return await this.repository.save(entity); + } + + async update(id: string, dto: Partial): Promise { + const entity = await this.repository.findOne({ where: { id } }); + + if (!entity) { + throw new NotFoundException('Addon meter attachment not found'); + } + + Object.assign(entity, dto); + + return await this.repository.save(entity); + } + + async deleteByAddonAndMeter(addonId: string, meterId: string): Promise { + await this.repository.delete({ addonId, meterId }); + } + + async replaceForAddon( + addonId: string, + attachments: Array<{ meterId: string; unitPriceNet?: string | null }>, + ): Promise { + await this.repository.delete({ addonId }); + + if (attachments.length === 0) { + return []; + } + + const entities = attachments.map((item) => + this.repository.create({ + addonId, + meterId: item.meterId, + unitPriceNet: item.unitPriceNet ?? null, + }), + ); + + return await this.repository.save(entities); + } +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/meters.repository.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/meters.repository.ts new file mode 100644 index 000000000..105e9485c --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/meters.repository.ts @@ -0,0 +1,83 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; + +import { MeterEntity } from '../entities/meter.entity'; +import { getRequiredTenantId } from '../utils/tenant-query.utils'; + +@Injectable() +export class MetersRepository { + constructor( + @InjectRepository(MeterEntity) + private readonly repository: Repository, + ) {} + + async findByIdOrThrow(id: string): Promise { + const entity = await this.repository.findOne({ where: { id, tenantId: getRequiredTenantId() } }); + + if (!entity) { + throw new NotFoundException(`Meter with ID ${id} not found`); + } + + return entity; + } + + async findById(id: string): Promise { + return await this.repository.findOne({ where: { id, tenantId: getRequiredTenantId() } }); + } + + async findByIds(ids: string[]): Promise { + if (ids.length === 0) { + return []; + } + + return await this.repository.find({ + where: { id: In(ids), tenantId: getRequiredTenantId() }, + }); + } + + async findByKey(key: string): Promise { + return await this.repository.findOne({ where: { key, tenantId: getRequiredTenantId() } }); + } + + async findAll(limit = 10, offset = 0): Promise { + return await this.repository.find({ + where: { tenantId: getRequiredTenantId() }, + take: limit, + skip: offset, + order: { createdAt: 'DESC' }, + }); + } + + async findActive(limit = 100, offset = 0): Promise { + return await this.repository.find({ + where: { tenantId: getRequiredTenantId(), isActive: true }, + take: limit, + skip: offset, + order: { name: 'ASC' }, + }); + } + + async create(dto: Partial): Promise { + const { tenantId: _ignoredTenantId, ...rest } = dto; + const entity = this.repository.create({ + ...rest, + tenantId: getRequiredTenantId(), + }); + + return await this.repository.save(entity); + } + + async update(id: string, dto: Partial): Promise { + const entity = await this.findByIdOrThrow(id); + const { tenantId: _ignoredTenantId, ...safeDto } = dto; + + Object.assign(entity, safeDto); + + return await this.repository.save(entity); + } + + async delete(id: string): Promise { + await this.repository.delete({ id, tenantId: getRequiredTenantId() }); + } +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plan-meters.repository.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plan-meters.repository.ts new file mode 100644 index 000000000..2ed5a20cf --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plan-meters.repository.ts @@ -0,0 +1,97 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; + +import { ServicePlanMeterEntity } from '../entities/service-plan-meter.entity'; + +@Injectable() +export class ServicePlanMetersRepository { + constructor( + @InjectRepository(ServicePlanMeterEntity) + private readonly repository: Repository, + ) {} + + async findByPlanId(servicePlanId: string): Promise { + return await this.repository.find({ + where: { servicePlanId }, + relations: ['meter'], + order: { createdAt: 'ASC' }, + }); + } + + async findByPlanIds(servicePlanIds: string[]): Promise { + if (servicePlanIds.length === 0) { + return []; + } + + return await this.repository.find({ + where: { servicePlanId: In(servicePlanIds) }, + relations: ['meter'], + order: { createdAt: 'ASC' }, + }); + } + + async findByPlanAndMeter(servicePlanId: string, meterId: string): Promise { + return await this.repository.findOne({ + where: { servicePlanId, meterId }, + relations: ['meter'], + }); + } + + async findByPlanAndMeterOrThrow(servicePlanId: string, meterId: string): Promise { + const row = await this.findByPlanAndMeter(servicePlanId, meterId); + + if (!row) { + throw new NotFoundException('Plan meter attachment not found'); + } + + return row; + } + + async countByMeterId(meterId: string): Promise { + return await this.repository.count({ where: { meterId } }); + } + + async create(dto: Partial): Promise { + const entity = this.repository.create(dto); + + return await this.repository.save(entity); + } + + async update(id: string, dto: Partial): Promise { + const entity = await this.repository.findOne({ where: { id } }); + + if (!entity) { + throw new NotFoundException('Plan meter attachment not found'); + } + + Object.assign(entity, dto); + + return await this.repository.save(entity); + } + + async deleteByPlanAndMeter(servicePlanId: string, meterId: string): Promise { + await this.repository.delete({ servicePlanId, meterId }); + } + + async replaceForPlan( + servicePlanId: string, + attachments: Array<{ meterId: string; unitPriceNet?: string | null }>, + ): Promise { + await this.repository.delete({ servicePlanId }); + + if (attachments.length === 0) { + return []; + } + + const entities = attachments.map((item) => + this.repository.create({ + servicePlanId, + meterId: item.meterId, + unitPriceNet: item.unitPriceNet ?? null, + }), + ); + + return await this.repository.save(entities); + } +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-type-meters.repository.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-type-meters.repository.ts new file mode 100644 index 000000000..474514196 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-type-meters.repository.ts @@ -0,0 +1,80 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; + +import { ServiceTypeMeterEntity } from '../entities/service-type-meter.entity'; + +@Injectable() +export class ServiceTypeMetersRepository { + constructor( + @InjectRepository(ServiceTypeMeterEntity) + private readonly repository: Repository, + ) {} + + async findByServiceTypeId(serviceTypeId: string): Promise { + return await this.repository.find({ + where: { serviceTypeId }, + relations: ['meter'], + order: { createdAt: 'ASC' }, + }); + } + + async findByServiceTypeIds(serviceTypeIds: string[]): Promise { + if (serviceTypeIds.length === 0) { + return []; + } + + return await this.repository.find({ + where: { serviceTypeId: In(serviceTypeIds) }, + relations: ['meter'], + order: { createdAt: 'ASC' }, + }); + } + + async findByServiceTypeAndMeter(serviceTypeId: string, meterId: string): Promise { + return await this.repository.findOne({ + where: { serviceTypeId, meterId }, + relations: ['meter'], + }); + } + + async findByServiceTypeAndMeterOrThrow(serviceTypeId: string, meterId: string): Promise { + const row = await this.findByServiceTypeAndMeter(serviceTypeId, meterId); + + if (!row) { + throw new NotFoundException('Service type meter attachment not found'); + } + + return row; + } + + async countByMeterId(meterId: string): Promise { + return await this.repository.count({ where: { meterId } }); + } + + async create(dto: Partial): Promise { + const entity = this.repository.create(dto); + + return await this.repository.save(entity); + } + + async update(id: string, dto: Partial): Promise { + const entity = await this.repository.findOne({ where: { id } }); + + if (!entity) { + throw new NotFoundException('Service type meter attachment not found'); + } + + Object.assign(entity, dto); + + return await this.repository.save(entity); + } + + async deleteByServiceTypeAndMeter(serviceTypeId: string, meterId: string): Promise { + await this.repository.delete({ serviceTypeId, meterId }); + } + + async deleteById(id: string): Promise { + await this.repository.delete({ id }); + } +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscriptions.repository.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscriptions.repository.ts index d6ff7face..b91c728de 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscriptions.repository.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscriptions.repository.ts @@ -180,6 +180,28 @@ export class SubscriptionsRepository { return await qb.getMany(); } + /** + * Active / config-change arrear subscriptions eligible for meter collection. + */ + async findActiveArrearForMeterCollect(limit = 500, offset = 0): Promise { + const qb = this.repository + .createQueryBuilder('subscription') + .innerJoin('users', 'user', 'user.id = subscription.user_id') + .innerJoinAndSelect('subscription.plan', 'plan') + .where('subscription.status IN (:...statuses)', { + statuses: [SubscriptionStatus.ACTIVE, SubscriptionStatus.PENDING_CONFIG_CHANGE], + }) + .andWhere('plan.bill_in_advance = false') + .orderBy('subscription.createdAt', 'ASC') + .addOrderBy('subscription.id', 'ASC') + .take(limit) + .skip(offset); + + applyUserTenantFilter(qb, 'user'); + + return await qb.getMany(); + } + async findDueForCancellation(now: Date = new Date(), limit = 100): Promise { const qb = this.repository .createQueryBuilder('subscription') diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/usage-records.repository.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/usage-records.repository.spec.ts index fee332cf8..5d19ab223 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/usage-records.repository.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/usage-records.repository.spec.ts @@ -62,4 +62,35 @@ describe('UsageRecordsRepository', () => { expect(mockRepository.create).toHaveBeenCalledWith(dto); expect(result).toEqual(created); }); + + it('findLatestCollectorForMeter filters by collector source and attachment', async () => { + const usage = { id: 'usage-1', usageSource: 'collector' }; + const mockAddOrderBy = jest.fn().mockReturnThis(); + mockGetOne.mockResolvedValue(usage); + + const mockRepository = { + create: jest.fn(), + save: jest.fn(), + createQueryBuilder: jest.fn().mockReturnValue({ + ...createQueryBuilderReturn, + addOrderBy: mockAddOrderBy, + }), + }; + const repository = new UsageRecordsRepository(mockRepository as never); + const result = await runWithTenantId('default', () => + repository.findLatestCollectorForMeter({ + subscriptionId: 'sub-1', + meterId: 'meter-1', + attachmentType: 'plan', + addonId: null, + }), + ); + + expect(mockAndWhere).toHaveBeenCalledWith('usage.usage_source = :usageSource', { + usageSource: 'collector', + }); + expect(mockAndWhere).toHaveBeenCalledWith('usage.addon_id IS NULL'); + expect(mockOrderBy).toHaveBeenCalledWith('usage.period_end', 'DESC'); + expect(result).toEqual(usage); + }); }); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/usage-records.repository.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/usage-records.repository.ts index 2063010c6..d35ed6041 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/usage-records.repository.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/usage-records.repository.ts @@ -1,8 +1,8 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { UsageRecordEntity } from '../entities/usage-record.entity'; +import { UsageRecordEntity, type UsageAttachmentType } from '../entities/usage-record.entity'; import { applyUserTenantFilter } from '../utils/tenant-query.utils'; @Injectable() @@ -26,9 +26,117 @@ export class UsageRecordsRepository { return await qb.getOne(); } + async findBySubscriptionId(subscriptionId: string): Promise { + const qb = this.repository + .createQueryBuilder('usage') + .innerJoin('usage.subscription', 'sub') + .innerJoin('users', 'user', 'user.id = sub.user_id') + .where('usage.subscription_id = :subscriptionId', { subscriptionId }) + .orderBy('usage.createdAt', 'DESC'); + + applyUserTenantFilter(qb, 'user'); + + return await qb.getMany(); + } + + async findByIdForSubscription(subscriptionId: string, entryId: string): Promise { + const qb = this.repository + .createQueryBuilder('usage') + .innerJoin('usage.subscription', 'sub') + .innerJoin('users', 'user', 'user.id = sub.user_id') + .where('usage.subscription_id = :subscriptionId', { subscriptionId }) + .andWhere('usage.id = :entryId', { entryId }); + + applyUserTenantFilter(qb, 'user'); + + return await qb.getOne(); + } + + async findByIdForSubscriptionOrThrow(subscriptionId: string, entryId: string): Promise { + const entity = await this.findByIdForSubscription(subscriptionId, entryId); + + if (!entity) { + throw new NotFoundException('Usage record not found'); + } + + return entity; + } + + async countByMeterId(meterId: string): Promise { + return await this.repository.count({ where: { meterId } }); + } + async create(dto: Partial): Promise { const entity = this.repository.create(dto); return await this.repository.save(entity); } + + async update(entity: UsageRecordEntity, dto: Partial): Promise { + Object.assign(entity, dto); + + return await this.repository.save(entity); + } + + async delete(entity: UsageRecordEntity): Promise { + await this.repository.remove(entity); + } + + async findMeteredForSubscription( + subscriptionId: string, + options?: { meterId?: string; attachmentType?: UsageAttachmentType; addonId?: string | null }, + ): Promise { + const qb = this.repository + .createQueryBuilder('usage') + .innerJoin('usage.subscription', 'sub') + .innerJoin('users', 'user', 'user.id = sub.user_id') + .where('usage.subscription_id = :subscriptionId', { subscriptionId }) + .andWhere('usage.meter_id IS NOT NULL') + .orderBy('usage.createdAt', 'DESC'); + + if (options?.meterId) { + qb.andWhere('usage.meter_id = :meterId', { meterId: options.meterId }); + } + + if (options?.attachmentType) { + qb.andWhere('usage.attachment_type = :attachmentType', { attachmentType: options.attachmentType }); + } + + if (options?.addonId) { + qb.andWhere('usage.addon_id = :addonId', { addonId: options.addonId }); + } + + applyUserTenantFilter(qb, 'user'); + + return await qb.getMany(); + } + + async findLatestCollectorForMeter(params: { + subscriptionId: string; + meterId: string; + attachmentType: UsageAttachmentType; + addonId?: string | null; + }): Promise { + const qb = this.repository + .createQueryBuilder('usage') + .innerJoin('usage.subscription', 'sub') + .innerJoin('users', 'user', 'user.id = sub.user_id') + .where('usage.subscription_id = :subscriptionId', { subscriptionId: params.subscriptionId }) + .andWhere('usage.meter_id = :meterId', { meterId: params.meterId }) + .andWhere('usage.usage_source = :usageSource', { usageSource: 'collector' }) + .andWhere('usage.attachment_type = :attachmentType', { attachmentType: params.attachmentType }) + .orderBy('usage.period_end', 'DESC') + .addOrderBy('usage.createdAt', 'DESC') + .take(1); + + if (params.attachmentType === 'addon') { + qb.andWhere('usage.addon_id = :addonId', { addonId: params.addonId ?? null }); + } else { + qb.andWhere('usage.addon_id IS NULL'); + } + + applyUserTenantFilter(qb, 'user'); + + return await qb.getOne(); + } } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/addon-module-registry.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/addon-module-registry.service.ts index 9bbb8f7c1..ce7739647 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/addon-module-registry.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/addon-module-registry.service.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; +import type { DeclaredMeterDefinition } from '../dto/declared-meter.dto'; +import type { MeterCollectContext, MeterCollectSample } from '../dto/meter-collect.types'; import type { AddonConfigFieldDefinition } from '../utils/addon-config.utils'; export interface AddonLifecycleContext { @@ -21,8 +23,18 @@ export interface BillingAddonModule { * Persisted onto the catalog addon `configSchema` at create/update; admins set defaults only. */ readonly configFields?: AddonConfigFieldDefinition[]; + /** + * Required usage meters declared by the module implementation. + * Sideloaded onto the catalog addon as non-removable attachments. + */ + readonly meters?: DeclaredMeterDefinition[]; provision(ctx: AddonLifecycleContext): Promise; teardown(ctx: AddonLifecycleContext): Promise; + /** + * Optional pull hook for meters with collectionIntervalMs. + * Invoked by the meter-collect job when due; omit to keep meters push-only. + */ + collectMeters?(ctx: MeterCollectContext): Promise; } /** diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/addon.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/addon.service.ts index a8f4c59dc..f7bc12943 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/addon.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/addon.service.ts @@ -40,6 +40,32 @@ export class AddonService { this.validateCompatibleProviders(dto.compatibleProviders); } + listRegisteredModules(): Array<{ + key: string; + displayName: string; + meters: Array<{ + key: string; + name: string; + description?: string; + unitLabel?: string; + aggregator: string; + defaultUnitPriceNet: number; + }>; + }> { + return this.addonModuleRegistry.list().map((module) => ({ + key: module.key, + displayName: module.displayName, + meters: (module.meters ?? []).map((meter) => ({ + key: meter.key, + name: meter.name, + description: meter.description, + unitLabel: meter.unitLabel, + aggregator: meter.aggregator, + defaultUnitPriceNet: meter.defaultUnitPriceNet, + })), + })); + } + validateUpdatePayload( existing: AddonEntity, dto: UpdateAddonDto, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/billing-admin.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/billing-admin.service.spec.ts index fff424241..defdc6e08 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/billing-admin.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/billing-admin.service.spec.ts @@ -47,11 +47,13 @@ describe('BillingAdminService', () => { it('listUserSubscriptions returns subscriptions for an existing user', async () => { usersRepository.findByIdForTenant.mockResolvedValue({ id: 'user-1' }); subscriptionService.listSubscriptions.mockResolvedValue([{ id: 'sub-1' }]); + subscriptionService.mapManyToResponses.mockResolvedValue([{ id: 'sub-1', meters: [] }]); const result = await service.listUserSubscriptions('user-1', 100, 0); expect(subscriptionService.listSubscriptions).toHaveBeenCalledWith('user-1', 100, 0); - expect(result).toEqual([{ id: 'sub-1' }]); + expect(subscriptionService.mapManyToResponses).toHaveBeenCalledWith([{ id: 'sub-1' }]); + expect(result).toEqual([{ id: 'sub-1', meters: [] }]); }); it('listSubscriptionsForAdmin maps subscriptions with user email and plan name', async () => { diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/billing-admin.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/billing-admin.service.ts index 8c85820dc..05f9d9a36 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/billing-admin.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/billing-admin.service.ts @@ -48,14 +48,16 @@ export class BillingAdminService { }; } - async listUserSubscriptions(userId: string, limit: number, offset: number): Promise { + async listUserSubscriptions(userId: string, limit: number, offset: number): Promise { const user = await this.usersRepository.findByIdForTenant(userId); if (!user) { throw new NotFoundException('User not found'); } - return await this.subscriptionService.listSubscriptions(userId, limit, offset); + const rows = await this.subscriptionService.listSubscriptions(userId, limit, offset); + + return await this.subscriptionService.mapManyToResponses(rows); } async listSubscriptionsForAdmin(params: { diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/builtin-provider-modules.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/builtin-provider-modules.ts new file mode 100644 index 000000000..cc2e380c0 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/builtin-provider-modules.ts @@ -0,0 +1,19 @@ +import type { BillingProviderModule } from './provider-module-registry.service'; + +/** Built-in stubs: no cloud metrics in this release; return empty samples. */ +export function createBuiltinProviderModules(): BillingProviderModule[] { + return [ + { + id: 'hetzner', + async collectMeters() { + return []; + }, + }, + { + id: 'digital-ocean', + async collectMeters() { + return []; + }, + }, + ]; +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-creation.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-creation.service.spec.ts index 38c2179ac..71361b92e 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-creation.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-creation.service.spec.ts @@ -42,6 +42,7 @@ describe('InvoiceCreationService', () => { invoiceTaxContextService?: any; subscriptionAddonsRepository?: any; billingScheduleService?: any; + meterBillingService?: any; } = {}, ) { const invoicesRepository = @@ -90,6 +91,11 @@ describe('InvoiceCreationService', () => { findBillableBySubscriptionId: jest.fn().mockResolvedValue([]), } as any), deps.billingScheduleService ?? ({ calculateSchedule: jest.fn() } as any), + deps.meterBillingService ?? + ({ + hasAnyMeterAttachments: jest.fn().mockResolvedValue(false), + buildMeterChargeLines: jest.fn().mockResolvedValue([]), + } as any), ); } @@ -274,6 +280,7 @@ describe('InvoiceCreationService', () => { const subscriptionChargePeriodService = { resolveChargePeriod: jest.fn().mockResolvedValue({ baseAmount: 0.005, + meterPeriodStart: new Date('2024-01-01T00:00:00Z'), periodStart: new Date('2024-01-01T00:00:00Z'), periodEnd: new Date('2024-01-01T00:02:00Z'), }), @@ -326,6 +333,7 @@ describe('InvoiceCreationService', () => { const subscriptionChargePeriodService = { resolveChargePeriod: jest.fn().mockResolvedValue({ baseAmount: 0.005, + meterPeriodStart: new Date('2024-01-01T00:00:00Z'), periodStart: new Date('2024-01-01T00:00:00Z'), periodEnd: new Date('2024-01-01T00:02:00Z'), }), @@ -378,6 +386,7 @@ describe('InvoiceCreationService', () => { const subscriptionChargePeriodService = { resolveChargePeriod: jest.fn().mockResolvedValue({ baseAmount: 0.5, + meterPeriodStart: new Date('2024-01-01T00:00:00Z'), periodStart: new Date('2024-01-01T00:00:00Z'), periodEnd: new Date('2024-01-01T00:02:00Z'), }), @@ -430,6 +439,7 @@ describe('InvoiceCreationService', () => { const subscriptionChargePeriodService = { resolveChargePeriod: jest.fn().mockResolvedValue({ baseAmount: 0.5, + meterPeriodStart: new Date('2024-01-01T00:00:00Z'), periodStart: new Date('2024-01-01T00:00:00Z'), periodEnd: new Date('2024-01-01T00:02:00Z'), }), @@ -570,6 +580,7 @@ describe('InvoiceCreationService', () => { amount: 10, chargePeriod: { baseAmount: 10, + meterPeriodStart: new Date('2024-01-01'), periodStart: new Date('2024-01-01'), periodEnd: new Date('2024-02-01'), }, @@ -677,6 +688,7 @@ describe('InvoiceCreationService', () => { amount: 0.5, chargePeriod: { baseAmount: 0.5, + meterPeriodStart: new Date('2024-01-01'), periodStart: new Date('2024-01-01'), periodEnd: new Date('2024-02-01'), }, @@ -745,6 +757,7 @@ describe('InvoiceCreationService', () => { amount: 10, chargePeriod: { baseAmount: 10, + meterPeriodStart: new Date('2024-01-01'), periodStart: new Date('2024-01-01'), periodEnd: new Date('2024-02-01'), }, @@ -812,6 +825,7 @@ describe('InvoiceCreationService', () => { amount: 10, chargePeriod: { baseAmount: 10, + meterPeriodStart: new Date('2024-01-01'), periodStart: new Date('2024-01-01'), periodEnd: new Date('2024-02-01'), }, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-creation.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-creation.service.ts index db95d9848..39e7c36c9 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-creation.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-creation.service.ts @@ -25,6 +25,7 @@ import { PricingService } from './pricing.service'; import { PromotionApplicationService, type PromotionRedemptionUpdate } from './promotion-application.service'; import { ProviderServerTypesService } from './provider-server-types.service'; import { BillingScheduleService } from './billing-schedule.service'; +import { MeterBillingService, type MeterChargeLine } from './meter-billing.service'; import { SubscriptionChargePeriodService, type SubscriptionChargePeriod } from './subscription-charge-period.service'; import type { InvoicePromotionApplicationDraft } from '../dto/promotion.dto'; @@ -57,6 +58,7 @@ export class InvoiceCreationService { private readonly invoiceTaxContextService: InvoiceTaxContextService, private readonly subscriptionAddonsRepository: SubscriptionAddonsRepository, private readonly billingScheduleService: BillingScheduleService, + private readonly meterBillingService: MeterBillingService, ) {} async createInvoice(subscriptionId: string, userId: string, description?: string, options?: InvoiceCreationOptions) { @@ -68,12 +70,6 @@ export class InvoiceCreationService { const plan = await this.servicePlansRepository.findByIdOrThrow(subscription.planId); const pricing = await this.resolveSubscriptionPricing(subscriptionId, plan); - const usageCost = - plan.billInAdvance === true - ? 0 - : this.extractUsageCost( - (await this.usageRecordsRepository.findLatestForSubscription(subscriptionId))?.usagePayload ?? {}, - ); const billUntil = options?.billUntil ?? new Date(); const chargePeriod = await this.subscriptionChargePeriodService.resolveChargePeriod( subscription, @@ -90,7 +86,23 @@ export class InvoiceCreationService { throw new BadRequestException('No billable amount since last invoice'); } - const total = chargePeriod.baseAmount + usageCost; + const hasMeterAttachments = await this.meterBillingService.hasAnyMeterAttachments(subscription, plan.serviceTypeId); + const usageCost = + plan.billInAdvance === true || hasMeterAttachments + ? 0 + : this.extractUsageCost( + (await this.usageRecordsRepository.findLatestForSubscription(subscriptionId))?.usagePayload ?? {}, + ); + const meterLines = hasMeterAttachments + ? await this.meterBillingService.buildMeterChargeLines({ + subscription, + plan, + periodStart: chargePeriod.meterPeriodStart, + periodEnd: chargePeriod.periodEnd, + }) + : []; + const meterTotal = meterLines.reduce((sum, line) => sum + line.unitPriceNet, 0); + const total = chargePeriod.baseAmount + usageCost + meterTotal; if (total < MIN_BILLABLE_AMOUNT) { if (options?.skipIfNoBillableAmount) { @@ -100,7 +112,7 @@ export class InvoiceCreationService { throw new BadRequestException('No billable amount since last invoice'); } - const roundedTotal = Math.round(total * 100) / 100; + const roundedTotal = Math.round((chargePeriod.baseAmount + usageCost) * 100) / 100; const taxCategory = resolvePlanTaxCategory(plan); const chargeLine = { description: description || 'Subscription charge', @@ -137,16 +149,17 @@ export class InvoiceCreationService { }; }) .filter((line): line is NonNullable => line != null); + const meterChargeLines = this.mapMeterLinesToInputs(meterLines, taxCategory); const promoResult = await this.promotionApplicationService.calculatePromotions({ userId, subscriptionId, - chargeLines: [chargeLine, ...addonChargeLines], + chargeLines: [chargeLine, ...addonChargeLines, ...meterChargeLines], defaultTaxCategory: taxCategory, chargePeriod: { start: chargePeriod.periodStart, end: chargePeriod.periodEnd }, subscriptionChargeNet: Math.round(chargePeriod.baseAmount * 100) / 100, }); - const lineInputs = [...promoResult.discountLines, chargeLine, ...addonChargeLines]; + const lineInputs = [...promoResult.discountLines, chargeLine, ...addonChargeLines, ...meterChargeLines]; const taxContext = await this.invoiceTaxContextService.resolveForUser(userId); const totals = this.taxCalculationService.computeLines(lineInputs, { taxTreatment: taxContext.treatment, @@ -194,6 +207,7 @@ export class InvoiceCreationService { group: (typeof groups)[number]; chargePeriod: ChargePeriodResult; amount: number; + meterLines: MeterChargeLine[]; }[] = []; for (const group of groups) { @@ -204,7 +218,12 @@ export class InvoiceCreationService { const charge = await this.getBillableChargeForPosition(group.representative); if (charge && charge.amount >= MIN_BILLABLE_AMOUNT) { - billableGroups.push({ group, chargePeriod: charge.chargePeriod, amount: charge.amount }); + billableGroups.push({ + group, + chargePeriod: charge.chargePeriod, + amount: charge.amount, + meterLines: charge.meterLines, + }); } } @@ -224,7 +243,7 @@ export class InvoiceCreationService { const promotionApplications: InvoicePromotionApplicationDraft[] = []; const redemptionUpdates: PromotionRedemptionUpdate[] = []; - for (const { group, amount, chargePeriod } of billableGroups) { + for (const { group, amount, chargePeriod, meterLines } of billableGroups) { const subscription = await this.subscriptionsRepository.findByIdOrThrow(group.subscriptionId); const plan = await this.servicePlansRepository.findByIdOrThrow(subscription.planId); const taxCategory = resolvePlanTaxCategory(plan); @@ -257,8 +276,10 @@ export class InvoiceCreationService { }; }) .filter((line): line is NonNullable => line != null); + const meterChargeLines = this.mapMeterLinesToInputs(meterLines, taxCategory); const addonTotal = addonChargeLines.reduce((sum, line) => sum + line.unitPriceNet, 0); - const planAmount = Math.max(0, Math.round((amount - addonTotal) * 100) / 100); + const meterTotal = meterChargeLines.reduce((sum, line) => sum + line.unitPriceNet, 0); + const planAmount = Math.max(0, Math.round((amount - addonTotal - meterTotal) * 100) / 100); const chargeLine = { description: group.representative.description ?? 'Subscription', quantity: 1, @@ -268,13 +289,13 @@ export class InvoiceCreationService { const promoResult = await this.promotionApplicationService.calculatePromotions({ userId, subscriptionId: group.subscriptionId, - chargeLines: [chargeLine, ...addonChargeLines], + chargeLines: [chargeLine, ...addonChargeLines, ...meterChargeLines], defaultTaxCategory: taxCategory, chargePeriod: { start: chargePeriod.periodStart, end: chargePeriod.periodEnd }, subscriptionChargeNet: Math.round(chargePeriod.baseAmount * 100) / 100, }); - lineInputs.push(...promoResult.discountLines, chargeLine, ...addonChargeLines); + lineInputs.push(...promoResult.discountLines, chargeLine, ...addonChargeLines, ...meterChargeLines); promotionApplications.push(...promoResult.applications); redemptionUpdates.push(...promoResult.redemptionUpdates); } @@ -419,7 +440,7 @@ export class InvoiceCreationService { private async getBillableChargeForPosition( position: OpenPositionEntity, - ): Promise<{ amount: number; chargePeriod: ChargePeriodResult } | null> { + ): Promise<{ amount: number; chargePeriod: ChargePeriodResult; meterLines: MeterChargeLine[] } | null> { const subscription = await this.subscriptionsRepository.findByIdOrThrow(position.subscriptionId); if (subscription.userId !== position.userId) { @@ -436,12 +457,6 @@ export class InvoiceCreationService { return sum + (Number.isFinite(price) && price > 0 ? price : 0); }, 0); - const usageCost = - plan.billInAdvance === true - ? 0 - : this.extractUsageCost( - (await this.usageRecordsRepository.findLatestForSubscription(position.subscriptionId))?.usagePayload ?? {}, - ); const chargePeriod = await this.subscriptionChargePeriodService.resolveChargePeriod( subscription, plan, @@ -457,7 +472,23 @@ export class InvoiceCreationService { throw new BadRequestException('No billable amount since last invoice'); } - const total = chargePeriod.baseAmount + usageCost; + const hasMeterAttachments = await this.meterBillingService.hasAnyMeterAttachments(subscription, plan.serviceTypeId); + const usageCost = + plan.billInAdvance === true || hasMeterAttachments + ? 0 + : this.extractUsageCost( + (await this.usageRecordsRepository.findLatestForSubscription(position.subscriptionId))?.usagePayload ?? {}, + ); + const meterLines = hasMeterAttachments + ? await this.meterBillingService.buildMeterChargeLines({ + subscription, + plan, + periodStart: chargePeriod.meterPeriodStart, + periodEnd: chargePeriod.periodEnd, + }) + : []; + const meterTotal = meterLines.reduce((sum, line) => sum + line.unitPriceNet, 0); + const total = chargePeriod.baseAmount + usageCost + meterTotal; if (total < MIN_BILLABLE_AMOUNT) { if (position.skipIfNoBillableAmount) { @@ -467,7 +498,19 @@ export class InvoiceCreationService { throw new BadRequestException('No billable amount since last invoice'); } - return { amount: total, chargePeriod }; + return { amount: total, chargePeriod, meterLines }; + } + + private mapMeterLinesToInputs( + meterLines: MeterChargeLine[] | undefined, + taxCategory: ReturnType, + ): LineItemInput[] { + return (meterLines ?? []).map((line) => ({ + description: line.description, + quantity: line.quantity, + unitPriceNet: line.unitPriceNet, + taxCategory, + })); } private async getBillableAmountForPosition(position: OpenPositionEntity): Promise { diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoicing-period.util.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoicing-period.util.ts index cb4ca40fb..6ed2868ee 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoicing-period.util.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoicing-period.util.ts @@ -34,7 +34,7 @@ export function resolveInvoicingPeriod( return normalizePeriod({ periodStart, periodEnd }); } -function subtractBillingInterval( +export function subtractBillingInterval( end: Date, plan: Pick, ): Date { diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-billing.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-billing.service.ts new file mode 100644 index 000000000..6aad6b839 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-billing.service.ts @@ -0,0 +1,331 @@ +import { Injectable } from '@nestjs/common'; + +import type { SubscriptionMeterSummaryDto } from '../dto/meter-response.dto'; +import type { ServicePlanEntity } from '../entities/service-plan.entity'; +import type { SubscriptionEntity } from '../entities/subscription.entity'; +import { AddonMetersRepository } from '../repositories/addon-meters.repository'; +import { ServicePlanMetersRepository } from '../repositories/service-plan-meters.repository'; +import { ServicePlansRepository } from '../repositories/service-plans.repository'; +import { ServiceTypeMetersRepository } from '../repositories/service-type-meters.repository'; +import { SubscriptionAddonsRepository } from '../repositories/subscription-addons.repository'; +import { UsageRecordsRepository } from '../repositories/usage-records.repository'; +import { + aggregateMeterValues, + filterEntriesForAttachment, + resolveEffectiveUnitPriceNet, + type MeterUsageEntry, +} from '../utils/meter-aggregation.util'; + +export type MeterChargeLine = { + description: string; + quantity: number; + unitPriceNet: number; + meterId: string; + attachmentType: 'plan' | 'addon'; + addonId?: string | null; + aggregatedValue: number; + effectiveUnitPriceNet: number; +}; + +const MIN_BILLABLE_AMOUNT = 0.01; + +type EffectivePlanMeterLink = { + meterId: string; + unitPriceNet?: string | null; + meter?: { + id: string; + key: string; + name: string; + unitLabel?: string | null; + aggregator: import('../entities/meter.entity').MeterAggregator; + defaultUnitPriceNet: string; + isActive: boolean; + }; +}; + +@Injectable() +export class MeterBillingService { + constructor( + private readonly servicePlansRepository: ServicePlansRepository, + private readonly servicePlanMetersRepository: ServicePlanMetersRepository, + private readonly serviceTypeMetersRepository: ServiceTypeMetersRepository, + private readonly addonMetersRepository: AddonMetersRepository, + private readonly subscriptionAddonsRepository: SubscriptionAddonsRepository, + private readonly usageRecordsRepository: UsageRecordsRepository, + ) {} + + private async resolveServiceTypeIdForPlan(planId: string, known?: string | null): Promise { + if (known !== undefined) { + return known ?? null; + } + + const plan = await this.servicePlansRepository.findById(planId); + + return plan?.serviceTypeId ?? null; + } + + private async resolveEffectivePlanMeterLinks( + planId: string, + serviceTypeId?: string | null, + ): Promise { + const resolvedTypeId = await this.resolveServiceTypeIdForPlan(planId, serviceTypeId); + const planLinks = await this.servicePlanMetersRepository.findByPlanId(planId); + const byMeterId = new Map(); + + for (const link of planLinks) { + byMeterId.set(link.meterId, link); + } + + if (resolvedTypeId) { + const typeLinks = await this.serviceTypeMetersRepository.findByServiceTypeId(resolvedTypeId); + + for (const link of typeLinks) { + if (!byMeterId.has(link.meterId)) { + byMeterId.set(link.meterId, link); + } + } + } + + return Array.from(byMeterId.values()); + } + + async hasAnyMeterAttachments(subscription: SubscriptionEntity, serviceTypeId?: string | null): Promise { + const planMeters = await this.resolveEffectivePlanMeterLinks(subscription.planId, serviceTypeId); + + if (planMeters.length > 0) { + return true; + } + + const billableAddons = await this.subscriptionAddonsRepository.findBillableBySubscriptionId(subscription.id); + + if (billableAddons.length === 0) { + return false; + } + + const addonMeters = await this.addonMetersRepository.findByAddonIds(billableAddons.map((row) => row.addonId)); + + return addonMeters.length > 0; + } + + async buildMeterChargeLines(params: { + subscription: SubscriptionEntity; + plan: ServicePlanEntity; + periodStart: Date; + periodEnd: Date; + }): Promise { + if (params.plan.billInAdvance === true) { + return []; + } + + const usageRows = await this.usageRecordsRepository.findMeteredForSubscription(params.subscription.id); + const entries: MeterUsageEntry[] = usageRows.map((row) => ({ + id: row.id, + meterId: row.meterId, + value: row.value, + attachmentType: row.attachmentType, + addonId: row.addonId, + periodStart: row.periodStart, + periodEnd: row.periodEnd, + createdAt: row.createdAt, + })); + + const lines: MeterChargeLine[] = []; + const planMeters = await this.resolveEffectivePlanMeterLinks(params.subscription.planId, params.plan.serviceTypeId); + + for (const link of planMeters) { + const meter = link.meter; + + if (!meter || !meter.isActive) { + continue; + } + + const matching = filterEntriesForAttachment(entries, { + meterId: meter.id, + attachmentType: 'plan', + periodStart: params.periodStart, + periodEnd: params.periodEnd, + }); + const aggregatedValue = aggregateMeterValues(matching, meter.aggregator); + const effectiveUnitPriceNet = resolveEffectiveUnitPriceNet(link.unitPriceNet, meter.defaultUnitPriceNet); + const unitPriceNet = Math.round(aggregatedValue * effectiveUnitPriceNet * 100) / 100; + + if (unitPriceNet < MIN_BILLABLE_AMOUNT) { + continue; + } + + const unitSuffix = meter.unitLabel ? ` (${meter.unitLabel})` : ''; + + lines.push({ + description: `Usage: ${meter.name}${unitSuffix}`, + quantity: 1, + unitPriceNet, + meterId: meter.id, + attachmentType: 'plan', + addonId: null, + aggregatedValue, + effectiveUnitPriceNet, + }); + } + + const billableAddons = await this.subscriptionAddonsRepository.findBillableBySubscriptionId(params.subscription.id); + const addonMeterLinks = await this.addonMetersRepository.findByAddonIds(billableAddons.map((row) => row.addonId)); + const linksByAddon = new Map(); + + for (const link of addonMeterLinks) { + const list = linksByAddon.get(link.addonId) ?? []; + list.push(link); + linksByAddon.set(link.addonId, list); + } + + for (const subscriptionAddon of billableAddons) { + const links = linksByAddon.get(subscriptionAddon.addonId) ?? []; + + for (const link of links) { + const meter = link.meter; + + if (!meter || !meter.isActive) { + continue; + } + + const matching = filterEntriesForAttachment(entries, { + meterId: meter.id, + attachmentType: 'addon', + addonId: subscriptionAddon.addonId, + periodStart: params.periodStart, + periodEnd: params.periodEnd, + }); + const aggregatedValue = aggregateMeterValues(matching, meter.aggregator); + const effectiveUnitPriceNet = resolveEffectiveUnitPriceNet(link.unitPriceNet, meter.defaultUnitPriceNet); + const unitPriceNet = Math.round(aggregatedValue * effectiveUnitPriceNet * 100) / 100; + + if (unitPriceNet < MIN_BILLABLE_AMOUNT) { + continue; + } + + const unitSuffix = meter.unitLabel ? ` (${meter.unitLabel})` : ''; + const addonLabel = subscriptionAddon.addonNameSnapshot || 'Addon'; + + lines.push({ + description: `Usage: ${meter.name}${unitSuffix} — ${addonLabel}`, + quantity: 1, + unitPriceNet, + meterId: meter.id, + attachmentType: 'addon', + addonId: subscriptionAddon.addonId, + aggregatedValue, + effectiveUnitPriceNet, + }); + } + } + + return lines; + } + + async buildSubscriptionMeterSummaries(params: { + subscription: SubscriptionEntity; + periodStart?: Date | null; + periodEnd?: Date | null; + }): Promise { + const periodStart = params.periodStart ?? params.subscription.currentPeriodStart ?? params.subscription.createdAt; + const periodEnd = params.periodEnd ?? params.subscription.currentPeriodEnd ?? new Date(); + const usageRows = await this.usageRecordsRepository.findMeteredForSubscription(params.subscription.id); + const entries: MeterUsageEntry[] = usageRows.map((row) => ({ + id: row.id, + meterId: row.meterId, + value: row.value, + attachmentType: row.attachmentType, + addonId: row.addonId, + periodStart: row.periodStart, + periodEnd: row.periodEnd, + createdAt: row.createdAt, + })); + + const summaries: SubscriptionMeterSummaryDto[] = []; + const planMeters = await this.resolveEffectivePlanMeterLinks(params.subscription.planId); + + for (const link of planMeters) { + const meter = link.meter; + + if (!meter) { + continue; + } + + const matching = filterEntriesForAttachment(entries, { + meterId: meter.id, + attachmentType: 'plan', + periodStart, + periodEnd, + }); + const aggregatedValue = aggregateMeterValues(matching, meter.aggregator); + const effectiveUnitPriceNet = resolveEffectiveUnitPriceNet(link.unitPriceNet, meter.defaultUnitPriceNet); + + summaries.push({ + meterId: meter.id, + key: meter.key, + name: meter.name, + unitLabel: meter.unitLabel ?? null, + aggregator: meter.aggregator, + attachmentType: 'plan', + addonId: null, + addonName: null, + effectiveUnitPriceNet, + aggregatedValue, + estimatedChargeNet: Math.round(aggregatedValue * effectiveUnitPriceNet * 100) / 100, + entryCount: matching.length, + periodStart, + periodEnd, + }); + } + + const billableAddons = await this.subscriptionAddonsRepository.findBillableBySubscriptionId(params.subscription.id); + const addonMeterLinks = await this.addonMetersRepository.findByAddonIds(billableAddons.map((row) => row.addonId)); + const linksByAddon = new Map(); + + for (const link of addonMeterLinks) { + const list = linksByAddon.get(link.addonId) ?? []; + list.push(link); + linksByAddon.set(link.addonId, list); + } + + for (const subscriptionAddon of billableAddons) { + const links = linksByAddon.get(subscriptionAddon.addonId) ?? []; + + for (const link of links) { + const meter = link.meter; + + if (!meter) { + continue; + } + + const matching = filterEntriesForAttachment(entries, { + meterId: meter.id, + attachmentType: 'addon', + addonId: subscriptionAddon.addonId, + periodStart, + periodEnd, + }); + const aggregatedValue = aggregateMeterValues(matching, meter.aggregator); + const effectiveUnitPriceNet = resolveEffectiveUnitPriceNet(link.unitPriceNet, meter.defaultUnitPriceNet); + + summaries.push({ + meterId: meter.id, + key: meter.key, + name: meter.name, + unitLabel: meter.unitLabel ?? null, + aggregator: meter.aggregator, + attachmentType: 'addon', + addonId: subscriptionAddon.addonId, + addonName: subscriptionAddon.addonNameSnapshot, + effectiveUnitPriceNet, + aggregatedValue, + estimatedChargeNet: Math.round(aggregatedValue * effectiveUnitPriceNet * 100) / 100, + entryCount: matching.length, + periodStart, + periodEnd, + }); + } + } + + return summaries; + } +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.spec.ts new file mode 100644 index 000000000..a74c65305 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.spec.ts @@ -0,0 +1,361 @@ +import { METER_COLLECT_USAGE_SOURCE } from '../dto/meter-collect.types'; +import { MeterCollectJobHandler } from './meter-collect.job-handler'; + +describe('MeterCollectJobHandler', () => { + const now = new Date('2026-08-06T12:00:00.000Z'); + + const subscriptionsRepository = { + findActiveArrearForMeterCollect: jest.fn(), + findByIdOrThrow: jest.fn(), + }; + const servicePlansRepository = { + findByIdOrThrow: jest.fn(), + }; + const subscriptionItemsRepository = { + findBySubscription: jest.fn(), + }; + const subscriptionAddonsRepository = { + findActiveBySubscriptionId: jest.fn(), + }; + const addonsRepository = { + findByIdOrThrow: jest.fn(), + }; + const addonMetersRepository = { + findByAddonId: jest.fn(), + }; + const usageRecordsRepository = { + findLatestCollectorForMeter: jest.fn(), + }; + const meterService = { + listEffectivePlanMeters: jest.fn(), + }; + const usageService = { + createUsage: jest.fn(), + }; + const providerModuleRegistry = { + get: jest.fn(), + }; + const providerRegistry = { + getProvider: jest.fn(), + }; + const addonModuleRegistry = { + get: jest.fn(), + }; + + const handler = new MeterCollectJobHandler( + subscriptionsRepository as never, + servicePlansRepository as never, + subscriptionItemsRepository as never, + subscriptionAddonsRepository as never, + addonsRepository as never, + addonMetersRepository as never, + usageRecordsRepository as never, + meterService as never, + usageService as never, + providerModuleRegistry as never, + providerRegistry as never, + addonModuleRegistry as never, + ); + + beforeEach(() => { + jest.clearAllMocks(); + subscriptionsRepository.findActiveArrearForMeterCollect.mockReset(); + subscriptionItemsRepository.findBySubscription.mockReset(); + usageRecordsRepository.findLatestCollectorForMeter.mockReset(); + providerModuleRegistry.get.mockReset(); + providerRegistry.getProvider.mockReset(); + usageService.createUsage.mockReset(); + meterService.listEffectivePlanMeters.mockReset(); + servicePlansRepository.findByIdOrThrow.mockReset(); + subscriptionAddonsRepository.findActiveBySubscriptionId.mockReset(); + subscriptionAddonsRepository.findActiveBySubscriptionId.mockResolvedValue([]); + }); + + const activeSub = (overrides: Record = {}) => ({ + id: 'sub-1', + planId: 'plan-1', + nextBillingAt: new Date('2026-09-01T00:00:00.000Z'), + ...overrides, + }); + + it('writes collector usage for due plan meters', async () => { + subscriptionsRepository.findActiveArrearForMeterCollect.mockResolvedValue([activeSub()]); + subscriptionItemsRepository.findBySubscription.mockResolvedValue([ + { + id: 'item-1', + providerReference: 'srv-1', + hostname: 'host', + serviceTypeId: 'st-1', + serviceType: { provider: 'acme' }, + }, + ]); + providerRegistry.getProvider.mockReturnValue({ + id: 'acme', + meters: [ + { + key: 'cpu', + name: 'CPU', + aggregator: 'max', + defaultUnitPriceNet: 1, + collectionIntervalMs: 60_000, + }, + ], + }); + providerModuleRegistry.get.mockReturnValue({ + id: 'acme', + collectMeters: jest.fn().mockResolvedValue([{ meterKey: 'cpu', value: 42 }]), + }); + servicePlansRepository.findByIdOrThrow.mockResolvedValue({ id: 'plan-1', serviceTypeId: 'st-1' }); + meterService.listEffectivePlanMeters.mockResolvedValue([{ meterId: 'meter-1', key: 'cpu', name: 'CPU' }]); + usageRecordsRepository.findLatestCollectorForMeter.mockResolvedValue(null); + usageService.createUsage.mockResolvedValue({ id: 'usage-1' }); + + await handler.processTenant('default', now); + + expect(providerModuleRegistry.get('acme').collectMeters).toHaveBeenCalledWith( + expect.objectContaining({ + subscriptionId: 'sub-1', + meterKeys: ['cpu'], + providerReference: 'srv-1', + periodEnd: now, + }), + ); + expect(usageService.createUsage).toHaveBeenCalledWith( + expect.objectContaining({ + subscriptionId: 'sub-1', + meterId: 'meter-1', + value: 42, + usageSource: METER_COLLECT_USAGE_SOURCE, + attachmentType: 'plan', + }), + ); + }); + + it('skips collect when interval has not elapsed', async () => { + subscriptionsRepository.findActiveArrearForMeterCollect.mockResolvedValue([activeSub()]); + subscriptionItemsRepository.findBySubscription.mockResolvedValue([ + { + id: 'item-1', + providerReference: 'srv-1', + serviceType: { provider: 'acme' }, + }, + ]); + providerRegistry.getProvider.mockReturnValue({ + id: 'acme', + meters: [ + { + key: 'cpu', + name: 'CPU', + aggregator: 'max', + defaultUnitPriceNet: 1, + collectionIntervalMs: 60_000, + }, + ], + }); + providerModuleRegistry.get.mockReturnValue({ + id: 'acme', + collectMeters: jest.fn(), + }); + servicePlansRepository.findByIdOrThrow.mockResolvedValue({ id: 'plan-1', serviceTypeId: 'st-1' }); + meterService.listEffectivePlanMeters.mockResolvedValue([{ meterId: 'meter-1', key: 'cpu', name: 'CPU' }]); + usageRecordsRepository.findLatestCollectorForMeter.mockResolvedValue({ + periodEnd: new Date(now.getTime() - 10_000), + }); + + await handler.processTenant('default', now); + + expect(providerModuleRegistry.get('acme').collectMeters).not.toHaveBeenCalled(); + expect(usageService.createUsage).not.toHaveBeenCalled(); + }); + + it('writes addon collector usage when module collectMeters is due', async () => { + subscriptionsRepository.findActiveArrearForMeterCollect.mockResolvedValue([activeSub()]); + subscriptionItemsRepository.findBySubscription.mockResolvedValue([ + { + id: 'item-1', + providerReference: 'srv-1', + serviceType: { provider: 'acme' }, + }, + ]); + providerRegistry.getProvider.mockReturnValue({ id: 'acme', meters: [] }); + providerModuleRegistry.get.mockReturnValue({ id: 'acme', collectMeters: jest.fn().mockResolvedValue([]) }); + servicePlansRepository.findByIdOrThrow.mockResolvedValue({ id: 'plan-1', serviceTypeId: 'st-1' }); + meterService.listEffectivePlanMeters.mockResolvedValue([]); + + subscriptionAddonsRepository.findActiveBySubscriptionId.mockResolvedValue([ + { id: 'sa-1', addonId: 'addon-1', addon: null, configSnapshot: { a: 1 } }, + ]); + addonsRepository.findByIdOrThrow.mockResolvedValue({ + id: 'addon-1', + key: 'backup', + implementationType: 'module', + moduleKey: 'backup-mod', + }); + addonModuleRegistry.get.mockReturnValue({ + key: 'backup-mod', + displayName: 'Backup', + meters: [ + { + key: 'storage', + name: 'Storage', + aggregator: 'max', + defaultUnitPriceNet: 2, + collectionIntervalMs: 60_000, + }, + ], + provision: jest.fn(), + teardown: jest.fn(), + collectMeters: jest.fn().mockResolvedValue([{ meterKey: 'storage', value: 9 }]), + }); + addonMetersRepository.findByAddonId.mockResolvedValue([ + { meterId: 'meter-2', meter: { id: 'meter-2', key: 'storage' } }, + ]); + usageRecordsRepository.findLatestCollectorForMeter.mockResolvedValue(null); + usageService.createUsage.mockResolvedValue({ id: 'usage-2' }); + + await handler.processTenant('default', now); + + expect(usageService.createUsage).toHaveBeenCalledWith( + expect.objectContaining({ + subscriptionId: 'sub-1', + meterId: 'meter-2', + value: 9, + usageSource: METER_COLLECT_USAGE_SOURCE, + attachmentType: 'addon', + addonId: 'addon-1', + }), + ); + }); + + it('isolates per-subscription errors', async () => { + subscriptionsRepository.findActiveArrearForMeterCollect.mockResolvedValue([ + activeSub({ id: 'sub-bad' }), + activeSub({ id: 'sub-good' }), + ]); + subscriptionItemsRepository.findBySubscription.mockRejectedValueOnce(new Error('boom')).mockResolvedValue([ + { + id: 'item-1', + providerReference: 'srv-1', + serviceType: { provider: 'acme' }, + }, + ]); + providerRegistry.getProvider.mockReturnValue({ + id: 'acme', + meters: [ + { + key: 'cpu', + name: 'CPU', + aggregator: 'max', + defaultUnitPriceNet: 1, + collectionIntervalMs: 60_000, + }, + ], + }); + providerModuleRegistry.get.mockReturnValue({ + id: 'acme', + collectMeters: jest.fn().mockResolvedValue([{ meterKey: 'cpu', value: 1 }]), + }); + servicePlansRepository.findByIdOrThrow.mockResolvedValue({ id: 'plan-1', serviceTypeId: 'st-1' }); + meterService.listEffectivePlanMeters.mockResolvedValue([{ meterId: 'meter-1', key: 'cpu', name: 'CPU' }]); + usageRecordsRepository.findLatestCollectorForMeter.mockResolvedValue(null); + usageService.createUsage.mockResolvedValue({ id: 'usage-1' }); + + await expect(handler.processTenant('default', now)).resolves.toBeUndefined(); + expect(usageService.createUsage).toHaveBeenCalled(); + }); + + it('skips when collectMeters is missing for collectable meters', async () => { + subscriptionsRepository.findActiveArrearForMeterCollect.mockResolvedValue([activeSub()]); + subscriptionItemsRepository.findBySubscription.mockResolvedValue([ + { + id: 'item-1', + providerReference: 'srv-1', + serviceType: { provider: 'acme' }, + }, + ]); + providerRegistry.getProvider.mockReturnValue({ + id: 'acme', + meters: [ + { + key: 'cpu', + name: 'CPU', + aggregator: 'max', + defaultUnitPriceNet: 1, + collectionIntervalMs: 60_000, + }, + ], + }); + providerModuleRegistry.get.mockReturnValue(undefined); + servicePlansRepository.findByIdOrThrow.mockResolvedValue({ id: 'plan-1', serviceTypeId: 'st-1' }); + meterService.listEffectivePlanMeters.mockResolvedValue([{ meterId: 'meter-1', key: 'cpu', name: 'CPU' }]); + usageRecordsRepository.findLatestCollectorForMeter.mockResolvedValue(null); + + await handler.processTenant('default', now); + + expect(usageService.createUsage).not.toHaveBeenCalled(); + }); + + it('pages through subscription batches until exhausted', async () => { + const firstPage = Array.from({ length: 2 }, (_, index) => activeSub({ id: `sub-${index}` })); + const originalBatchSize = (handler as unknown as { batchSize: number }).batchSize; + Object.defineProperty(handler, 'batchSize', { value: 2, configurable: true }); + subscriptionsRepository.findActiveArrearForMeterCollect + .mockResolvedValueOnce(firstPage) + .mockResolvedValueOnce([activeSub({ id: 'sub-2' })]) + .mockResolvedValueOnce([]); + subscriptionItemsRepository.findBySubscription.mockResolvedValue([]); + + try { + await handler.processTenant('default', now); + + expect(subscriptionsRepository.findActiveArrearForMeterCollect).toHaveBeenNthCalledWith(1, 2, 0); + expect(subscriptionsRepository.findActiveArrearForMeterCollect).toHaveBeenNthCalledWith(2, 2, 2); + expect(subscriptionsRepository.findActiveArrearForMeterCollect).toHaveBeenCalledTimes(2); + } finally { + Object.defineProperty(handler, 'batchSize', { value: originalBatchSize, configurable: true }); + } + }); + + it('clamps collector periodEnd to nextBillingAt when collect runs after period end', async () => { + const nextBillingAt = new Date('2026-08-06T11:00:00.000Z'); + subscriptionsRepository.findActiveArrearForMeterCollect.mockResolvedValue([activeSub({ nextBillingAt })]); + subscriptionItemsRepository.findBySubscription.mockResolvedValue([ + { + id: 'item-1', + providerReference: 'srv-1', + serviceType: { provider: 'acme' }, + }, + ]); + providerRegistry.getProvider.mockReturnValue({ + id: 'acme', + meters: [ + { + key: 'cpu', + name: 'CPU', + aggregator: 'max', + defaultUnitPriceNet: 1, + collectionIntervalMs: 60_000, + }, + ], + }); + providerModuleRegistry.get.mockReturnValue({ + id: 'acme', + collectMeters: jest.fn().mockResolvedValue([{ meterKey: 'cpu', value: 7 }]), + }); + servicePlansRepository.findByIdOrThrow.mockResolvedValue({ id: 'plan-1', serviceTypeId: 'st-1' }); + meterService.listEffectivePlanMeters.mockResolvedValue([{ meterId: 'meter-1', key: 'cpu', name: 'CPU' }]); + usageRecordsRepository.findLatestCollectorForMeter.mockResolvedValue({ + periodEnd: new Date('2026-08-06T10:00:00.000Z'), + }); + usageService.createUsage.mockResolvedValue({ id: 'usage-1' }); + + await handler.processTenant('default', now); + + expect(usageService.createUsage).toHaveBeenCalledWith( + expect.objectContaining({ + periodEnd: nextBillingAt, + value: 7, + }), + ); + }); +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts new file mode 100644 index 000000000..e5bd01e35 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts @@ -0,0 +1,382 @@ +import { runWithTenantId } from '@forepath/shared/backend'; +import { Injectable, Logger } from '@nestjs/common'; + +import type { DeclaredMeterDefinition } from '../dto/declared-meter.dto'; +import { METER_COLLECT_USAGE_SOURCE, type MeterCollectSample } from '../dto/meter-collect.types'; +import type { UsageAttachmentType } from '../entities/usage-record.entity'; +import { AddonsRepository } from '../repositories/addons.repository'; +import { AddonMetersRepository } from '../repositories/addon-meters.repository'; +import { ServicePlansRepository } from '../repositories/service-plans.repository'; +import { SubscriptionAddonsRepository } from '../repositories/subscription-addons.repository'; +import { SubscriptionItemsRepository } from '../repositories/subscription-items.repository'; +import { SubscriptionsRepository } from '../repositories/subscriptions.repository'; +import { UsageRecordsRepository } from '../repositories/usage-records.repository'; + +import { AddonModuleRegistryService } from './addon-module-registry.service'; +import { MeterService } from './meter.service'; +import { ProviderModuleRegistryService } from './provider-module-registry.service'; +import { ProviderRegistryService } from './provider-registry.service'; +import { UsageService } from './usage.service'; + +interface DueMeterWindow { + meterKey: string; + meterId: string; + intervalMs: number; + periodStart: Date; + periodEnd: Date; +} + +@Injectable() +export class MeterCollectJobHandler { + private readonly logger = new Logger(MeterCollectJobHandler.name); + private readonly batchSize = parseInt(process.env.BILLING_METER_COLLECT_BATCH_SIZE ?? '500', 10); + + constructor( + private readonly subscriptionsRepository: SubscriptionsRepository, + private readonly servicePlansRepository: ServicePlansRepository, + private readonly subscriptionItemsRepository: SubscriptionItemsRepository, + private readonly subscriptionAddonsRepository: SubscriptionAddonsRepository, + private readonly addonsRepository: AddonsRepository, + private readonly addonMetersRepository: AddonMetersRepository, + private readonly usageRecordsRepository: UsageRecordsRepository, + private readonly meterService: MeterService, + private readonly usageService: UsageService, + private readonly providerModuleRegistry: ProviderModuleRegistryService, + private readonly providerRegistry: ProviderRegistryService, + private readonly addonModuleRegistry: AddonModuleRegistryService, + ) {} + + async processTenant(tenantId: string, now: Date = new Date()): Promise { + await runWithTenantId(tenantId, async () => { + let offset = 0; + + // eslint-disable-next-line no-constant-condition + while (true) { + const subscriptions = await this.subscriptionsRepository.findActiveArrearForMeterCollect( + this.batchSize, + offset, + ); + + if (subscriptions.length === 0) { + break; + } + + for (const subscription of subscriptions) { + try { + await this.collectForSubscription(subscription, now); + } catch (error) { + this.logger.warn( + `Meter collect failed for subscription ${subscription.id}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + if (subscriptions.length < this.batchSize) { + break; + } + + offset += subscriptions.length; + } + }); + } + + private async collectForSubscription( + subscription: { id: string; planId: string; nextBillingAt?: Date | null; currentPeriodEnd?: Date | null }, + now: Date, + ): Promise { + const chargePeriodEnd = subscription.nextBillingAt ?? subscription.currentPeriodEnd ?? null; + + await this.collectPlanMeters(subscription.id, subscription.planId, now, chargePeriodEnd); + await this.collectAddonMeters(subscription.id, now, chargePeriodEnd); + } + + private async collectPlanMeters( + subscriptionId: string, + planId: string, + now: Date, + chargePeriodEnd: Date | null, + ): Promise { + const items = await this.subscriptionItemsRepository.findBySubscription(subscriptionId); + const item = items.find((row) => row.providerReference?.trim()); + + if (!item?.providerReference) { + return; + } + + const providerId = item.serviceType?.provider?.trim(); + + if (!providerId) { + return; + } + + const declaredByKey = this.resolveProviderDeclaredMeters(providerId); + + if (declaredByKey.size === 0) { + return; + } + + const plan = await this.servicePlansRepository.findByIdOrThrow(planId); + const effective = await this.meterService.listEffectivePlanMeters(planId, plan.serviceTypeId); + const dueWindows: DueMeterWindow[] = []; + + for (const attached of effective) { + const declared = declaredByKey.get(attached.key); + + if (!declared?.collectionIntervalMs) { + continue; + } + + const window = await this.resolveDueWindow({ + subscriptionId, + meterId: attached.meterId, + meterKey: attached.key, + intervalMs: declared.collectionIntervalMs, + attachmentType: 'plan', + addonId: null, + now, + chargePeriodEnd, + }); + + if (window) { + dueWindows.push(window); + } + } + + if (dueWindows.length === 0) { + return; + } + + const providerModule = this.providerModuleRegistry.get(providerId); + + if (!providerModule?.collectMeters) { + this.logger.warn( + `Provider '${providerId}' has collectable meters but no collectMeters implementation; skipping subscription ${subscriptionId}`, + ); + + return; + } + + // One collect call per due meter so sample windows match persisted periods. + for (const window of dueWindows) { + const samples = await providerModule.collectMeters({ + subscriptionId, + subscriptionItemId: item.id, + provider: providerId, + providerReference: item.providerReference, + hostname: item.hostname, + meterKeys: [window.meterKey], + periodStart: window.periodStart, + periodEnd: window.periodEnd, + }); + + await this.persistSamples({ + subscriptionId, + samples, + dueWindows: [window], + attachmentType: 'plan', + }); + } + } + + private async collectAddonMeters(subscriptionId: string, now: Date, chargePeriodEnd: Date | null): Promise { + const items = await this.subscriptionItemsRepository.findBySubscription(subscriptionId); + const item = items.find((row) => row.providerReference?.trim()); + const billableAddons = await this.subscriptionAddonsRepository.findActiveBySubscriptionId(subscriptionId); + + for (const subscriptionAddon of billableAddons) { + try { + const addon = + subscriptionAddon.addon ?? (await this.addonsRepository.findByIdOrThrow(subscriptionAddon.addonId)); + + if (addon.implementationType !== 'module' || !addon.moduleKey?.trim()) { + continue; + } + + const module = this.addonModuleRegistry.get(addon.moduleKey.trim()); + const declaredByKey = new Map((module?.meters ?? []).map((def) => [def.key.trim(), def] as const)); + + if (declaredByKey.size === 0) { + continue; + } + + const links = await this.addonMetersRepository.findByAddonId(addon.id); + const dueWindows: DueMeterWindow[] = []; + + for (const link of links) { + const meter = link.meter; + + if (!meter) { + continue; + } + + const def = declaredByKey.get(meter.key); + + if (!def?.collectionIntervalMs) { + continue; + } + + const window = await this.resolveDueWindow({ + subscriptionId, + meterId: meter.id, + meterKey: meter.key, + intervalMs: def.collectionIntervalMs, + attachmentType: 'addon', + addonId: addon.id, + now, + chargePeriodEnd, + }); + + if (window) { + dueWindows.push(window); + } + } + + if (dueWindows.length === 0) { + continue; + } + + if (!module?.collectMeters) { + this.logger.warn( + `Addon module '${addon.moduleKey}' has collectable meters but no collectMeters; skipping addon ${addon.id} on ${subscriptionId}`, + ); + continue; + } + + const providerId = item?.serviceType?.provider ?? ''; + + for (const window of dueWindows) { + const samples = await module.collectMeters({ + subscriptionId, + subscriptionItemId: item?.id, + provider: providerId, + providerReference: item?.providerReference, + addonId: addon.id, + addonKey: addon.key, + configSnapshot: subscriptionAddon.configSnapshot, + hostname: item?.hostname, + meterKeys: [window.meterKey], + periodStart: window.periodStart, + periodEnd: window.periodEnd, + }); + + await this.persistSamples({ + subscriptionId, + samples, + dueWindows: [window], + attachmentType: 'addon', + addonId: addon.id, + }); + } + } catch (error) { + this.logger.warn( + `Meter collect failed for subscription addon ${subscriptionAddon.id}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } + + private resolveProviderDeclaredMeters(providerId: string): Map { + const byKey = new Map(); + const metadata = this.providerRegistry.getProvider(providerId); + const runtime = this.providerModuleRegistry.get(providerId); + + for (const def of metadata?.meters ?? []) { + byKey.set(def.key.trim(), def); + } + + for (const def of runtime?.meters ?? []) { + byKey.set(def.key.trim(), def); + } + + return byKey; + } + + private async resolveDueWindow(params: { + subscriptionId: string; + meterId: string; + meterKey: string; + intervalMs: number; + attachmentType: UsageAttachmentType; + addonId: string | null; + now: Date; + chargePeriodEnd: Date | null; + }): Promise { + const last = await this.usageRecordsRepository.findLatestCollectorForMeter({ + subscriptionId: params.subscriptionId, + meterId: params.meterId, + attachmentType: params.attachmentType, + addonId: params.addonId, + }); + + if (last?.periodEnd) { + const elapsed = params.now.getTime() - new Date(last.periodEnd).getTime(); + + if (elapsed < params.intervalMs) { + return null; + } + } + + // Settle into the open charge window when collect runs after nextBillingAt but before the billing tick. + const periodEnd = + params.chargePeriodEnd && params.now.getTime() > params.chargePeriodEnd.getTime() + ? new Date(params.chargePeriodEnd) + : params.now; + const periodStart = last?.periodEnd ? new Date(last.periodEnd) : new Date(periodEnd.getTime() - params.intervalMs); + + if (periodStart.getTime() >= periodEnd.getTime()) { + return null; + } + + return { + meterKey: params.meterKey, + meterId: params.meterId, + intervalMs: params.intervalMs, + periodStart, + periodEnd, + }; + } + + private async persistSamples(params: { + subscriptionId: string; + samples: MeterCollectSample[]; + dueWindows: DueMeterWindow[]; + attachmentType: UsageAttachmentType; + addonId?: string; + }): Promise { + const dueByKey = new Map(params.dueWindows.map((row) => [row.meterKey, row])); + + for (const sample of params.samples) { + const due = dueByKey.get(sample.meterKey.trim()); + + if (!due) { + this.logger.debug( + `Ignoring collector sample for unexpected meter key '${sample.meterKey}' on subscription ${params.subscriptionId}`, + ); + continue; + } + + if (!Number.isFinite(sample.value) || sample.value < 0) { + this.logger.warn( + `Collector sample for meter '${sample.meterKey}' on subscription ${params.subscriptionId} is invalid; skipping`, + ); + continue; + } + + await this.usageService.createUsage({ + subscriptionId: params.subscriptionId, + periodStart: due.periodStart, + periodEnd: due.periodEnd, + usageSource: METER_COLLECT_USAGE_SOURCE, + usagePayload: sample.usagePayload ?? {}, + meterId: due.meterId, + value: sample.value, + attachmentType: params.attachmentType, + addonId: params.addonId, + }); + } + } +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.spec.ts new file mode 100644 index 000000000..0201ac757 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.spec.ts @@ -0,0 +1,294 @@ +import { BadRequestException } from '@nestjs/common'; + +import { MeterService } from './meter.service'; + +describe('MeterService', () => { + const metersRepository = { + create: jest.fn(), + update: jest.fn(), + findByIdOrThrow: jest.fn(), + findByKey: jest.fn(), + delete: jest.fn(), + }; + const servicePlanMetersRepository = { + countByMeterId: jest.fn(), + findByPlanId: jest.fn(), + findByPlanAndMeter: jest.fn(), + findByPlanAndMeterOrThrow: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + deleteByPlanAndMeter: jest.fn(), + }; + const addonMetersRepository = { + countByMeterId: jest.fn(), + findByAddonId: jest.fn(), + findByAddonAndMeter: jest.fn(), + findByAddonAndMeterOrThrow: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + deleteByAddonAndMeter: jest.fn(), + }; + const serviceTypeMetersRepository = { + countByMeterId: jest.fn(), + findByServiceTypeId: jest.fn(), + findByServiceTypeAndMeter: jest.fn(), + findByServiceTypeAndMeterOrThrow: jest.fn(), + create: jest.fn(), + update: jest.fn(), + deleteByServiceTypeAndMeter: jest.fn(), + }; + const usageRecordsRepository = { + countByMeterId: jest.fn(), + }; + const billingNotificationPublisher = { + publish: jest.fn(), + }; + const addonModuleRegistry = { + get: jest.fn(), + }; + const providerRegistry = { + getProvider: jest.fn(), + }; + + const service = new MeterService( + metersRepository as never, + servicePlanMetersRepository as never, + addonMetersRepository as never, + serviceTypeMetersRepository as never, + usageRecordsRepository as never, + billingNotificationPublisher as never, + addonModuleRegistry as never, + providerRegistry as never, + ); + + const catalogMeter = { + id: 'meter-1', + key: 'traffic', + name: 'Traffic', + description: null, + unitLabel: 'GB', + aggregator: 'max' as const, + defaultUnitPriceNet: '0.01', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('blocks delete when meter is still referenced', async () => { + metersRepository.findByIdOrThrow.mockResolvedValue({ id: 'meter-1', key: 'traffic' }); + servicePlanMetersRepository.countByMeterId.mockResolvedValue(1); + addonMetersRepository.countByMeterId.mockResolvedValue(0); + serviceTypeMetersRepository.countByMeterId.mockResolvedValue(0); + usageRecordsRepository.countByMeterId.mockResolvedValue(0); + + await expect(service.deleteMeter('meter-1')).rejects.toThrow(BadRequestException); + expect(metersRepository.delete).not.toHaveBeenCalled(); + }); + + it('publishes meter.created on create', async () => { + metersRepository.findByKey.mockResolvedValue(null); + metersRepository.create.mockResolvedValue(catalogMeter); + + await service.createMeter({ + key: 'traffic', + name: 'Traffic', + aggregator: 'max', + defaultUnitPriceNet: 0.01, + }); + + expect(billingNotificationPublisher.publish).toHaveBeenCalledWith( + 'meter.created', + expect.objectContaining({ meterId: 'meter-1', key: 'traffic' }), + ); + }); + + it('ensureCatalogMeter returns existing meter without clobbering defaults', async () => { + metersRepository.findByKey.mockResolvedValue(catalogMeter); + + const result = await service.ensureCatalogMeter({ + key: 'traffic', + name: 'New name', + aggregator: 'avg', + defaultUnitPriceNet: 9.99, + }); + + expect(result).toBe(catalogMeter); + expect(metersRepository.create).not.toHaveBeenCalled(); + }); + + it('ensureCatalogMeter creates missing catalog meters', async () => { + metersRepository.findByKey.mockResolvedValue(null); + metersRepository.create.mockResolvedValue(catalogMeter); + + await service.ensureCatalogMeter({ + key: 'traffic', + name: 'Traffic', + aggregator: 'max', + defaultUnitPriceNet: 0.01, + }); + + expect(metersRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'traffic', + name: 'Traffic', + aggregator: 'max', + defaultUnitPriceNet: '0.01', + isActive: true, + }), + ); + }); + + it('syncAddonModuleMeters sideloads required module meters', async () => { + addonModuleRegistry.get.mockReturnValue({ + key: 'backup', + displayName: 'Backup', + meters: [{ key: 'traffic', name: 'Traffic', aggregator: 'max', defaultUnitPriceNet: 0.01 }], + }); + metersRepository.findByKey.mockResolvedValue(catalogMeter); + addonMetersRepository.findByAddonId.mockResolvedValueOnce([]).mockResolvedValueOnce([ + { + id: 'link-1', + addonId: 'addon-1', + meterId: 'meter-1', + unitPriceNet: null, + source: 'module', + required: true, + meter: catalogMeter, + }, + ]); + addonMetersRepository.create.mockResolvedValue({ + id: 'link-1', + addonId: 'addon-1', + meterId: 'meter-1', + unitPriceNet: null, + source: 'module', + required: true, + }); + + const result = await service.syncAddonModuleMeters({ + id: 'addon-1', + implementationType: 'module', + moduleKey: 'backup', + } as never); + + expect(addonMetersRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + addonId: 'addon-1', + meterId: 'meter-1', + source: 'module', + required: true, + }), + ); + expect(result).toHaveLength(1); + expect(result[0].required).toBe(true); + expect(result[0].source).toBe('module'); + }); + + it('syncServiceTypeProviderMeters sideloads required provider meters', async () => { + providerRegistry.getProvider.mockReturnValue({ + id: 'hetzner', + displayName: 'Hetzner', + meters: [{ key: 'traffic', name: 'Traffic', aggregator: 'max', defaultUnitPriceNet: 0.01 }], + }); + metersRepository.findByKey.mockResolvedValue(catalogMeter); + serviceTypeMetersRepository.findByServiceTypeId.mockResolvedValueOnce([]).mockResolvedValueOnce([ + { + id: 'link-1', + serviceTypeId: 'st-1', + meterId: 'meter-1', + unitPriceNet: null, + source: 'provider', + required: true, + meter: catalogMeter, + }, + ]); + serviceTypeMetersRepository.create.mockResolvedValue({ + id: 'link-1', + serviceTypeId: 'st-1', + meterId: 'meter-1', + unitPriceNet: null, + source: 'provider', + required: true, + }); + + const result = await service.syncServiceTypeProviderMeters({ + id: 'st-1', + provider: 'hetzner', + } as never); + + expect(serviceTypeMetersRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + serviceTypeId: 'st-1', + meterId: 'meter-1', + source: 'provider', + required: true, + }), + ); + expect(result[0].source).toBe('provider'); + expect(result[0].required).toBe(true); + }); + + it('rejects detach of required service type meters', async () => { + serviceTypeMetersRepository.findByServiceTypeAndMeterOrThrow.mockResolvedValue({ + id: 'link-1', + required: true, + source: 'provider', + }); + + await expect(service.detachServiceTypeMeter('st-1', 'meter-1')).rejects.toThrow(BadRequestException); + expect(serviceTypeMetersRepository.deleteByServiceTypeAndMeter).not.toHaveBeenCalled(); + }); + + it('rejects detach of required addon meters', async () => { + addonMetersRepository.findByAddonAndMeterOrThrow.mockResolvedValue({ + id: 'link-1', + required: true, + source: 'module', + }); + + await expect(service.detachAddonMeter('addon-1', 'meter-1')).rejects.toThrow(BadRequestException); + expect(addonMetersRepository.deleteByAddonAndMeter).not.toHaveBeenCalled(); + }); + + it('listEffectivePlanMeters unions plan and service-type meters', async () => { + servicePlanMetersRepository.findByPlanId.mockResolvedValue([ + { + id: 'plan-link', + meterId: 'meter-plan', + unitPriceNet: null, + source: 'manual', + required: false, + meter: { ...catalogMeter, id: 'meter-plan', key: 'plan-only' }, + }, + ]); + serviceTypeMetersRepository.findByServiceTypeId.mockResolvedValue([ + { + id: 'type-link', + meterId: 'meter-1', + unitPriceNet: null, + source: 'provider', + required: true, + meter: catalogMeter, + }, + ]); + + const result = await service.listEffectivePlanMeters('plan-1', 'st-1'); + + expect(result).toHaveLength(2); + expect(result.find((item) => item.meterId === 'meter-1')?.inherited).toBe(true); + expect(result.find((item) => item.meterId === 'meter-plan')?.inherited).toBeUndefined(); + }); + + it('isPlanMeterAttached accepts inherited service-type meters', async () => { + servicePlanMetersRepository.findByPlanAndMeter.mockResolvedValue(null); + serviceTypeMetersRepository.findByServiceTypeAndMeter.mockResolvedValue({ id: 'type-link' }); + + await expect(service.isPlanMeterAttached('plan-1', 'st-1', 'meter-1')).resolves.toBe(true); + }); +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.ts new file mode 100644 index 000000000..512e669e4 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.ts @@ -0,0 +1,564 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; + +import type { MeterAttachmentSource } from '../constants/meter-attachment.constants'; +import { assertDeclaredMeterCollectionInterval, type DeclaredMeterDefinition } from '../dto/declared-meter.dto'; +import type { AttachedMeterResponseDto, MeterResponseDto } from '../dto/meter-response.dto'; +import type { CreateMeterDto, UpdateMeterDto } from '../dto/meter.dto'; +import type { AddonEntity } from '../entities/addon.entity'; +import type { AddonMeterEntity } from '../entities/addon-meter.entity'; +import type { MeterEntity } from '../entities/meter.entity'; +import type { ServicePlanMeterEntity } from '../entities/service-plan-meter.entity'; +import type { ServiceTypeEntity } from '../entities/service-type.entity'; +import type { ServiceTypeMeterEntity } from '../entities/service-type-meter.entity'; +import { AddonMetersRepository } from '../repositories/addon-meters.repository'; +import { MetersRepository } from '../repositories/meters.repository'; +import { ServicePlanMetersRepository } from '../repositories/service-plan-meters.repository'; +import { ServiceTypeMetersRepository } from '../repositories/service-type-meters.repository'; +import { UsageRecordsRepository } from '../repositories/usage-records.repository'; +import { resolveEffectiveUnitPriceNet } from '../utils/meter-aggregation.util'; +import { BillingNotificationPublisher } from '../notifications/billing-notification.publisher'; +import { AddonModuleRegistryService } from './addon-module-registry.service'; +import { ProviderRegistryService } from './provider-registry.service'; + +type MeterLinkEntity = ServicePlanMeterEntity | AddonMeterEntity | ServiceTypeMeterEntity; + +@Injectable() +export class MeterService { + constructor( + private readonly metersRepository: MetersRepository, + private readonly servicePlanMetersRepository: ServicePlanMetersRepository, + private readonly addonMetersRepository: AddonMetersRepository, + private readonly serviceTypeMetersRepository: ServiceTypeMetersRepository, + private readonly usageRecordsRepository: UsageRecordsRepository, + private readonly billingNotificationPublisher: BillingNotificationPublisher, + private readonly addonModuleRegistry: AddonModuleRegistryService, + private readonly providerRegistry: ProviderRegistryService, + ) {} + + mapMeterToResponse(row: MeterEntity): MeterResponseDto { + return { + id: row.id, + key: row.key, + name: row.name, + description: row.description ?? null, + unitLabel: row.unitLabel ?? null, + aggregator: row.aggregator, + defaultUnitPriceNet: Number(row.defaultUnitPriceNet), + isActive: row.isActive, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + + mapAttachedMeter( + link: MeterLinkEntity, + meter: MeterEntity, + options?: { inherited?: boolean }, + ): AttachedMeterResponseDto { + const defaultUnitPriceNet = Number(meter.defaultUnitPriceNet); + const unitPriceNetOverride = + link.unitPriceNet === null || link.unitPriceNet === undefined ? null : Number(link.unitPriceNet); + + return { + meterId: meter.id, + key: meter.key, + name: meter.name, + description: meter.description ?? null, + unitLabel: meter.unitLabel ?? null, + aggregator: meter.aggregator, + defaultUnitPriceNet, + unitPriceNetOverride, + effectiveUnitPriceNet: resolveEffectiveUnitPriceNet(unitPriceNetOverride, defaultUnitPriceNet), + isActive: meter.isActive, + source: link.source ?? 'manual', + required: link.required === true, + inherited: options?.inherited === true ? true : undefined, + }; + } + + async ensureCatalogMeter(def: DeclaredMeterDefinition): Promise { + assertDeclaredMeterCollectionInterval(def); + + const key = def.key.trim(); + const existing = await this.metersRepository.findByKey(key); + + if (existing) { + return existing; + } + + return await this.metersRepository.create({ + key, + name: def.name.trim(), + description: def.description?.trim() || null, + unitLabel: def.unitLabel?.trim() || null, + aggregator: def.aggregator, + defaultUnitPriceNet: String(def.defaultUnitPriceNet), + isActive: true, + }); + } + + async syncAddonModuleMeters(addon: AddonEntity): Promise { + if (addon.implementationType !== 'module' || !addon.moduleKey?.trim()) { + return await this.listAddonMeters(addon.id); + } + + const module = this.addonModuleRegistry.get(addon.moduleKey.trim()); + const declared = module?.meters ?? []; + const declaredKeys = new Set(declared.map((item) => item.key.trim())); + const existing = await this.addonMetersRepository.findByAddonId(addon.id); + + for (const def of declared) { + const meter = await this.ensureCatalogMeter(def); + const link = existing.find((row) => row.meterId === meter.id); + + if (!link) { + await this.addonMetersRepository.create({ + addonId: addon.id, + meterId: meter.id, + unitPriceNet: null, + source: 'module', + required: true, + }); + this.billingNotificationPublisher.publish('addon.meter_attached', { + addonId: addon.id, + meterId: meter.id, + unitPriceNet: null, + source: 'module', + required: true, + }); + } else if (link.source !== 'module' || link.required !== true) { + await this.addonMetersRepository.update(link.id, { source: 'module', required: true }); + } + } + + for (const link of existing) { + if (link.source !== 'module') { + continue; + } + + const key = link.meter?.key; + + if (!key || declaredKeys.has(key)) { + continue; + } + + await this.addonMetersRepository.deleteByAddonAndMeter(addon.id, link.meterId); + this.billingNotificationPublisher.publish('addon.meter_detached', { + addonId: addon.id, + meterId: link.meterId, + }); + } + + return await this.listAddonMeters(addon.id); + } + + async syncServiceTypeProviderMeters(serviceType: ServiceTypeEntity): Promise { + const provider = this.providerRegistry.getProvider(serviceType.provider); + const declared = provider?.meters ?? []; + const declaredKeys = new Set(declared.map((item) => item.key.trim())); + const existing = await this.serviceTypeMetersRepository.findByServiceTypeId(serviceType.id); + + for (const def of declared) { + const meter = await this.ensureCatalogMeter(def); + const link = existing.find((row) => row.meterId === meter.id); + + if (!link) { + await this.serviceTypeMetersRepository.create({ + serviceTypeId: serviceType.id, + meterId: meter.id, + unitPriceNet: null, + source: 'provider', + required: true, + }); + this.billingNotificationPublisher.publish('service_type.meter_attached', { + serviceTypeId: serviceType.id, + meterId: meter.id, + unitPriceNet: null, + source: 'provider', + required: true, + }); + } else if (link.source !== 'provider' || link.required !== true) { + await this.serviceTypeMetersRepository.update(link.id, { source: 'provider', required: true }); + } + } + + for (const link of existing) { + if (link.source !== 'provider') { + continue; + } + + const key = link.meter?.key; + + if (!key || declaredKeys.has(key)) { + continue; + } + + await this.serviceTypeMetersRepository.deleteByServiceTypeAndMeter(serviceType.id, link.meterId); + this.billingNotificationPublisher.publish('service_type.meter_detached', { + serviceTypeId: serviceType.id, + meterId: link.meterId, + }); + } + + return await this.listServiceTypeMeters(serviceType.id); + } + + async createMeter(dto: CreateMeterDto): Promise { + const key = dto.key.trim(); + const existing = await this.metersRepository.findByKey(key); + + if (existing) { + throw new BadRequestException('A meter with this key already exists'); + } + + const row = await this.metersRepository.create({ + key, + name: dto.name.trim(), + description: dto.description?.trim() || null, + unitLabel: dto.unitLabel?.trim() || null, + aggregator: dto.aggregator, + defaultUnitPriceNet: String(dto.defaultUnitPriceNet), + isActive: dto.isActive ?? true, + }); + + this.billingNotificationPublisher.publish('meter.created', { + meterId: row.id, + key: row.key, + name: row.name, + aggregator: row.aggregator, + }); + + return row; + } + + async updateMeter(id: string, dto: UpdateMeterDto): Promise { + const patch: Partial = {}; + + if (dto.name !== undefined) { + patch.name = dto.name.trim(); + } + + if (dto.description !== undefined) { + patch.description = dto.description?.trim() || null; + } + + if (dto.unitLabel !== undefined) { + patch.unitLabel = dto.unitLabel?.trim() || null; + } + + if (dto.aggregator !== undefined) { + patch.aggregator = dto.aggregator; + } + + if (dto.defaultUnitPriceNet !== undefined) { + patch.defaultUnitPriceNet = String(dto.defaultUnitPriceNet); + } + + if (dto.isActive !== undefined) { + patch.isActive = dto.isActive; + } + + const row = await this.metersRepository.update(id, patch); + + this.billingNotificationPublisher.publish('meter.updated', { + meterId: row.id, + key: row.key, + name: row.name, + aggregator: row.aggregator, + isActive: row.isActive, + }); + + return row; + } + + async deleteMeter(id: string): Promise { + const row = await this.metersRepository.findByIdOrThrow(id); + const planLinks = await this.servicePlanMetersRepository.countByMeterId(id); + const addonLinks = await this.addonMetersRepository.countByMeterId(id); + const typeLinks = await this.serviceTypeMetersRepository.countByMeterId(id); + const usageCount = await this.usageRecordsRepository.countByMeterId(id); + + if (planLinks > 0 || addonLinks > 0 || typeLinks > 0 || usageCount > 0) { + throw new BadRequestException( + 'Meter cannot be deleted while attached to plans/addons/service types or referenced by usage records; deactivate it instead', + ); + } + + await this.metersRepository.delete(id); + + this.billingNotificationPublisher.publish('meter.deleted', { + meterId: row.id, + key: row.key, + }); + } + + async listPlanMeters(servicePlanId: string): Promise { + const links = await this.servicePlanMetersRepository.findByPlanId(servicePlanId); + + return links.filter((link) => link.meter).map((link) => this.mapAttachedMeter(link, link.meter!)); + } + + async listEffectivePlanMeters( + servicePlanId: string, + serviceTypeId?: string | null, + ): Promise { + const planMeters = await this.listPlanMeters(servicePlanId); + const byMeterId = new Map(planMeters.map((item) => [item.meterId, item])); + + if (serviceTypeId) { + const typeMeters = await this.listServiceTypeMeters(serviceTypeId); + + for (const meter of typeMeters) { + if (byMeterId.has(meter.meterId)) { + continue; + } + + byMeterId.set(meter.meterId, { ...meter, inherited: true }); + } + } + + return Array.from(byMeterId.values()); + } + + async attachPlanMeter( + servicePlanId: string, + meterId: string, + unitPriceNet?: number | null, + ): Promise { + const meter = await this.metersRepository.findByIdOrThrow(meterId); + + if (!meter.isActive) { + throw new BadRequestException('Cannot attach an inactive meter'); + } + + const existing = await this.servicePlanMetersRepository.findByPlanAndMeter(servicePlanId, meterId); + + if (existing) { + throw new BadRequestException('Meter is already attached to this plan'); + } + + const link = await this.servicePlanMetersRepository.create({ + servicePlanId, + meterId, + unitPriceNet: unitPriceNet === undefined ? null : unitPriceNet === null ? null : String(unitPriceNet), + source: 'manual', + required: false, + }); + + this.billingNotificationPublisher.publish('service_plan.meter_attached', { + servicePlanId, + meterId, + unitPriceNet: unitPriceNet ?? null, + }); + + return this.mapAttachedMeter(link, meter); + } + + async updatePlanMeter( + servicePlanId: string, + meterId: string, + unitPriceNet?: number | null, + ): Promise { + const link = await this.servicePlanMetersRepository.findByPlanAndMeterOrThrow(servicePlanId, meterId); + const meter = link.meter ?? (await this.metersRepository.findByIdOrThrow(meterId)); + const updated = await this.servicePlanMetersRepository.update(link.id, { + unitPriceNet: + unitPriceNet === undefined ? link.unitPriceNet : unitPriceNet === null ? null : String(unitPriceNet), + }); + + this.billingNotificationPublisher.publish('service_plan.meter_updated', { + servicePlanId, + meterId, + unitPriceNet: unitPriceNet ?? null, + }); + + return this.mapAttachedMeter(updated, meter); + } + + async detachPlanMeter(servicePlanId: string, meterId: string): Promise { + const link = await this.servicePlanMetersRepository.findByPlanAndMeterOrThrow(servicePlanId, meterId); + + if (link.required === true) { + throw new BadRequestException('Required meters cannot be detached'); + } + + await this.servicePlanMetersRepository.deleteByPlanAndMeter(servicePlanId, meterId); + + this.billingNotificationPublisher.publish('service_plan.meter_detached', { + servicePlanId, + meterId, + }); + } + + async listAddonMeters(addonId: string): Promise { + const links = await this.addonMetersRepository.findByAddonId(addonId); + + return links.filter((link) => link.meter).map((link) => this.mapAttachedMeter(link, link.meter!)); + } + + async attachAddonMeter( + addonId: string, + meterId: string, + unitPriceNet?: number | null, + ): Promise { + const meter = await this.metersRepository.findByIdOrThrow(meterId); + + if (!meter.isActive) { + throw new BadRequestException('Cannot attach an inactive meter'); + } + + const existing = await this.addonMetersRepository.findByAddonAndMeter(addonId, meterId); + + if (existing) { + throw new BadRequestException('Meter is already attached to this addon'); + } + + const link = await this.addonMetersRepository.create({ + addonId, + meterId, + unitPriceNet: unitPriceNet === undefined ? null : unitPriceNet === null ? null : String(unitPriceNet), + source: 'manual', + required: false, + }); + + this.billingNotificationPublisher.publish('addon.meter_attached', { + addonId, + meterId, + unitPriceNet: unitPriceNet ?? null, + }); + + return this.mapAttachedMeter(link, meter); + } + + async updateAddonMeter( + addonId: string, + meterId: string, + unitPriceNet?: number | null, + ): Promise { + const link = await this.addonMetersRepository.findByAddonAndMeterOrThrow(addonId, meterId); + const meter = link.meter ?? (await this.metersRepository.findByIdOrThrow(meterId)); + const updated = await this.addonMetersRepository.update(link.id, { + unitPriceNet: + unitPriceNet === undefined ? link.unitPriceNet : unitPriceNet === null ? null : String(unitPriceNet), + }); + + this.billingNotificationPublisher.publish('addon.meter_updated', { + addonId, + meterId, + unitPriceNet: unitPriceNet ?? null, + }); + + return this.mapAttachedMeter(updated, meter); + } + + async detachAddonMeter(addonId: string, meterId: string): Promise { + const link = await this.addonMetersRepository.findByAddonAndMeterOrThrow(addonId, meterId); + + if (link.required === true) { + throw new BadRequestException('Required meters cannot be detached'); + } + + await this.addonMetersRepository.deleteByAddonAndMeter(addonId, meterId); + + this.billingNotificationPublisher.publish('addon.meter_detached', { + addonId, + meterId, + }); + } + + async listServiceTypeMeters(serviceTypeId: string): Promise { + const links = await this.serviceTypeMetersRepository.findByServiceTypeId(serviceTypeId); + + return links.filter((link) => link.meter).map((link) => this.mapAttachedMeter(link, link.meter!)); + } + + async attachServiceTypeMeter( + serviceTypeId: string, + meterId: string, + unitPriceNet?: number | null, + ): Promise { + const meter = await this.metersRepository.findByIdOrThrow(meterId); + + if (!meter.isActive) { + throw new BadRequestException('Cannot attach an inactive meter'); + } + + const existing = await this.serviceTypeMetersRepository.findByServiceTypeAndMeter(serviceTypeId, meterId); + + if (existing) { + throw new BadRequestException('Meter is already attached to this service type'); + } + + const link = await this.serviceTypeMetersRepository.create({ + serviceTypeId, + meterId, + unitPriceNet: unitPriceNet === undefined ? null : unitPriceNet === null ? null : String(unitPriceNet), + source: 'manual' satisfies MeterAttachmentSource, + required: false, + }); + + this.billingNotificationPublisher.publish('service_type.meter_attached', { + serviceTypeId, + meterId, + unitPriceNet: unitPriceNet ?? null, + }); + + return this.mapAttachedMeter(link, meter); + } + + async updateServiceTypeMeter( + serviceTypeId: string, + meterId: string, + unitPriceNet?: number | null, + ): Promise { + const link = await this.serviceTypeMetersRepository.findByServiceTypeAndMeterOrThrow(serviceTypeId, meterId); + const meter = link.meter ?? (await this.metersRepository.findByIdOrThrow(meterId)); + const updated = await this.serviceTypeMetersRepository.update(link.id, { + unitPriceNet: + unitPriceNet === undefined ? link.unitPriceNet : unitPriceNet === null ? null : String(unitPriceNet), + }); + + this.billingNotificationPublisher.publish('service_type.meter_updated', { + serviceTypeId, + meterId, + unitPriceNet: unitPriceNet ?? null, + }); + + return this.mapAttachedMeter(updated, meter); + } + + async detachServiceTypeMeter(serviceTypeId: string, meterId: string): Promise { + const link = await this.serviceTypeMetersRepository.findByServiceTypeAndMeterOrThrow(serviceTypeId, meterId); + + if (link.required === true) { + throw new BadRequestException('Required meters cannot be detached'); + } + + await this.serviceTypeMetersRepository.deleteByServiceTypeAndMeter(serviceTypeId, meterId); + + this.billingNotificationPublisher.publish('service_type.meter_detached', { + serviceTypeId, + meterId, + }); + } + + async isPlanMeterAttached( + planId: string, + serviceTypeId: string | null | undefined, + meterId: string, + ): Promise { + const planLink = await this.servicePlanMetersRepository.findByPlanAndMeter(planId, meterId); + + if (planLink) { + return true; + } + + if (!serviceTypeId) { + return false; + } + + const typeLink = await this.serviceTypeMetersRepository.findByServiceTypeAndMeter(serviceTypeId, meterId); + + return typeLink != null; + } +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/provider-module-registry.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/provider-module-registry.service.spec.ts new file mode 100644 index 000000000..b3d255ffb --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/provider-module-registry.service.spec.ts @@ -0,0 +1,19 @@ +import { ProviderModuleRegistryService } from './provider-module-registry.service'; + +describe('ProviderModuleRegistryService', () => { + it('registers, gets, and lists modules', () => { + const registry = new ProviderModuleRegistryService(); + const module = { + id: 'acme', + async collectMeters() { + return [{ meterKey: 'cpu', value: 1 }]; + }, + }; + + registry.register(module); + + expect(registry.has('acme')).toBe(true); + expect(registry.get('acme')).toBe(module); + expect(registry.list()).toEqual([module]); + }); +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/provider-module-registry.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/provider-module-registry.service.ts new file mode 100644 index 000000000..313da95a2 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/provider-module-registry.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; + +import type { DeclaredMeterDefinition } from '../dto/declared-meter.dto'; +import type { MeterCollectContext, MeterCollectSample } from '../dto/meter-collect.types'; + +/** + * Runtime provider module: collectMeters + optional meter declarations. + * Distinct from ProviderDetailDto metadata (DYNAMIC_BILLING_PROVIDER_METADATA). + */ +export interface BillingProviderModule { + /** Matches provider metadata id (hetzner, digital-ocean, …). */ + readonly id: string; + /** + * Optional declared meters for interval resolution / sync helpers. + * Catalog sync still prefers metadata meters when present on ProviderDetailDto. + */ + readonly meters?: DeclaredMeterDefinition[]; + collectMeters(ctx: MeterCollectContext): Promise; +} + +/** + * Registry of runtime billing provider modules (built-in + DYNAMIC_BILLING_PROVIDER_MODULES). + */ +@Injectable() +export class ProviderModuleRegistryService { + private readonly modules = new Map(); + + register(module: BillingProviderModule): void { + this.modules.set(module.id, module); + } + + get(id: string): BillingProviderModule | undefined { + return this.modules.get(id); + } + + has(id: string): boolean { + return this.modules.has(id); + } + + list(): BillingProviderModule[] { + return Array.from(this.modules.values()); + } +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-charge-period.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-charge-period.service.spec.ts index 6ef88c44d..271e09602 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-charge-period.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-charge-period.service.spec.ts @@ -50,11 +50,70 @@ describe('SubscriptionChargePeriodService', () => { expect.objectContaining({ periodStart: createdAt, periodEnd: billUntil, + meterPeriodStart: createdAt, }), ); expect(result?.baseAmount).toBeGreaterThan(0); }); + it('uses schedule-aligned meterPeriodStart when prior invoice lags the period boundary', async () => { + const lastInvoiceAt = new Date('2026-02-01T00:05:00Z'); + const billUntil = new Date('2026-03-01T00:00:00Z'); + const service = new SubscriptionChargePeriodService( + { findLatestBySubscription: jest.fn().mockResolvedValue({ createdAt: lastInvoiceAt }) } as never, + { findBySubscription: jest.fn().mockResolvedValue([]) } as never, + billingScheduleService, + ); + const subscription = { + id: 'sub-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + currentPeriodStart: billUntil, + currentPeriodEnd: new Date('2026-04-01T00:00:00Z'), + } as never; + const plan = { + billingIntervalType: 'month', + billInAdvance: false, + autoRecalculatePriceDaily: false, + billingIntervalValue: 1, + billingDayOfMonth: 1, + } as never; + + const result = await service.resolveChargePeriod(subscription, plan, 30, billUntil); + + expect(result?.periodStart).toEqual(lastInvoiceAt); + expect(result?.periodEnd).toEqual(billUntil); + expect(result?.meterPeriodStart).toEqual(new Date('2026-02-01T00:00:00Z')); + }); + + it('uses currentPeriodStart for meters when createInvoice lags after schedule start', async () => { + const lastInvoiceAt = new Date('2026-02-01T00:05:00Z'); + const currentPeriodStart = new Date('2026-02-01T00:00:00Z'); + const billUntil = new Date('2026-02-15T00:00:00Z'); + const service = new SubscriptionChargePeriodService( + { findLatestBySubscription: jest.fn().mockResolvedValue({ createdAt: lastInvoiceAt }) } as never, + { findBySubscription: jest.fn().mockResolvedValue([]) } as never, + billingScheduleService, + ); + const subscription = { + id: 'sub-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + currentPeriodStart, + currentPeriodEnd: new Date('2026-03-01T00:00:00Z'), + } as never; + const plan = { + billingIntervalType: 'month', + billInAdvance: false, + autoRecalculatePriceDaily: false, + billingIntervalValue: 1, + billingDayOfMonth: 1, + } as never; + + const result = await service.resolveChargePeriod(subscription, plan, 30, billUntil); + + expect(result?.periodStart).toEqual(lastInvoiceAt); + expect(result?.meterPeriodStart).toEqual(currentPeriodStart); + }); + it('returns full period when no prior invoice exists', async () => { const service = new SubscriptionChargePeriodService( { findLatestBySubscription: jest.fn().mockResolvedValue(null) } as never, @@ -76,6 +135,7 @@ describe('SubscriptionChargePeriodService', () => { expect.objectContaining({ periodStart, periodEnd: billUntil, + meterPeriodStart: periodStart, }), ); expect(result?.baseAmount).toBeGreaterThan(0); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-charge-period.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-charge-period.service.ts index c9600c92f..c1bc510b4 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-charge-period.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-charge-period.service.ts @@ -8,11 +8,18 @@ import { calculateProratedAmount } from '../utils/billing-proration.util'; import { getEarliestProvisionedAt } from '../utils/provisioned-billing.util'; import { BillingScheduleService } from './billing-schedule.service'; +import { subtractBillingInterval } from './invoicing-period.util'; export interface SubscriptionChargePeriod { baseAmount: number; + /** Floor used for subscription base proration (often last invoice createdAt). */ periodStart: Date; periodEnd: Date; + /** + * Floor for meter aggregation. Schedule-aligned when the arrear tick already rolled + * the subscription forward, so lagged invoice.createdAt does not drop collector samples. + */ + meterPeriodStart: Date; } @Injectable() @@ -60,10 +67,20 @@ export class SubscriptionChargePeriodService { } if (!lastBillingAt) { + const meterPeriodStart = this.resolveMeterPeriodStart({ + subscription, + plan, + billUntil: effectiveUntil, + rolledForward, + fallbackStart: periodFloor, + hasPriorInvoice: false, + }); + return { baseAmount: fullPeriodPrice, periodStart: periodFloor, periodEnd: effectiveUntil, + meterPeriodStart, }; } @@ -95,11 +112,52 @@ export class SubscriptionChargePeriodService { effectiveUntil, this.billingScheduleService, ); + const meterPeriodStart = this.resolveMeterPeriodStart({ + subscription, + plan, + billUntil: effectiveUntil, + rolledForward, + fallbackStart: lastBillingAt, + hasPriorInvoice: latestInvoice != null, + }); return { baseAmount, periodStart: lastBillingAt, periodEnd: effectiveUntil, + meterPeriodStart, }; } + + private resolveMeterPeriodStart(params: { + subscription: SubscriptionEntity; + plan: ServicePlanEntity; + billUntil: Date; + rolledForward: boolean; + fallbackStart: Date; + hasPriorInvoice: boolean; + }): Date { + const createdAt = params.subscription.createdAt ?? params.fallbackStart; + + if (params.rolledForward && params.hasPriorInvoice) { + const alignedStart = subtractBillingInterval(params.billUntil, params.plan); + + return alignedStart.getTime() > createdAt.getTime() ? alignedStart : createdAt; + } + + // Direct createInvoice / in-period charges: do not let a lagged invoice.createdAt + // push the meter floor past the open schedule period start. + const scheduleStart = params.subscription.currentPeriodStart; + + if ( + params.hasPriorInvoice && + scheduleStart && + scheduleStart.getTime() < params.billUntil.getTime() && + scheduleStart.getTime() < params.fallbackStart.getTime() + ) { + return scheduleStart.getTime() > createdAt.getTime() ? scheduleStart : createdAt; + } + + return params.fallbackStart; + } } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-period-charge.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-period-charge.service.spec.ts index dcde1ad0c..8c38a4946 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-period-charge.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-period-charge.service.spec.ts @@ -15,11 +15,15 @@ describe('SubscriptionPeriodChargeService', () => { publishPeriodCharged: jest.fn(), publishSubscription: jest.fn(), }; + const meterBillingService = { + buildMeterChargeLines: jest.fn().mockResolvedValue([]), + }; const service = new SubscriptionPeriodChargeService( openPositionsRepository as never, subscriptionsRepository as never, billingScheduleService, billingNotificationPublisher as never, + meterBillingService as never, ); beforeEach(() => { @@ -73,7 +77,12 @@ describe('SubscriptionPeriodChargeService', () => { await service.processDueBilling(subscription, plan); expect(openPositionsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ billUntil: nextBillingAt })); - expect(subscriptionsRepository.update).toHaveBeenCalled(); + expect(subscriptionsRepository.update).toHaveBeenCalledWith( + 'sub-1', + expect.objectContaining({ + currentPeriodStart: nextBillingAt, + }), + ); expect(billingNotificationPublisher.publishSubscription).toHaveBeenCalledWith( 'subscription.updated', expect.anything(), @@ -81,6 +90,43 @@ describe('SubscriptionPeriodChargeService', () => { ); }); + it('arrear late billing advances from nextBillingAt not wall-clock now', async () => { + const nextBillingAt = new Date('2026-02-01T00:00:00Z'); + const subscription = { + id: 'sub-1', + userId: 'user-1', + number: '100', + planId: 'plan-1', + nextBillingAt, + currentPeriodStart: new Date('2026-01-01T00:00:00Z'), + createdAt: new Date('2026-01-01T00:00:00Z'), + } as never; + const plan = { + billingIntervalType: BillingIntervalType.HOUR, + billingIntervalValue: 1, + billingDayOfMonth: undefined, + billInAdvance: false, + autoRecalculatePriceDaily: false, + } as never; + + subscriptionsRepository.update.mockResolvedValue({ id: 'sub-1' }); + const calculateScheduleSpy = jest.spyOn(billingScheduleService, 'calculateSchedule'); + + await service.processDueBilling(subscription, plan); + + expect(calculateScheduleSpy).toHaveBeenCalledWith(BillingIntervalType.HOUR, 1, undefined, nextBillingAt); + expect(subscriptionsRepository.update).toHaveBeenCalledWith( + 'sub-1', + expect.objectContaining({ + currentPeriodStart: nextBillingAt, + currentPeriodEnd: new Date('2026-02-01T01:00:00Z'), + nextBillingAt: new Date('2026-02-01T01:00:00Z'), + }), + ); + + calculateScheduleSpy.mockRestore(); + }); + it('advance due billing uses new period end as billUntil', async () => { const subscription = { id: 'sub-1', diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-period-charge.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-period-charge.service.ts index b6e0a8459..81b541c02 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-period-charge.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-period-charge.service.ts @@ -7,6 +7,7 @@ import { OpenPositionsRepository } from '../repositories/open-positions.reposito import { SubscriptionsRepository } from '../repositories/subscriptions.repository'; import { BillingScheduleService } from './billing-schedule.service'; +import { MeterBillingService } from './meter-billing.service'; @Injectable() export class SubscriptionPeriodChargeService { @@ -17,6 +18,7 @@ export class SubscriptionPeriodChargeService { private readonly subscriptionsRepository: SubscriptionsRepository, private readonly billingScheduleService: BillingScheduleService, private readonly billingNotificationPublisher: BillingNotificationPublisher, + private readonly meterBillingService: MeterBillingService, ) {} /** @@ -38,7 +40,15 @@ export class SubscriptionPeriodChargeService { skipIfNoBillableAmount: true, }); - this.billingNotificationPublisher.publishPeriodCharged(subscription, plan, billUntil, periodStart, periodEnd); + const meterCharges = await this.resolveMeterCharges(subscription, plan, periodStart, periodEnd); + this.billingNotificationPublisher.publishPeriodCharged( + subscription, + plan, + billUntil, + periodStart, + periodEnd, + meterCharges, + ); this.logger.log(`Recorded open position for subscription ${subscription.id} until ${billUntil.toISOString()}`); } @@ -50,11 +60,13 @@ export class SubscriptionPeriodChargeService { async processDueBilling(subscription: SubscriptionEntity, plan: ServicePlanEntity): Promise { const now = new Date(); const billInAdvance = plan.billInAdvance === true; + // Arrear: advance from the closed period end so late ticks do not open a gap that drops meter samples. + const scheduleAnchor = billInAdvance ? now : (subscription.nextBillingAt ?? now); const schedule = this.billingScheduleService.calculateSchedule( plan.billingIntervalType as BillingIntervalType, plan.billingIntervalValue, plan.billingDayOfMonth, - now, + scheduleAnchor, ); const billUntil = billInAdvance ? schedule.currentPeriodEnd : (subscription.nextBillingAt ?? now); @@ -71,7 +83,15 @@ export class SubscriptionPeriodChargeService { skipIfNoBillableAmount: true, }); - this.billingNotificationPublisher.publishPeriodCharged(subscription, plan, billUntil, periodStart, periodEnd); + const meterCharges = await this.resolveMeterCharges(subscription, plan, periodStart, periodEnd); + this.billingNotificationPublisher.publishPeriodCharged( + subscription, + plan, + billUntil, + periodStart, + periodEnd, + meterCharges, + ); const updated = await this.subscriptionsRepository.update(subscription.id, { currentPeriodStart: schedule.currentPeriodStart, @@ -84,4 +104,32 @@ export class SubscriptionPeriodChargeService { return updated; } + + private async resolveMeterCharges( + subscription: SubscriptionEntity, + plan: Pick & Partial, + periodStart: Date, + periodEnd: Date, + ): Promise>> { + if (plan.billInAdvance === true) { + return []; + } + + const lines = await this.meterBillingService.buildMeterChargeLines({ + subscription, + plan: plan as ServicePlanEntity, + periodStart, + periodEnd, + }); + + return lines.map((line) => ({ + meterId: line.meterId, + attachmentType: line.attachmentType, + addonId: line.addonId ?? null, + description: line.description, + aggregatedValue: line.aggregatedValue, + effectiveUnitPriceNet: line.effectiveUnitPriceNet, + unitPriceNet: line.unitPriceNet, + })); + } } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.spec.ts index 13e18e962..8fcd41420 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.spec.ts @@ -199,6 +199,7 @@ describe('SubscriptionService', () => { customerTrustScoreService as never, subscriptionPeriodChargeService as never, sshExecutor as never, + { buildSubscriptionMeterSummaries: jest.fn().mockResolvedValue([]) } as never, ); beforeEach(() => { diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.ts index 844356655..eb69d95f7 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.ts @@ -63,6 +63,7 @@ import { BillingEmailPublisher } from '../email/billing-email.publisher'; import { CustomerTrustScoreService } from '../trust-score/customer-trust-score.service'; import { SubscriptionPeriodChargeService } from './subscription-period-charge.service'; import { convertAddonPriceToPlanPeriod } from '../utils/addon-pricing.util'; +import { MeterBillingService } from './meter-billing.service'; const PROVISIONING_SSH_USER = 'root'; const PROVISIONING_SSH_PORT = 22; @@ -105,6 +106,7 @@ export class SubscriptionService { private readonly customerTrustScoreService: CustomerTrustScoreService, private readonly subscriptionPeriodChargeService: SubscriptionPeriodChargeService, private readonly sshExecutor: SshExecutorService, + private readonly meterBillingService: MeterBillingService, ) {} async createSubscription( @@ -958,6 +960,11 @@ export class SubscriptionService { withdrawalEligibility: eligibility, withdrawalResult, periodTotalPrice, + meters: await this.meterBillingService.buildSubscriptionMeterSummaries({ + subscription, + periodStart: subscription.currentPeriodStart, + periodEnd: subscription.currentPeriodEnd, + }), createdAt: subscription.createdAt, updatedAt: subscription.updatedAt, }; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/usage.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/usage.service.spec.ts index 09df10aa9..bdc23a416 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/usage.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/usage.service.spec.ts @@ -6,6 +6,10 @@ describe('UsageService', () => { const usageRecordsRepository = { findLatestForSubscription: jest.fn(), create: jest.fn(), + findMeteredForSubscription: jest.fn(), + findByIdForSubscriptionOrThrow: jest.fn(), + update: jest.fn(), + delete: jest.fn(), }; const subscriptionsRepository = { findByIdOrThrow: jest.fn(), @@ -13,14 +17,39 @@ describe('UsageService', () => { const servicePlansRepository = { findByIdOrThrow: jest.fn(), }; + const servicePlanMetersRepository = { + findByPlanAndMeter: jest.fn(), + }; + const serviceTypeMetersRepository = { + findByServiceTypeAndMeter: jest.fn(), + }; + const addonMetersRepository = { + findByAddonAndMeter: jest.fn(), + }; + const subscriptionAddonsRepository = { + findBillableBySubscriptionId: jest.fn().mockResolvedValue([]), + }; + const meterBillingService = { + hasAnyMeterAttachments: jest.fn().mockResolvedValue(false), + }; + const billingNotificationPublisher = { + publish: jest.fn(), + }; const service = new UsageService( usageRecordsRepository as never, subscriptionsRepository as never, servicePlansRepository as never, + servicePlanMetersRepository as never, + serviceTypeMetersRepository as never, + addonMetersRepository as never, + subscriptionAddonsRepository as never, + meterBillingService as never, + billingNotificationPublisher as never, ); beforeEach(() => { jest.clearAllMocks(); + meterBillingService.hasAnyMeterAttachments.mockResolvedValue(false); }); it('rejects usage records for advance-billed subscriptions', async () => { @@ -40,10 +69,17 @@ describe('UsageService', () => { expect(usageRecordsRepository.create).not.toHaveBeenCalled(); }); - it('creates usage records for arrear-billed subscriptions', async () => { + it('creates legacy usage records for arrear-billed subscriptions without meters', async () => { subscriptionsRepository.findByIdOrThrow.mockResolvedValue({ id: 'sub-1', planId: 'plan-1' }); servicePlansRepository.findByIdOrThrow.mockResolvedValue({ id: 'plan-1', billInAdvance: false }); - usageRecordsRepository.create.mockResolvedValue({ id: 'usage-1' }); + usageRecordsRepository.create.mockResolvedValue({ + id: 'usage-1', + subscriptionId: 'sub-1', + meterId: null, + attachmentType: null, + addonId: null, + value: null, + }); const dto = { subscriptionId: 'sub-1', @@ -53,7 +89,90 @@ describe('UsageService', () => { usagePayload: { totalCost: 1 }, }; - await expect(service.createUsage(dto)).resolves.toEqual({ id: 'usage-1' }); - expect(usageRecordsRepository.create).toHaveBeenCalledWith(dto); + await expect(service.createUsage(dto)).resolves.toEqual(expect.objectContaining({ id: 'usage-1' })); + expect(usageRecordsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + subscriptionId: 'sub-1', + usagePayload: { totalCost: 1 }, + meterId: null, + }), + ); + expect(billingNotificationPublisher.publish).toHaveBeenCalledWith( + 'usage.recorded', + expect.objectContaining({ usageRecordId: 'usage-1' }), + ); + }); + + it('requires meterId and value when subscription has meter attachments', async () => { + subscriptionsRepository.findByIdOrThrow.mockResolvedValue({ id: 'sub-1', planId: 'plan-1' }); + servicePlansRepository.findByIdOrThrow.mockResolvedValue({ id: 'plan-1', billInAdvance: false }); + meterBillingService.hasAnyMeterAttachments.mockResolvedValue(true); + + await expect( + service.createUsage({ + subscriptionId: 'sub-1', + periodStart: new Date(), + periodEnd: new Date(), + usageSource: 'admin', + usagePayload: {}, + }), + ).rejects.toThrow(BadRequestException); + }); + + it('creates plan-scoped metered usage when meter is attached to plan', async () => { + subscriptionsRepository.findByIdOrThrow.mockResolvedValue({ id: 'sub-1', planId: 'plan-1' }); + servicePlansRepository.findByIdOrThrow.mockResolvedValue({ id: 'plan-1', billInAdvance: false }); + meterBillingService.hasAnyMeterAttachments.mockResolvedValue(true); + servicePlanMetersRepository.findByPlanAndMeter.mockResolvedValue({ id: 'link-1' }); + usageRecordsRepository.create.mockResolvedValue({ + id: 'usage-2', + subscriptionId: 'sub-1', + meterId: 'meter-1', + attachmentType: 'plan', + addonId: null, + value: '12.5', + }); + + await expect( + service.createUsage({ + subscriptionId: 'sub-1', + periodStart: new Date('2026-01-01'), + periodEnd: new Date('2026-02-01'), + usageSource: 'admin', + meterId: 'meter-1', + value: 12.5, + attachmentType: 'plan', + }), + ).resolves.toEqual(expect.objectContaining({ id: 'usage-2' })); + + expect(usageRecordsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + meterId: 'meter-1', + value: '12.5', + attachmentType: 'plan', + addonId: null, + }), + ); + }); + + it('rejects negative meter values on create', async () => { + subscriptionsRepository.findByIdOrThrow.mockResolvedValue({ id: 'sub-1', planId: 'plan-1' }); + servicePlansRepository.findByIdOrThrow.mockResolvedValue({ id: 'plan-1', billInAdvance: false }); + meterBillingService.hasAnyMeterAttachments.mockResolvedValue(true); + servicePlanMetersRepository.findByPlanAndMeter.mockResolvedValue({ id: 'link-1' }); + + await expect( + service.createUsage({ + subscriptionId: 'sub-1', + periodStart: new Date('2026-01-01'), + periodEnd: new Date('2026-02-01'), + usageSource: 'admin', + meterId: 'meter-1', + value: -1, + attachmentType: 'plan', + }), + ).rejects.toThrow(BadRequestException); + + expect(usageRecordsRepository.create).not.toHaveBeenCalled(); }); }); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/usage.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/usage.service.ts index 3aeb9e9ee..e0b86fa6e 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/usage.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/usage.service.ts @@ -1,8 +1,18 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import type { UpdateUsageMeterEntryDto } from '../dto/create-usage-record.dto'; +import type { UsageMeterEntryResponseDto } from '../dto/meter-response.dto'; +import type { UsageAttachmentType, UsageRecordEntity } from '../entities/usage-record.entity'; +import { AddonMetersRepository } from '../repositories/addon-meters.repository'; +import { ServicePlanMetersRepository } from '../repositories/service-plan-meters.repository'; import { ServicePlansRepository } from '../repositories/service-plans.repository'; +import { ServiceTypeMetersRepository } from '../repositories/service-type-meters.repository'; +import { SubscriptionAddonsRepository } from '../repositories/subscription-addons.repository'; import { SubscriptionsRepository } from '../repositories/subscriptions.repository'; import { UsageRecordsRepository } from '../repositories/usage-records.repository'; +import { BillingNotificationPublisher } from '../notifications/billing-notification.publisher'; + +import { MeterBillingService } from './meter-billing.service'; @Injectable() export class UsageService { @@ -10,18 +20,34 @@ export class UsageService { private readonly usageRecordsRepository: UsageRecordsRepository, private readonly subscriptionsRepository: SubscriptionsRepository, private readonly servicePlansRepository: ServicePlansRepository, + private readonly servicePlanMetersRepository: ServicePlanMetersRepository, + private readonly serviceTypeMetersRepository: ServiceTypeMetersRepository, + private readonly addonMetersRepository: AddonMetersRepository, + private readonly subscriptionAddonsRepository: SubscriptionAddonsRepository, + private readonly meterBillingService: MeterBillingService, + private readonly billingNotificationPublisher: BillingNotificationPublisher, ) {} async getLatestUsage(subscriptionId: string) { return await this.usageRecordsRepository.findLatestForSubscription(subscriptionId); } + async listMeterEntries(subscriptionId: string): Promise { + const rows = await this.usageRecordsRepository.findMeteredForSubscription(subscriptionId); + + return rows.map((row) => this.mapEntry(row)); + } + async createUsage(dto: { subscriptionId: string; periodStart: Date; periodEnd: Date; usageSource: string; - usagePayload: Record; + usagePayload?: Record; + meterId?: string; + value?: number; + attachmentType?: UsageAttachmentType; + addonId?: string; }) { const subscription = await this.subscriptionsRepository.findByIdOrThrow(dto.subscriptionId); const plan = await this.servicePlansRepository.findByIdOrThrow(subscription.planId); @@ -30,6 +56,187 @@ export class UsageService { throw new BadRequestException('Usage-based billing is not available for advance-billed (prepaid) subscriptions'); } - return await this.usageRecordsRepository.create(dto); + const hasMeterAttachments = await this.meterBillingService.hasAnyMeterAttachments(subscription, plan.serviceTypeId); + const meterId = dto.meterId; + + if (hasMeterAttachments) { + if (!meterId || dto.value === undefined || dto.value === null) { + throw new BadRequestException('meterId and value are required when the subscription has meter attachments'); + } + } + + let attachmentType: UsageAttachmentType | null = null; + let addonId: string | null = null; + + if (meterId) { + if (dto.value === undefined || dto.value === null || !Number.isFinite(dto.value)) { + throw new BadRequestException('value must be a finite number when meterId is set'); + } + + if (dto.value < 0) { + throw new BadRequestException('value must be greater than or equal to 0 when meterId is set'); + } + + attachmentType = dto.attachmentType ?? 'plan'; + addonId = attachmentType === 'addon' ? (dto.addonId ?? null) : null; + await this.assertAttachmentAllowed( + subscription.id, + subscription.planId, + plan.serviceTypeId, + meterId, + attachmentType, + addonId, + ); + } + + const record = await this.usageRecordsRepository.create({ + subscriptionId: dto.subscriptionId, + periodStart: dto.periodStart, + periodEnd: dto.periodEnd, + usageSource: dto.usageSource, + usagePayload: dto.usagePayload ?? {}, + meterId: meterId ?? null, + value: meterId != null ? String(dto.value) : null, + attachmentType, + addonId, + }); + + this.billingNotificationPublisher.publish('usage.recorded', { + usageRecordId: record.id, + subscriptionId: record.subscriptionId, + meterId: record.meterId ?? null, + attachmentType: record.attachmentType ?? null, + addonId: record.addonId ?? null, + value: record.value ?? null, + }); + + return record; + } + + async updateMeterEntry( + subscriptionId: string, + entryId: string, + dto: UpdateUsageMeterEntryDto, + ): Promise { + const subscription = await this.subscriptionsRepository.findByIdOrThrow(subscriptionId); + const plan = await this.servicePlansRepository.findByIdOrThrow(subscription.planId); + + if (plan.billInAdvance === true) { + throw new BadRequestException('Usage-based billing is not available for advance-billed (prepaid) subscriptions'); + } + + const existing = await this.usageRecordsRepository.findByIdForSubscriptionOrThrow(subscriptionId, entryId); + + if (!existing.meterId) { + throw new BadRequestException('Only metered usage entries can be updated via this endpoint'); + } + + const meterId = dto.meterId ?? existing.meterId; + const attachmentType = dto.attachmentType ?? existing.attachmentType ?? 'plan'; + const addonId = attachmentType === 'addon' ? (dto.addonId !== undefined ? dto.addonId : existing.addonId) : null; + + await this.assertAttachmentAllowed( + subscriptionId, + subscription.planId, + plan.serviceTypeId, + meterId, + attachmentType, + addonId, + ); + + const updated = await this.usageRecordsRepository.update(existing, { + periodStart: dto.periodStart ? new Date(dto.periodStart) : existing.periodStart, + periodEnd: dto.periodEnd ? new Date(dto.periodEnd) : existing.periodEnd, + value: dto.value !== undefined ? String(dto.value) : existing.value, + usagePayload: dto.usagePayload ?? existing.usagePayload, + meterId, + attachmentType, + addonId, + }); + + this.billingNotificationPublisher.publish('usage.updated', { + usageRecordId: updated.id, + subscriptionId: updated.subscriptionId, + meterId: updated.meterId ?? null, + attachmentType: updated.attachmentType ?? null, + addonId: updated.addonId ?? null, + value: updated.value ?? null, + }); + + return this.mapEntry(updated); + } + + async deleteMeterEntry(subscriptionId: string, entryId: string): Promise { + const existing = await this.usageRecordsRepository.findByIdForSubscriptionOrThrow(subscriptionId, entryId); + + await this.usageRecordsRepository.delete(existing); + + this.billingNotificationPublisher.publish('usage.deleted', { + usageRecordId: existing.id, + subscriptionId: existing.subscriptionId, + meterId: existing.meterId ?? null, + attachmentType: existing.attachmentType ?? null, + addonId: existing.addonId ?? null, + }); + } + + private async assertAttachmentAllowed( + subscriptionId: string, + planId: string, + serviceTypeId: string | null | undefined, + meterId: string, + attachmentType: UsageAttachmentType, + addonId: string | null | undefined, + ): Promise { + if (attachmentType === 'plan') { + const planLink = await this.servicePlanMetersRepository.findByPlanAndMeter(planId, meterId); + + if (planLink) { + return; + } + + if (serviceTypeId) { + const typeLink = await this.serviceTypeMetersRepository.findByServiceTypeAndMeter(serviceTypeId, meterId); + + if (typeLink) { + return; + } + } + + throw new BadRequestException('Meter is not attached to the subscription plan'); + } + + if (!addonId) { + throw new BadRequestException('addonId is required when attachmentType is addon'); + } + + const billableAddons = await this.subscriptionAddonsRepository.findBillableBySubscriptionId(subscriptionId); + const hasAddon = billableAddons.some((row) => row.addonId === addonId); + + if (!hasAddon) { + throw new BadRequestException('Addon is not an active billable addon on this subscription'); + } + + const link = await this.addonMetersRepository.findByAddonAndMeter(addonId, meterId); + + if (!link) { + throw new BadRequestException('Meter is not attached to the specified addon'); + } + } + + private mapEntry(row: UsageRecordEntity): UsageMeterEntryResponseDto { + return { + id: row.id, + subscriptionId: row.subscriptionId, + meterId: row.meterId!, + value: Number(row.value ?? 0), + attachmentType: row.attachmentType ?? 'plan', + addonId: row.addonId ?? null, + periodStart: row.periodStart, + periodEnd: row.periodEnd, + usageSource: row.usageSource, + usagePayload: row.usagePayload ?? {}, + createdAt: row.createdAt, + }; } } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/meter-aggregation.util.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/meter-aggregation.util.spec.ts new file mode 100644 index 000000000..eeeb39e34 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/meter-aggregation.util.spec.ts @@ -0,0 +1,207 @@ +import { + aggregateMeterValues, + filterEntriesForAttachment, + isUsageInChargeWindow, + resolveEffectiveUnitPriceNet, + type MeterUsageEntry, +} from './meter-aggregation.util'; + +function entry(partial: Partial & Pick): MeterUsageEntry { + return { + meterId: 'meter-1', + value: 10, + attachmentType: 'plan', + addonId: null, + periodStart: new Date('2026-01-10T00:00:00Z'), + periodEnd: new Date('2026-01-20T00:00:00Z'), + createdAt: new Date('2026-01-15T00:00:00Z'), + ...partial, + }; +} + +describe('meter-aggregation.util', () => { + describe('resolveEffectiveUnitPriceNet', () => { + it('uses override when present', () => { + expect(resolveEffectiveUnitPriceNet(2.5, 1)).toBe(2.5); + }); + + it('falls back to default when override is null', () => { + expect(resolveEffectiveUnitPriceNet(null, '1.25')).toBe(1.25); + }); + }); + + describe('isUsageInChargeWindow', () => { + const windowStart = new Date('2026-01-01T00:00:00Z'); + const windowEnd = new Date('2026-01-31T00:00:00Z'); + const nextWindowStart = new Date('2026-02-01T00:00:00Z'); + const nextWindowEnd = new Date('2026-02-28T00:00:00Z'); + + it('includes when periodEnd is inside window', () => { + expect( + isUsageInChargeWindow( + entry({ + id: 'in-end', + periodStart: new Date('2025-12-01T00:00:00Z'), + periodEnd: new Date('2026-01-15T00:00:00Z'), + }), + windowStart, + windowEnd, + ), + ).toBe(true); + }); + + it('excludes when only periodStart is inside window (settles in later period)', () => { + expect( + isUsageInChargeWindow( + entry({ + id: 'span-start', + periodStart: new Date('2026-01-15T00:00:00Z'), + periodEnd: new Date('2026-02-15T00:00:00Z'), + }), + windowStart, + windowEnd, + ), + ).toBe(false); + expect( + isUsageInChargeWindow( + entry({ + id: 'span-end', + periodStart: new Date('2026-01-15T00:00:00Z'), + periodEnd: new Date('2026-02-15T00:00:00Z'), + }), + nextWindowStart, + nextWindowEnd, + ), + ).toBe(true); + }); + + it('does not double-count spanning entries across adjacent periods', () => { + const spanning = entry({ + id: 'span', + periodStart: new Date('2026-01-25T00:00:00Z'), + periodEnd: new Date('2026-02-05T00:00:00Z'), + }); + + expect(isUsageInChargeWindow(spanning, windowStart, windowEnd)).toBe(false); + expect(isUsageInChargeWindow(spanning, nextWindowStart, nextWindowEnd)).toBe(true); + }); + + it('settles boundary periodEnd only into the ending period (left-open start)', () => { + const onBoundary = entry({ + id: 'boundary', + periodStart: new Date('2026-01-20T00:00:00Z'), + periodEnd: windowEnd, + }); + + expect(isUsageInChargeWindow(onBoundary, windowStart, windowEnd)).toBe(true); + expect(isUsageInChargeWindow(onBoundary, windowEnd, nextWindowEnd)).toBe(false); + }); + + it('excludes when periodEnd is outside window', () => { + expect( + isUsageInChargeWindow( + entry({ + id: 'out', + periodStart: new Date('2025-11-01T00:00:00Z'), + periodEnd: new Date('2025-12-01T00:00:00Z'), + }), + windowStart, + windowEnd, + ), + ).toBe(false); + }); + }); + + describe('filterEntriesForAttachment', () => { + const periodStart = new Date('2026-01-01T00:00:00Z'); + const periodEnd = new Date('2026-01-31T00:00:00Z'); + const entries = [ + entry({ id: 'a', attachmentType: 'plan', value: 1 }), + entry({ id: 'b', attachmentType: 'addon', addonId: 'addon-1', value: 2 }), + entry({ id: 'c', attachmentType: 'addon', addonId: 'addon-2', value: 3 }), + ]; + + it('filters plan attachment without mixing addon rows', () => { + const filtered = filterEntriesForAttachment(entries, { + meterId: 'meter-1', + attachmentType: 'plan', + periodStart, + periodEnd, + }); + + expect(filtered.map((row) => row.id)).toEqual(['a']); + }); + + it('scopes addon attachment by addonId', () => { + const filtered = filterEntriesForAttachment(entries, { + meterId: 'meter-1', + attachmentType: 'addon', + addonId: 'addon-1', + periodStart, + periodEnd, + }); + + expect(filtered.map((row) => row.id)).toEqual(['b']); + }); + }); + + describe('aggregateMeterValues', () => { + const rows = [ + entry({ id: '1', value: 5, createdAt: new Date('2026-01-01T00:00:00Z') }), + entry({ id: '2', value: 15, createdAt: new Date('2026-01-02T00:00:00Z') }), + entry({ id: '3', value: 10, createdAt: new Date('2026-01-03T00:00:00Z') }), + ]; + + it('aggregates max min avg first last', () => { + expect(aggregateMeterValues(rows, 'max')).toBe(15); + expect(aggregateMeterValues(rows, 'min')).toBe(5); + expect(aggregateMeterValues(rows, 'avg')).toBe(10); + expect(aggregateMeterValues(rows, 'first')).toBe(5); + expect(aggregateMeterValues(rows, 'last')).toBe(10); + }); + + it('sums sample values', () => { + expect(aggregateMeterValues(rows, 'sum')).toBe(30); + expect(aggregateMeterValues([entry({ id: 'only', value: 7 })], 'sum')).toBe(7); + expect(aggregateMeterValues([], 'sum')).toBe(0); + }); + + it('sums positive deltas between consecutive samples', () => { + const increasing = [ + entry({ id: 'a', value: 10, createdAt: new Date('2026-01-01T00:00:00Z') }), + entry({ id: 'b', value: 25, createdAt: new Date('2026-01-02T00:00:00Z') }), + entry({ id: 'c', value: 40, createdAt: new Date('2026-01-03T00:00:00Z') }), + ]; + + expect(aggregateMeterValues(increasing, 'sum_positive_deltas')).toBe(30); + expect(aggregateMeterValues([entry({ id: 'only', value: 12 })], 'sum_positive_deltas')).toBe(12); + expect(aggregateMeterValues([], 'sum_positive_deltas')).toBe(0); + }); + + it('treats counter resets as a fresh series for sum_positive_deltas', () => { + const withReset = [ + entry({ id: 'a', value: 100, createdAt: new Date('2026-01-01T00:00:00Z') }), + entry({ id: 'b', value: 150, createdAt: new Date('2026-01-02T00:00:00Z') }), + entry({ id: 'c', value: 20, createdAt: new Date('2026-01-03T00:00:00Z') }), + entry({ id: 'd', value: 35, createdAt: new Date('2026-01-04T00:00:00Z') }), + ]; + + // +50 then reset adds 20 then +15 + expect(aggregateMeterValues(withReset, 'sum_positive_deltas')).toBe(85); + }); + + it('sums decreasing series as successive resets for sum_positive_deltas', () => { + const decreasing = [ + entry({ id: 'a', value: 30, createdAt: new Date('2026-01-01T00:00:00Z') }), + entry({ id: 'b', value: 20, createdAt: new Date('2026-01-02T00:00:00Z') }), + entry({ id: 'c', value: 5, createdAt: new Date('2026-01-03T00:00:00Z') }), + ]; + + expect(aggregateMeterValues(decreasing, 'sum_positive_deltas')).toBe(25); + }); + + it('returns 0 for empty set', () => { + expect(aggregateMeterValues([], 'max')).toBe(0); + }); + }); +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/meter-aggregation.util.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/meter-aggregation.util.ts new file mode 100644 index 000000000..6232494d5 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/meter-aggregation.util.ts @@ -0,0 +1,153 @@ +import type { MeterAggregator } from '../entities/meter.entity'; +import type { UsageAttachmentType } from '../entities/usage-record.entity'; + +export type MeterUsageEntry = { + id: string; + meterId: string | null | undefined; + value: string | number | null | undefined; + attachmentType: UsageAttachmentType | null | undefined; + addonId?: string | null; + periodStart: Date; + periodEnd: Date; + createdAt: Date; +}; + +export function resolveEffectiveUnitPriceNet( + overrideUnitPriceNet: string | number | null | undefined, + defaultUnitPriceNet: string | number, +): number { + const override = parseNumeric(overrideUnitPriceNet); + + if (override !== null) { + return override; + } + + return parseNumeric(defaultUnitPriceNet) ?? 0; +} + +/** + * Assigns each usage record to exactly one charge period by `periodEnd`. + * Spanning intervals must not bill in both adjacent periods (avoids double charges). + * Windows are left-open on the start (`periodEnd > periodStart`) so a sample ending + * exactly on a boundary settles only into the period that ends there. + */ +export function isUsageInChargeWindow(entry: MeterUsageEntry, periodStart: Date, periodEnd: Date): boolean { + const end = entry.periodEnd.getTime(); + const windowStart = periodStart.getTime(); + const windowEnd = periodEnd.getTime(); + + return end > windowStart && end <= windowEnd; +} + +export function filterEntriesForAttachment( + entries: MeterUsageEntry[], + options: { + meterId: string; + attachmentType: UsageAttachmentType; + addonId?: string | null; + periodStart: Date; + periodEnd: Date; + }, +): MeterUsageEntry[] { + return entries.filter((entry) => { + if (entry.meterId !== options.meterId) { + return false; + } + + if ((entry.attachmentType ?? 'plan') !== options.attachmentType) { + return false; + } + + if (options.attachmentType === 'addon') { + if (!options.addonId || entry.addonId !== options.addonId) { + return false; + } + } + + return isUsageInChargeWindow(entry, options.periodStart, options.periodEnd); + }); +} + +export function aggregateMeterValues(entries: MeterUsageEntry[], aggregator: MeterAggregator): number { + if (entries.length === 0) { + return 0; + } + + const sorted = [...entries].sort((a, b) => { + const byCreated = a.createdAt.getTime() - b.createdAt.getTime(); + + if (byCreated !== 0) { + return byCreated; + } + + return a.id.localeCompare(b.id); + }); + + const values = sorted.map((entry) => parseNumeric(entry.value)).filter((value): value is number => value !== null); + + if (values.length === 0) { + return 0; + } + + switch (aggregator) { + case 'max': + return Math.max(...values); + case 'min': + return Math.min(...values); + case 'avg': + return values.reduce((sum, value) => sum + value, 0) / values.length; + case 'first': + return values[0] ?? 0; + case 'last': + return values[values.length - 1] ?? 0; + case 'sum': + return values.reduce((sum, value) => sum + value, 0); + case 'sum_positive_deltas': + return sumPositiveDeltas(values); + default: + return 0; + } +} + +/** + * Sum increases between consecutive samples. On counter reset (value decreases), + * treat the new value as a fresh series start and add it (Prometheus-style). + */ +function sumPositiveDeltas(values: number[]): number { + if (values.length === 0) { + return 0; + } + + if (values.length === 1) { + return values[0] ?? 0; + } + + let total = 0; + + for (let index = 1; index < values.length; index += 1) { + const previous = values[index - 1] ?? 0; + const current = values[index] ?? 0; + + if (current >= previous) { + total += current - previous; + } else { + total += current; + } + } + + return total; +} + +export function parseNumeric(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + + return Number.isFinite(parsed) ? parsed : null; + } + + return null; +} diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/index.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/index.ts index 53a80a327..e67cfd53e 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/index.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/index.ts @@ -24,6 +24,7 @@ export * from './lib/constants/service-type-id.constants'; export * from './lib/services/service-types.service'; export * from './lib/services/cloud-init-configs.service'; export * from './lib/services/addons.service'; +export * from './lib/services/meters.service'; export * from './lib/services/service-plans.service'; export * from './lib/services/subscriptions.service'; export * from './lib/services/backorders.service'; @@ -63,6 +64,20 @@ export * from './lib/state/addons/addons.facade'; export * from './lib/state/addons/addons.reducer'; export * from './lib/state/addons/addons.selectors'; +// Meters State +export * from './lib/state/meters/meters.actions'; +export * from './lib/state/meters/meters.effects'; +export * from './lib/state/meters/meters.facade'; +export * from './lib/state/meters/meters.reducer'; +export * from './lib/state/meters/meters.selectors'; + +// Subscription Meters State +export * from './lib/state/subscription-meters/subscription-meters.actions'; +export * from './lib/state/subscription-meters/subscription-meters.effects'; +export * from './lib/state/subscription-meters/subscription-meters.facade'; +export * from './lib/state/subscription-meters/subscription-meters.reducer'; +export * from './lib/state/subscription-meters/subscription-meters.selectors'; + // Service Plans State export * from './lib/state/service-plans/service-plans.actions'; export * from './lib/state/service-plans/service-plans.effects'; diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/addons.service.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/addons.service.spec.ts index 0c860f7bb..07cf5426e 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/addons.service.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/addons.service.spec.ts @@ -133,4 +133,79 @@ describe('AddonsService', () => { req.flush(null); }); }); + + describe('addon meters', () => { + const attachedMeter = { + meterId: 'meter-1', + key: 'api_calls', + name: 'API Calls', + aggregator: 'max' as const, + defaultUnitPriceNet: 0.01, + effectiveUnitPriceNet: 0.01, + isActive: true, + }; + + it('lists addon meters', (done) => { + service.listAddonMeters('addon-1').subscribe((meters) => { + expect(meters).toEqual([attachedMeter]); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/addons/addon-1/meters`); + expect(req.request.method).toBe('GET'); + req.flush([attachedMeter]); + }); + + it('attaches an addon meter', (done) => { + service.attachAddonMeter('addon-1', { meterId: 'meter-1' }).subscribe((meter) => { + expect(meter).toEqual(attachedMeter); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/addons/addon-1/meters`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ meterId: 'meter-1' }); + req.flush(attachedMeter); + }); + + it('updates an addon meter override', (done) => { + service.updateAddonMeter('addon-1', 'meter-1', { unitPriceNet: 0.05 }).subscribe((meter) => { + expect(meter.effectiveUnitPriceNet).toBe(0.05); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/addons/addon-1/meters/meter-1`); + expect(req.request.method).toBe('POST'); + req.flush({ ...attachedMeter, effectiveUnitPriceNet: 0.05 }); + }); + + it('detaches an addon meter', (done) => { + service.detachAddonMeter('addon-1', 'meter-1').subscribe(() => done()); + + const req = httpMock.expectOne(`${apiUrl}/addons/addon-1/meters/meter-1`); + expect(req.request.method).toBe('DELETE'); + req.flush(null); + }); + }); + + describe('listAddonModules', () => { + it('lists registered addon modules', (done) => { + const modules = [ + { + key: 'backup', + displayName: 'Backup', + meters: [{ key: 'traffic', name: 'Traffic', aggregator: 'max' as const, defaultUnitPriceNet: 0.01 }], + }, + ]; + + service.listAddonModules().subscribe((list) => { + expect(list).toEqual(modules); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/addons/modules`); + expect(req.request.method).toBe('GET'); + req.flush(modules); + }); + }); }); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/addons.service.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/addons.service.ts index 552b70bda..28d677a9d 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/addons.service.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/addons.service.ts @@ -4,7 +4,16 @@ import type { Environment } from '@forepath/shared/frontend/util-configuration'; import { ENVIRONMENT } from '@forepath/shared/frontend/util-configuration'; import { Observable } from 'rxjs'; -import type { AddonResponse, CreateAddonDto, ListParams, UpdateAddonDto } from '../types/billing.types'; +import type { + AddonModuleDetail, + AddonResponse, + AttachedMeterResponse, + AttachMeterDto, + CreateAddonDto, + ListParams, + UpdateAddonDto, + UpdateAttachedMeterDto, +} from '../types/billing.types'; @Injectable({ providedIn: 'root', @@ -31,6 +40,10 @@ export class AddonsService { return this.http.get(`${this.apiUrl}/addons`, { params: httpParams }); } + listAddonModules(): Observable { + return this.http.get(`${this.apiUrl}/addons/modules`); + } + getAddon(id: string): Observable { return this.http.get(`${this.apiUrl}/addons/${id}`); } @@ -46,4 +59,20 @@ export class AddonsService { deleteAddon(id: string): Observable { return this.http.delete(`${this.apiUrl}/addons/${id}`); } + + listAddonMeters(addonId: string): Observable { + return this.http.get(`${this.apiUrl}/addons/${addonId}/meters`); + } + + attachAddonMeter(addonId: string, dto: AttachMeterDto): Observable { + return this.http.post(`${this.apiUrl}/addons/${addonId}/meters`, dto); + } + + updateAddonMeter(addonId: string, meterId: string, dto: UpdateAttachedMeterDto): Observable { + return this.http.post(`${this.apiUrl}/addons/${addonId}/meters/${meterId}`, dto); + } + + detachAddonMeter(addonId: string, meterId: string): Observable { + return this.http.delete(`${this.apiUrl}/addons/${addonId}/meters/${meterId}`); + } } diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.spec.ts index d7f55d881..41b7d1a29 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.spec.ts @@ -331,4 +331,70 @@ describe('AdminBillingService', () => { expect(req.request.responseType).toBe('blob'); req.flush(blob); }); + + describe('subscription meter entries', () => { + const entry = { + id: 'entry-1', + subscriptionId: 'sub-1', + meterId: 'meter-1', + value: 50, + attachmentType: 'plan' as const, + periodStart: '2026-01-01T00:00:00Z', + periodEnd: '2026-01-31T23:59:59Z', + usageSource: 'admin', + usagePayload: {}, + createdAt: '2026-01-15T00:00:00Z', + }; + + it('lists meter entries', (done) => { + service.listSubscriptionMeterEntries('sub-1').subscribe((entries) => { + expect(entries).toEqual([entry]); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/admin/billing/subscriptions/sub-1/meter-entries`); + expect(req.request.method).toBe('GET'); + req.flush([entry]); + }); + + it('creates a meter entry', (done) => { + const dto = { + meterId: 'meter-1', + value: 50, + attachmentType: 'plan' as const, + periodStart: '2026-01-01T00:00:00Z', + periodEnd: '2026-01-31T23:59:59Z', + }; + + service.createSubscriptionMeterEntry('sub-1', dto).subscribe((created) => { + expect(created).toEqual(entry); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/admin/billing/subscriptions/sub-1/meter-entries`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(dto); + req.flush(entry); + }); + + it('updates a meter entry', (done) => { + service.updateSubscriptionMeterEntry('sub-1', 'entry-1', { value: 60 }).subscribe((updated) => { + expect(updated.value).toBe(60); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/admin/billing/subscriptions/sub-1/meter-entries/entry-1`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ value: 60 }); + req.flush({ ...entry, value: 60 }); + }); + + it('deletes a meter entry', (done) => { + service.deleteSubscriptionMeterEntry('sub-1', 'entry-1').subscribe(() => done()); + + const req = httpMock.expectOne(`${apiUrl}/admin/billing/subscriptions/sub-1/meter-entries/entry-1`); + expect(req.request.method).toBe('DELETE'); + req.flush(null); + }); + }); }); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.ts index 8cb5b8861..91529417e 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.ts @@ -25,6 +25,9 @@ import type { AdminSubscriptionListItem, SubscriptionResponse, UpdateManualInvoiceDto, + CreateUsageMeterEntryDto, + UpdateUsageMeterEntryDto, + UsageMeterEntryResponse, } from '../types/billing.types'; import type { AdminDatevExportListParams, @@ -269,4 +272,37 @@ export class AdminBillingService { responseType: 'blob', }); } + + listSubscriptionMeterEntries(subscriptionId: string): Observable { + return this.http.get( + `${this.apiUrl}/admin/billing/subscriptions/${subscriptionId}/meter-entries`, + ); + } + + createSubscriptionMeterEntry( + subscriptionId: string, + dto: CreateUsageMeterEntryDto, + ): Observable { + return this.http.post( + `${this.apiUrl}/admin/billing/subscriptions/${subscriptionId}/meter-entries`, + dto, + ); + } + + updateSubscriptionMeterEntry( + subscriptionId: string, + entryId: string, + dto: UpdateUsageMeterEntryDto, + ): Observable { + return this.http.post( + `${this.apiUrl}/admin/billing/subscriptions/${subscriptionId}/meter-entries/${entryId}`, + dto, + ); + } + + deleteSubscriptionMeterEntry(subscriptionId: string, entryId: string): Observable { + return this.http.delete( + `${this.apiUrl}/admin/billing/subscriptions/${subscriptionId}/meter-entries/${entryId}`, + ); + } } diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/meters.service.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/meters.service.spec.ts new file mode 100644 index 000000000..325084879 --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/meters.service.spec.ts @@ -0,0 +1,134 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { ENVIRONMENT } from '@forepath/shared/frontend/util-configuration'; + +import type { CreateMeterDto, MeterResponse, UpdateMeterDto } from '../types/billing.types'; + +import { MetersService } from './meters.service'; + +describe('MetersService', () => { + let service: MetersService; + let httpMock: HttpTestingController; + const apiUrl = 'http://localhost:3200/api'; + const mockMeter: MeterResponse = { + id: 'meter-1', + key: 'api_calls', + name: 'API Calls', + aggregator: 'max', + defaultUnitPriceNet: 0.01, + isActive: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + { + provide: ENVIRONMENT, + useValue: { billing: { restApiUrl: apiUrl } }, + }, + ], + }); + + service = TestBed.inject(MetersService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('listMeters', () => { + it('lists meters', (done) => { + service.listMeters().subscribe((list) => { + expect(list).toEqual([mockMeter]); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/meters`); + expect(req.request.method).toBe('GET'); + req.flush([mockMeter]); + }); + + it('includes pagination parameters when provided', (done) => { + service.listMeters({ limit: 10, offset: 20 }).subscribe((list) => { + expect(list).toEqual([mockMeter]); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/meters?limit=10&offset=20`); + expect(req.request.params.get('limit')).toBe('10'); + expect(req.request.params.get('offset')).toBe('20'); + req.flush([mockMeter]); + }); + }); + + describe('getMeter', () => { + it('returns a meter by id', (done) => { + service.getMeter('meter-1').subscribe((meter) => { + expect(meter).toEqual(mockMeter); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/meters/meter-1`); + expect(req.request.method).toBe('GET'); + req.flush(mockMeter); + }); + }); + + describe('createMeter', () => { + it('creates a meter', (done) => { + const dto: CreateMeterDto = { + key: 'api_calls', + name: 'API Calls', + aggregator: 'max', + defaultUnitPriceNet: 0.01, + }; + + service.createMeter(dto).subscribe((meter) => { + expect(meter).toEqual(mockMeter); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/meters`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(dto); + req.flush(mockMeter); + }); + }); + + describe('updateMeter', () => { + it('updates a meter', (done) => { + const dto: UpdateMeterDto = { name: 'Updated' }; + + service.updateMeter('meter-1', dto).subscribe((meter) => { + expect(meter).toEqual(mockMeter); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/meters/meter-1`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(dto); + req.flush(mockMeter); + }); + }); + + describe('deleteMeter', () => { + it('deletes a meter', (done) => { + service.deleteMeter('meter-1').subscribe((result) => { + expect(result).toBeNull(); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/meters/meter-1`); + expect(req.request.method).toBe('DELETE'); + req.flush(null); + }); + }); +}); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/meters.service.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/meters.service.ts new file mode 100644 index 000000000..bbdccf477 --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/meters.service.ts @@ -0,0 +1,49 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import type { Environment } from '@forepath/shared/frontend/util-configuration'; +import { ENVIRONMENT } from '@forepath/shared/frontend/util-configuration'; +import { Observable } from 'rxjs'; + +import type { CreateMeterDto, ListParams, MeterResponse, UpdateMeterDto } from '../types/billing.types'; + +@Injectable({ + providedIn: 'root', +}) +export class MetersService { + private readonly http = inject(HttpClient); + private readonly environment = inject(ENVIRONMENT); + + private get apiUrl(): string { + return this.environment.billing.restApiUrl; + } + + listMeters(params?: ListParams): Observable { + let httpParams = new HttpParams(); + + if (params?.limit !== undefined) { + httpParams = httpParams.set('limit', params.limit.toString()); + } + + if (params?.offset !== undefined) { + httpParams = httpParams.set('offset', params.offset.toString()); + } + + return this.http.get(`${this.apiUrl}/meters`, { params: httpParams }); + } + + getMeter(id: string): Observable { + return this.http.get(`${this.apiUrl}/meters/${id}`); + } + + createMeter(dto: CreateMeterDto): Observable { + return this.http.post(`${this.apiUrl}/meters`, dto); + } + + updateMeter(id: string, dto: UpdateMeterDto): Observable { + return this.http.post(`${this.apiUrl}/meters/${id}`, dto); + } + + deleteMeter(id: string): Observable { + return this.http.delete(`${this.apiUrl}/meters/${id}`); + } +} diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-plans.service.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-plans.service.spec.ts index 70cb11db4..0889869f6 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-plans.service.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-plans.service.spec.ts @@ -221,4 +221,59 @@ describe('ServicePlansService', () => { req.flush(options); }); }); + + describe('plan meters', () => { + const attachedMeter = { + meterId: 'meter-1', + key: 'api_calls', + name: 'API Calls', + aggregator: 'max' as const, + defaultUnitPriceNet: 0.01, + effectiveUnitPriceNet: 0.01, + isActive: true, + }; + + it('lists plan meters', (done) => { + service.listPlanMeters('sp-1').subscribe((meters) => { + expect(meters).toEqual([attachedMeter]); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/service-plans/sp-1/meters`); + expect(req.request.method).toBe('GET'); + req.flush([attachedMeter]); + }); + + it('attaches a plan meter', (done) => { + service.attachPlanMeter('sp-1', { meterId: 'meter-1', unitPriceNet: 0.02 }).subscribe((meter) => { + expect(meter).toEqual({ ...attachedMeter, effectiveUnitPriceNet: 0.02 }); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/service-plans/sp-1/meters`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ meterId: 'meter-1', unitPriceNet: 0.02 }); + req.flush({ ...attachedMeter, effectiveUnitPriceNet: 0.02 }); + }); + + it('updates a plan meter override', (done) => { + service.updatePlanMeter('sp-1', 'meter-1', { unitPriceNet: 0.03 }).subscribe((meter) => { + expect(meter.effectiveUnitPriceNet).toBe(0.03); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/service-plans/sp-1/meters/meter-1`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ unitPriceNet: 0.03 }); + req.flush({ ...attachedMeter, effectiveUnitPriceNet: 0.03 }); + }); + + it('detaches a plan meter', (done) => { + service.detachPlanMeter('sp-1', 'meter-1').subscribe(() => done()); + + const req = httpMock.expectOne(`${apiUrl}/service-plans/sp-1/meters/meter-1`); + expect(req.request.method).toBe('DELETE'); + req.flush(null); + }); + }); }); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-plans.service.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-plans.service.ts index 8fdea0553..8c6fd254f 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-plans.service.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-plans.service.ts @@ -5,12 +5,15 @@ import { ENVIRONMENT } from '@forepath/shared/frontend/util-configuration'; import { Observable } from 'rxjs'; import type { + AttachedMeterResponse, + AttachMeterDto, CreateServicePlanDto, CloudInitConfigOrderField, ListParams, OrderProvisioningOption, PlanAddonOptionDto, ServicePlanResponse, + UpdateAttachedMeterDto, UpdateServicePlanDto, } from '../types/billing.types'; @@ -90,4 +93,20 @@ export class ServicePlansService { deleteServicePlan(id: string): Observable { return this.http.delete(`${this.apiUrl}/service-plans/${id}`); } + + listPlanMeters(planId: string): Observable { + return this.http.get(`${this.apiUrl}/service-plans/${planId}/meters`); + } + + attachPlanMeter(planId: string, dto: AttachMeterDto): Observable { + return this.http.post(`${this.apiUrl}/service-plans/${planId}/meters`, dto); + } + + updatePlanMeter(planId: string, meterId: string, dto: UpdateAttachedMeterDto): Observable { + return this.http.post(`${this.apiUrl}/service-plans/${planId}/meters/${meterId}`, dto); + } + + detachPlanMeter(planId: string, meterId: string): Observable { + return this.http.delete(`${this.apiUrl}/service-plans/${planId}/meters/${meterId}`); + } } diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-types.service.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-types.service.spec.ts index b6c17ed4f..1f4909374 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-types.service.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-types.service.spec.ts @@ -84,4 +84,60 @@ describe('ServiceTypesService', () => { req.flush(mockLocations); }); }); + + describe('service type meters', () => { + const attachedMeter = { + meterId: 'meter-1', + key: 'traffic', + name: 'Traffic', + aggregator: 'max' as const, + defaultUnitPriceNet: 0.01, + effectiveUnitPriceNet: 0.01, + isActive: true, + source: 'manual' as const, + required: false, + }; + + it('lists service type meters', (done) => { + service.listServiceTypeMeters('st-1').subscribe((meters) => { + expect(meters).toEqual([attachedMeter]); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/service-types/st-1/meters`); + expect(req.request.method).toBe('GET'); + req.flush([attachedMeter]); + }); + + it('attaches a service type meter', (done) => { + service.attachServiceTypeMeter('st-1', { meterId: 'meter-1', unitPriceNet: 0.02 }).subscribe((meter) => { + expect(meter).toEqual(attachedMeter); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/service-types/st-1/meters`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ meterId: 'meter-1', unitPriceNet: 0.02 }); + req.flush(attachedMeter); + }); + + it('updates a service type meter override', (done) => { + service.updateServiceTypeMeter('st-1', 'meter-1', { unitPriceNet: 0.05 }).subscribe((meter) => { + expect(meter.effectiveUnitPriceNet).toBe(0.05); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/service-types/st-1/meters/meter-1`); + expect(req.request.method).toBe('POST'); + req.flush({ ...attachedMeter, effectiveUnitPriceNet: 0.05 }); + }); + + it('detaches a service type meter', (done) => { + service.detachServiceTypeMeter('st-1', 'meter-1').subscribe(() => done()); + + const req = httpMock.expectOne(`${apiUrl}/service-types/st-1/meters/meter-1`); + expect(req.request.method).toBe('DELETE'); + req.flush(null); + }); + }); }); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-types.service.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-types.service.ts index f87b13a4b..8bf867bc7 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-types.service.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-types.service.ts @@ -6,12 +6,15 @@ import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import type { + AttachMeterDto, + AttachedMeterResponse, CreateServiceTypeDto, ListParams, ProviderDetail, ProviderLocation, ServerType, ServiceTypeResponse, + UpdateAttachedMeterDto, UpdateServiceTypeDto, } from '../types/billing.types'; import { normalizeProviderServerTypes } from '../utils/server-type-list.utils'; @@ -117,4 +120,27 @@ export class ServiceTypesService { deleteServiceType(id: string): Observable { return this.http.delete(`${this.apiUrl}/service-types/${id}`); } + + listServiceTypeMeters(serviceTypeId: string): Observable { + return this.http.get(`${this.apiUrl}/service-types/${serviceTypeId}/meters`); + } + + attachServiceTypeMeter(serviceTypeId: string, dto: AttachMeterDto): Observable { + return this.http.post(`${this.apiUrl}/service-types/${serviceTypeId}/meters`, dto); + } + + updateServiceTypeMeter( + serviceTypeId: string, + meterId: string, + dto: UpdateAttachedMeterDto, + ): Observable { + return this.http.post( + `${this.apiUrl}/service-types/${serviceTypeId}/meters/${meterId}`, + dto, + ); + } + + detachServiceTypeMeter(serviceTypeId: string, meterId: string): Observable { + return this.http.delete(`${this.apiUrl}/service-types/${serviceTypeId}/meters/${meterId}`); + } } diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/usage.service.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/usage.service.spec.ts index 67c6989e5..1ba2eb566 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/usage.service.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/usage.service.spec.ts @@ -89,4 +89,31 @@ describe('UsageService', () => { req.flush(mockRecord); }); }); + + describe('getSubscriptionMeters', () => { + it('returns meter summaries for a subscription', (done) => { + const summaries = [ + { + meterId: 'meter-1', + key: 'api_calls', + name: 'API Calls', + aggregator: 'max' as const, + attachmentType: 'plan' as const, + effectiveUnitPriceNet: 0.01, + aggregatedValue: 100, + estimatedChargeNet: 1, + entryCount: 1, + }, + ]; + + service.getSubscriptionMeters(subscriptionId).subscribe((result) => { + expect(result).toEqual(summaries); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/subscriptions/${subscriptionId}/meters`); + expect(req.request.method).toBe('GET'); + req.flush(summaries); + }); + }); }); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/usage.service.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/usage.service.ts index 2e5997578..a52d87fcd 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/usage.service.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/usage.service.ts @@ -4,7 +4,12 @@ import type { Environment } from '@forepath/shared/frontend/util-configuration'; import { ENVIRONMENT } from '@forepath/shared/frontend/util-configuration'; import { Observable } from 'rxjs'; -import type { CreateUsageRecordDto, UsageRecordResponse, UsageSummary } from '../types/billing.types'; +import type { + CreateUsageRecordDto, + SubscriptionMeterSummary, + UsageRecordResponse, + UsageSummary, +} from '../types/billing.types'; @Injectable({ providedIn: 'root', @@ -33,4 +38,8 @@ export class UsageService { recordUsage(record: CreateUsageRecordDto): Observable { return this.http.post(`${this.apiUrl}/admin/usage/record`, record); } + + getSubscriptionMeters(subscriptionId: string): Observable { + return this.http.get(`${this.apiUrl}/subscriptions/${subscriptionId}/meters`); + } } diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.actions.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.actions.ts new file mode 100644 index 000000000..26a084c3e --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.actions.ts @@ -0,0 +1,24 @@ +import { createAction, props } from '@ngrx/store'; + +import type { CreateMeterDto, ListParams, MeterResponse, UpdateMeterDto } from '../../types/billing.types'; + +export const loadMeters = createAction('[Meters] Load Meters', props<{ params?: ListParams }>()); +export const loadMetersSuccess = createAction('[Meters] Load Meters Success', props<{ meters: MeterResponse[] }>()); +export const loadMetersFailure = createAction('[Meters] Load Meters Failure', props<{ error: string }>()); +export const loadMetersBatch = createAction( + '[Meters] Load Meters Batch', + props<{ offset: number; accumulatedMeters: MeterResponse[] }>(), +); +export const loadMeter = createAction('[Meters] Load Meter', props<{ id: string }>()); +export const loadMeterSuccess = createAction('[Meters] Load Meter Success', props<{ meter: MeterResponse }>()); +export const loadMeterFailure = createAction('[Meters] Load Meter Failure', props<{ error: string }>()); +export const createMeter = createAction('[Meters] Create Meter', props<{ meter: CreateMeterDto }>()); +export const createMeterSuccess = createAction('[Meters] Create Meter Success', props<{ meter: MeterResponse }>()); +export const createMeterFailure = createAction('[Meters] Create Meter Failure', props<{ error: string }>()); +export const updateMeter = createAction('[Meters] Update Meter', props<{ id: string; meter: UpdateMeterDto }>()); +export const updateMeterSuccess = createAction('[Meters] Update Meter Success', props<{ meter: MeterResponse }>()); +export const updateMeterFailure = createAction('[Meters] Update Meter Failure', props<{ error: string }>()); +export const deleteMeter = createAction('[Meters] Delete Meter', props<{ id: string }>()); +export const deleteMeterSuccess = createAction('[Meters] Delete Meter Success', props<{ id: string }>()); +export const deleteMeterFailure = createAction('[Meters] Delete Meter Failure', props<{ error: string }>()); +export const clearSelectedMeter = createAction('[Meters] Clear Selected Meter'); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.effects.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.effects.spec.ts new file mode 100644 index 000000000..7a259eabb --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.effects.spec.ts @@ -0,0 +1,160 @@ +import { TestBed } from '@angular/core/testing'; +import { Actions } from '@ngrx/effects'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { of, throwError } from 'rxjs'; + +import { MetersService } from '../../services/meters.service'; +import type { MeterResponse } from '../../types/billing.types'; + +import { + createMeter, + createMeterFailure, + createMeterSuccess, + deleteMeter, + deleteMeterFailure, + deleteMeterSuccess, + loadMeter, + loadMeterFailure, + loadMeters, + loadMetersBatch, + loadMetersFailure, + loadMetersSuccess, + loadMeterSuccess, + updateMeter, + updateMeterFailure, + updateMeterSuccess, +} from './meters.actions'; +import { createMeter$, deleteMeter$, loadMeter$, loadMeters$, loadMetersBatch$, updateMeter$ } from './meters.effects'; + +describe('metersEffects', () => { + let actions$: Actions; + let service: jest.Mocked; + const meter: MeterResponse = { + id: 'meter-1', + key: 'api_calls', + name: 'API Calls', + aggregator: 'max', + defaultUnitPriceNet: 0.01, + isActive: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }; + + beforeEach(() => { + service = { + listMeters: jest.fn(), + getMeter: jest.fn(), + createMeter: jest.fn(), + updateMeter: jest.fn(), + deleteMeter: jest.fn(), + } as never; + + TestBed.configureTestingModule({ + providers: [provideMockActions(() => actions$), { provide: MetersService, useValue: service }], + }); + actions$ = TestBed.inject(Actions); + }); + + it('loads meters', (done) => { + actions$ = of(loadMeters({})); + service.listMeters.mockReturnValue(of([meter])); + + loadMeters$(actions$, service).subscribe((result) => { + expect(result).toEqual(loadMetersSuccess({ meters: [meter] })); + done(); + }); + }); + + it('starts a batch when the first page is full', (done) => { + const page = Array.from({ length: 10 }, (_, index) => ({ ...meter, id: `meter-${index}` })); + actions$ = of(loadMeters({})); + service.listMeters.mockReturnValue(of(page)); + + loadMeters$(actions$, service).subscribe((result) => { + expect(result).toEqual(loadMetersBatch({ offset: 10, accumulatedMeters: page })); + done(); + }); + }); + + it('continues batching until the last page', (done) => { + const firstPage = Array.from({ length: 10 }, (_, index) => ({ ...meter, id: `meter-${index}` })); + const lastPage = [{ ...meter, id: 'meter-10' }]; + actions$ = of(loadMetersBatch({ offset: 10, accumulatedMeters: firstPage })); + service.listMeters.mockReturnValue(of(lastPage)); + + loadMetersBatch$(actions$, service).subscribe((result) => { + expect(result).toEqual(loadMetersSuccess({ meters: [...firstPage, ...lastPage] })); + done(); + }); + }); + + it('maps load errors', (done) => { + actions$ = of(loadMeters({})); + service.listMeters.mockReturnValue(throwError(() => new Error('Load failed'))); + + loadMeters$(actions$, service).subscribe((result) => { + expect(result).toEqual(loadMetersFailure({ error: 'Load failed' })); + done(); + }); + }); + + it('loads a single meter', (done) => { + actions$ = of(loadMeter({ id: meter.id })); + service.getMeter.mockReturnValue(of(meter)); + + loadMeter$(actions$, service).subscribe((result) => { + expect(result).toEqual(loadMeterSuccess({ meter })); + done(); + }); + }); + + it('maps single meter load errors', (done) => { + actions$ = of(loadMeter({ id: meter.id })); + service.getMeter.mockReturnValue(throwError(() => 'not found')); + + loadMeter$(actions$, service).subscribe((result) => { + expect(result).toEqual(loadMeterFailure({ error: 'not found' })); + done(); + }); + }); + + it('creates a meter', (done) => { + actions$ = of(createMeter({ meter: {} as never })); + service.createMeter.mockReturnValue(of(meter)); + + createMeter$(actions$, service).subscribe((result) => { + expect(result).toEqual(createMeterSuccess({ meter })); + done(); + }); + }); + + it('updates a meter', (done) => { + actions$ = of(updateMeter({ id: meter.id, meter: { name: 'Updated' } })); + service.updateMeter.mockReturnValue(of({ ...meter, name: 'Updated' })); + + updateMeter$(actions$, service).subscribe((result) => { + expect(result).toEqual(updateMeterSuccess({ meter: { ...meter, name: 'Updated' } })); + done(); + }); + }); + + it('deletes a meter', (done) => { + actions$ = of(deleteMeter({ id: meter.id })); + service.deleteMeter.mockReturnValue(of(undefined)); + + deleteMeter$(actions$, service).subscribe((result) => { + expect(result).toEqual(deleteMeterSuccess({ id: meter.id })); + done(); + }); + }); + + it('maps delete errors', (done) => { + actions$ = of(deleteMeter({ id: meter.id })); + service.deleteMeter.mockReturnValue(throwError(() => ({ code: 500 }))); + + deleteMeter$(actions$, service).subscribe((result) => { + expect(result).toEqual(deleteMeterFailure({ error: 'An unexpected error occurred' })); + done(); + }); + }); +}); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.effects.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.effects.ts new file mode 100644 index 000000000..bb54c8768 --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.effects.ts @@ -0,0 +1,130 @@ +import { inject } from '@angular/core'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { catchError, map, of, switchMap } from 'rxjs'; + +import { MetersService } from '../../services/meters.service'; + +import { + createMeter, + createMeterFailure, + createMeterSuccess, + deleteMeter, + deleteMeterFailure, + deleteMeterSuccess, + loadMeter, + loadMeterFailure, + loadMeters, + loadMetersBatch, + loadMetersFailure, + loadMetersSuccess, + loadMeterSuccess, + updateMeter, + updateMeterFailure, + updateMeterSuccess, +} from './meters.actions'; + +function normalizeError(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + if (error && typeof error === 'object' && 'message' in error) return String(error.message); + + return 'An unexpected error occurred'; +} + +const BATCH_SIZE = 10; + +export const loadMeters$ = createEffect( + (actions$ = inject(Actions), metersService = inject(MetersService)) => + actions$.pipe( + ofType(loadMeters), + switchMap(({ params }) => { + const batchParams = { limit: BATCH_SIZE, offset: 0, ...params }; + + return metersService.listMeters(batchParams).pipe( + switchMap((meters) => + meters.length < BATCH_SIZE + ? of(loadMetersSuccess({ meters })) + : of(loadMetersBatch({ offset: BATCH_SIZE, accumulatedMeters: meters })), + ), + catchError((error) => of(loadMetersFailure({ error: normalizeError(error) }))), + ); + }), + ), + { functional: true }, +); + +export const loadMetersBatch$ = createEffect( + (actions$ = inject(Actions), metersService = inject(MetersService)) => + actions$.pipe( + ofType(loadMetersBatch), + switchMap(({ offset, accumulatedMeters }) => + metersService.listMeters({ limit: BATCH_SIZE, offset }).pipe( + switchMap((meters) => { + const next = [...accumulatedMeters, ...meters]; + + return meters.length < BATCH_SIZE + ? of(loadMetersSuccess({ meters: next })) + : of(loadMetersBatch({ offset: offset + BATCH_SIZE, accumulatedMeters: next })); + }), + catchError((error) => of(loadMetersFailure({ error: normalizeError(error) }))), + ), + ), + ), + { functional: true }, +); + +export const loadMeter$ = createEffect( + (actions$ = inject(Actions), metersService = inject(MetersService)) => + actions$.pipe( + ofType(loadMeter), + switchMap(({ id }) => + metersService.getMeter(id).pipe( + map((meter) => loadMeterSuccess({ meter })), + catchError((error) => of(loadMeterFailure({ error: normalizeError(error) }))), + ), + ), + ), + { functional: true }, +); + +export const createMeter$ = createEffect( + (actions$ = inject(Actions), metersService = inject(MetersService)) => + actions$.pipe( + ofType(createMeter), + switchMap(({ meter }) => + metersService.createMeter(meter).pipe( + map((created) => createMeterSuccess({ meter: created })), + catchError((error) => of(createMeterFailure({ error: normalizeError(error) }))), + ), + ), + ), + { functional: true }, +); + +export const updateMeter$ = createEffect( + (actions$ = inject(Actions), metersService = inject(MetersService)) => + actions$.pipe( + ofType(updateMeter), + switchMap(({ id, meter }) => + metersService.updateMeter(id, meter).pipe( + map((updated) => updateMeterSuccess({ meter: updated })), + catchError((error) => of(updateMeterFailure({ error: normalizeError(error) }))), + ), + ), + ), + { functional: true }, +); + +export const deleteMeter$ = createEffect( + (actions$ = inject(Actions), metersService = inject(MetersService)) => + actions$.pipe( + ofType(deleteMeter), + switchMap(({ id }) => + metersService.deleteMeter(id).pipe( + map(() => deleteMeterSuccess({ id })), + catchError((error) => of(deleteMeterFailure({ error: normalizeError(error) }))), + ), + ), + ), + { functional: true }, +); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.facade.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.facade.spec.ts new file mode 100644 index 000000000..31693f60e --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.facade.spec.ts @@ -0,0 +1,107 @@ +import { TestBed } from '@angular/core/testing'; +import { Store } from '@ngrx/store'; +import { of } from 'rxjs'; + +import type { CreateMeterDto, ListParams, MeterResponse, UpdateMeterDto } from '../../types/billing.types'; + +import { clearSelectedMeter, createMeter, deleteMeter, loadMeter, loadMeters, updateMeter } from './meters.actions'; +import { MetersFacade } from './meters.facade'; + +describe('MetersFacade', () => { + let facade: MetersFacade; + let store: jest.Mocked; + const mockMeter: MeterResponse = { + id: 'meter-1', + key: 'api_calls', + name: 'API Calls', + aggregator: 'max', + defaultUnitPriceNet: 0.01, + isActive: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }; + + beforeEach(() => { + store = { select: jest.fn(), dispatch: jest.fn() } as never; + + TestBed.configureTestingModule({ + providers: [MetersFacade, { provide: Store, useValue: store }], + }); + + facade = TestBed.inject(MetersFacade); + }); + + describe('State Observables', () => { + it('returns meters observable', (done) => { + store.select.mockReturnValue(of([mockMeter])); + facade.getMeters$().subscribe((result) => { + expect(result).toEqual([mockMeter]); + done(); + }); + }); + + it('returns active meters observable', (done) => { + store.select.mockReturnValue(of([mockMeter])); + facade.getActiveMeters$().subscribe((result) => { + expect(result).toEqual([mockMeter]); + done(); + }); + }); + + it('returns selected meter observable', (done) => { + store.select.mockReturnValue(of(mockMeter)); + facade.getSelectedMeter$().subscribe((result) => { + expect(result).toEqual(mockMeter); + done(); + }); + }); + + it('returns loading observables', (done) => { + store.select.mockReturnValue(of(true)); + facade.getMetersLoading$().subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + }); + + describe('Actions', () => { + it('dispatches loadMeters', () => { + const params: ListParams = { limit: 10 }; + facade.loadMeters(params); + expect(store.dispatch).toHaveBeenCalledWith(loadMeters({ params })); + }); + + it('dispatches loadMeter', () => { + facade.loadMeter('meter-1'); + expect(store.dispatch).toHaveBeenCalledWith(loadMeter({ id: 'meter-1' })); + }); + + it('dispatches createMeter', () => { + const dto: CreateMeterDto = { + key: 'api_calls', + name: 'API Calls', + aggregator: 'max', + defaultUnitPriceNet: 0.01, + }; + facade.createMeter(dto); + expect(store.dispatch).toHaveBeenCalledWith(createMeter({ meter: dto })); + }); + + it('dispatches updateMeter', () => { + const dto: UpdateMeterDto = { name: 'Updated' }; + facade.updateMeter('meter-1', dto); + expect(store.dispatch).toHaveBeenCalledWith(updateMeter({ id: 'meter-1', meter: dto })); + }); + + it('dispatches deleteMeter', () => { + facade.deleteMeter('meter-1'); + expect(store.dispatch).toHaveBeenCalledWith(deleteMeter({ id: 'meter-1' })); + }); + + it('dispatches clearSelectedMeter', () => { + facade.clearSelectedMeter(); + expect(store.dispatch).toHaveBeenCalledWith(clearSelectedMeter()); + }); + }); +}); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.facade.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.facade.ts new file mode 100644 index 000000000..3d52f2688 --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.facade.ts @@ -0,0 +1,90 @@ +import { inject, Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; + +import type { CreateMeterDto, ListParams, MeterResponse, UpdateMeterDto } from '../../types/billing.types'; + +import { clearSelectedMeter, createMeter, deleteMeter, loadMeter, loadMeters, updateMeter } from './meters.actions'; +import { + selectActiveMeters, + selectMeterLoading, + selectMetersCreating, + selectMetersDeleting, + selectMetersEntities, + selectMetersError, + selectMetersLoading, + selectMetersLoadingAny, + selectMetersUpdating, + selectSelectedMeter, +} from './meters.selectors'; + +@Injectable({ + providedIn: 'root', +}) +export class MetersFacade { + private readonly store = inject(Store); + + getMeters$(): Observable { + return this.store.select(selectMetersEntities); + } + + getActiveMeters$(): Observable { + return this.store.select(selectActiveMeters); + } + + getSelectedMeter$(): Observable { + return this.store.select(selectSelectedMeter); + } + + getMetersLoading$(): Observable { + return this.store.select(selectMetersLoading); + } + + getMeterLoading$(): Observable { + return this.store.select(selectMeterLoading); + } + + getMetersCreating$(): Observable { + return this.store.select(selectMetersCreating); + } + + getMetersUpdating$(): Observable { + return this.store.select(selectMetersUpdating); + } + + getMetersDeleting$(): Observable { + return this.store.select(selectMetersDeleting); + } + + getMetersLoadingAny$(): Observable { + return this.store.select(selectMetersLoadingAny); + } + + getMetersError$(): Observable { + return this.store.select(selectMetersError); + } + + loadMeters(params?: ListParams): void { + this.store.dispatch(loadMeters({ params })); + } + + loadMeter(id: string): void { + this.store.dispatch(loadMeter({ id })); + } + + createMeter(meter: CreateMeterDto): void { + this.store.dispatch(createMeter({ meter })); + } + + updateMeter(id: string, meter: UpdateMeterDto): void { + this.store.dispatch(updateMeter({ id, meter })); + } + + deleteMeter(id: string): void { + this.store.dispatch(deleteMeter({ id })); + } + + clearSelectedMeter(): void { + this.store.dispatch(clearSelectedMeter()); + } +} diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.reducer.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.reducer.spec.ts new file mode 100644 index 000000000..42e3b8f87 --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.reducer.spec.ts @@ -0,0 +1,92 @@ +import type { MeterResponse } from '../../types/billing.types'; + +import { + clearSelectedMeter, + createMeter, + createMeterFailure, + createMeterSuccess, + deleteMeter, + deleteMeterFailure, + deleteMeterSuccess, + loadMeter, + loadMeterFailure, + loadMeters, + loadMetersBatch, + loadMetersFailure, + loadMetersSuccess, + loadMeterSuccess, + updateMeter, + updateMeterFailure, + updateMeterSuccess, +} from './meters.actions'; +import { initialMetersState, metersReducer } from './meters.reducer'; + +describe('metersReducer', () => { + const meter: MeterResponse = { + id: 'meter-1', + key: 'api_calls', + name: 'API Calls', + aggregator: 'max', + defaultUnitPriceNet: 0.01, + isActive: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }; + + it('returns the initial state', () => { + expect(metersReducer(undefined, { type: 'UNKNOWN' } as never)).toEqual(initialMetersState); + }); + + it('loads meters', () => { + const loading = metersReducer(initialMetersState, loadMeters({})); + const batched = metersReducer(loading, loadMetersBatch({ offset: 10, accumulatedMeters: [meter] })); + const loaded = metersReducer(batched, loadMetersSuccess({ meters: [meter] })); + + expect(loading.loading).toBe(true); + expect(batched.entities).toEqual([meter]); + expect(loaded.entities).toEqual([meter]); + expect(loaded.loading).toBe(false); + }); + + it('stores load failures', () => { + const state = metersReducer({ ...initialMetersState, loading: true }, loadMetersFailure({ error: 'Load failed' })); + + expect(state.error).toBe('Load failed'); + expect(state.loading).toBe(false); + }); + + it('loads a single meter into selection and entities', () => { + const loading = metersReducer(initialMetersState, loadMeter({ id: meter.id })); + const loaded = metersReducer(loading, loadMeterSuccess({ meter })); + const replaced = metersReducer(loaded, loadMeterSuccess({ meter: { ...meter, name: 'Replaced' } })); + const failed = metersReducer(replaced, loadMeterFailure({ error: 'Missing' })); + + expect(loading.loadingMeter).toBe(true); + expect(loaded.selectedMeter).toEqual(meter); + expect(loaded.entities).toEqual([meter]); + expect(replaced.entities[0].name).toBe('Replaced'); + expect(failed.error).toBe('Missing'); + expect(failed.loadingMeter).toBe(false); + }); + + it('adds, updates, and deletes meters', () => { + const creating = metersReducer(initialMetersState, createMeter({ meter: {} as never })); + const created = metersReducer(creating, createMeterSuccess({ meter })); + const createFailed = metersReducer(creating, createMeterFailure({ error: 'Create failed' })); + const updating = metersReducer(created, updateMeter({ id: meter.id, meter: {} })); + const updated = metersReducer(updating, updateMeterSuccess({ meter: { ...meter, name: 'Updated' } })); + const updateFailed = metersReducer(updating, updateMeterFailure({ error: 'Update failed' })); + const deleting = metersReducer(updated, deleteMeter({ id: meter.id })); + const deleted = metersReducer(deleting, deleteMeterSuccess({ id: meter.id })); + const deleteFailed = metersReducer(deleting, deleteMeterFailure({ error: 'Delete failed' })); + const cleared = metersReducer(created, clearSelectedMeter()); + + expect(created.entities).toEqual([meter]); + expect(createFailed.creating).toBe(false); + expect(updated.entities[0].name).toBe('Updated'); + expect(updateFailed.updating).toBe(false); + expect(deleted.entities).toEqual([]); + expect(deleteFailed.deleting).toBe(false); + expect(cleared.selectedMeter).toBeNull(); + }); +}); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.reducer.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.reducer.ts new file mode 100644 index 000000000..9f84b6f43 --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.reducer.ts @@ -0,0 +1,97 @@ +import { createReducer, on } from '@ngrx/store'; + +import type { MeterResponse } from '../../types/billing.types'; + +import { + clearSelectedMeter, + createMeter, + createMeterFailure, + createMeterSuccess, + deleteMeter, + deleteMeterFailure, + deleteMeterSuccess, + loadMeter, + loadMeterFailure, + loadMeters, + loadMetersBatch, + loadMetersFailure, + loadMetersSuccess, + loadMeterSuccess, + updateMeter, + updateMeterFailure, + updateMeterSuccess, +} from './meters.actions'; + +export interface MetersState { + entities: MeterResponse[]; + selectedMeter: MeterResponse | null; + loading: boolean; + loadingMeter: boolean; + creating: boolean; + updating: boolean; + deleting: boolean; + error: string | null; +} + +export const initialMetersState: MetersState = { + entities: [], + selectedMeter: null, + loading: false, + loadingMeter: false, + creating: false, + updating: false, + deleting: false, + error: null, +}; + +export const metersReducer = createReducer( + initialMetersState, + on(loadMeters, (state) => ({ ...state, entities: [], loading: true, error: null })), + on(loadMetersBatch, (state, { accumulatedMeters }) => ({ + ...state, + entities: accumulatedMeters, + loading: true, + error: null, + })), + on(loadMetersSuccess, (state, { meters }) => ({ ...state, entities: meters, loading: false, error: null })), + on(loadMetersFailure, (state, { error }) => ({ ...state, loading: false, error })), + on(loadMeter, (state) => ({ ...state, loadingMeter: true, error: null })), + on(loadMeterSuccess, (state, { meter }) => ({ + ...state, + entities: state.entities.some((item) => item.id === meter.id) + ? state.entities.map((item) => (item.id === meter.id ? meter : item)) + : [...state.entities, meter], + selectedMeter: meter, + loadingMeter: false, + error: null, + })), + on(loadMeterFailure, (state, { error }) => ({ ...state, loadingMeter: false, error })), + on(createMeter, (state) => ({ ...state, creating: true, error: null })), + on(createMeterSuccess, (state, { meter }) => ({ + ...state, + entities: [...state.entities, meter], + selectedMeter: meter, + creating: false, + error: null, + })), + on(createMeterFailure, (state, { error }) => ({ ...state, creating: false, error })), + on(updateMeter, (state) => ({ ...state, updating: true, error: null })), + on(updateMeterSuccess, (state, { meter }) => ({ + ...state, + entities: state.entities.map((item) => (item.id === meter.id ? meter : item)), + selectedMeter: state.selectedMeter?.id === meter.id ? meter : state.selectedMeter, + updating: false, + error: null, + })), + on(updateMeterFailure, (state, { error }) => ({ ...state, updating: false, error })), + on(deleteMeter, (state) => ({ ...state, deleting: true, error: null })), + on(deleteMeterSuccess, (state, { id }) => ({ + ...state, + entities: state.entities.filter((item) => item.id !== id), + selectedMeter: state.selectedMeter?.id === id ? null : state.selectedMeter, + deleting: false, + error: null, + })), + on(deleteMeterFailure, (state, { error }) => ({ ...state, deleting: false, error })), + on(clearSelectedMeter, (state) => ({ ...state, selectedMeter: null })), +); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.selectors.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.selectors.spec.ts new file mode 100644 index 000000000..ff90cf050 --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.selectors.spec.ts @@ -0,0 +1,80 @@ +import { + selectActiveMeters, + selectMeterById, + selectMeterLoading, + selectMetersCreating, + selectMetersDeleting, + selectMetersEntities, + selectMetersError, + selectMetersLoading, + selectMetersLoadingAny, + selectMetersUpdating, + selectSelectedMeter, +} from './meters.selectors'; +import { initialMetersState } from './meters.reducer'; + +describe('metersSelectors', () => { + const activeMeter = { + id: '1', + key: 'a', + name: 'Active', + aggregator: 'max' as const, + defaultUnitPriceNet: 1, + isActive: true, + createdAt: '', + updatedAt: '', + }; + const inactiveMeter = { + id: '2', + key: 'b', + name: 'Inactive', + aggregator: 'max' as const, + defaultUnitPriceNet: 2, + isActive: false, + createdAt: '', + updatedAt: '', + }; + const state = { + meters: { + ...initialMetersState, + entities: [activeMeter, inactiveMeter], + selectedMeter: activeMeter, + loading: true, + loadingMeter: false, + creating: false, + updating: false, + deleting: false, + error: 'Load failed', + }, + }; + + it('selects all entities', () => { + expect(selectMetersEntities(state)).toHaveLength(2); + }); + + it('selects active meters only', () => { + expect(selectActiveMeters(state)).toEqual([activeMeter]); + }); + + it('selects selected meter', () => { + expect(selectSelectedMeter(state)).toEqual(activeMeter); + }); + + it('selects loading flags', () => { + expect(selectMetersLoading(state)).toBe(true); + expect(selectMeterLoading(state)).toBe(false); + expect(selectMetersCreating(state)).toBe(false); + expect(selectMetersUpdating(state)).toBe(false); + expect(selectMetersDeleting(state)).toBe(false); + expect(selectMetersLoadingAny(state)).toBe(true); + }); + + it('selects error', () => { + expect(selectMetersError(state)).toBe('Load failed'); + }); + + it('selects meter by id', () => { + expect(selectMeterById('2')(state)).toEqual(inactiveMeter); + expect(selectMeterById('missing')(state)).toBeUndefined(); + }); +}); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.selectors.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.selectors.ts new file mode 100644 index 000000000..9f841b250 --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/meters/meters.selectors.ts @@ -0,0 +1,22 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; + +import type { MetersState } from './meters.reducer'; + +export const selectMetersState = createFeatureSelector('meters'); +export const selectMetersEntities = createSelector(selectMetersState, (state) => state.entities); +export const selectActiveMeters = createSelector(selectMetersEntities, (entities) => + entities.filter((meter) => meter.isActive), +); +export const selectSelectedMeter = createSelector(selectMetersState, (state) => state.selectedMeter); +export const selectMetersLoading = createSelector(selectMetersState, (state) => state.loading); +export const selectMeterLoading = createSelector(selectMetersState, (state) => state.loadingMeter); +export const selectMetersCreating = createSelector(selectMetersState, (state) => state.creating); +export const selectMetersUpdating = createSelector(selectMetersState, (state) => state.updating); +export const selectMetersDeleting = createSelector(selectMetersState, (state) => state.deleting); +export const selectMetersError = createSelector(selectMetersState, (state) => state.error); +export const selectMetersLoadingAny = createSelector( + selectMetersState, + (state) => state.loading || state.loadingMeter || state.creating || state.updating || state.deleting, +); +export const selectMeterById = (id: string) => + createSelector(selectMetersEntities, (entities) => entities.find((meter) => meter.id === id)); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.actions.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.actions.ts new file mode 100644 index 000000000..0eb939d41 --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.actions.ts @@ -0,0 +1,72 @@ +import { createAction, props } from '@ngrx/store'; + +import type { + CreateUsageMeterEntryDto, + SubscriptionMeterSummary, + UpdateUsageMeterEntryDto, + UsageMeterEntryResponse, +} from '../../types/billing.types'; + +export const loadSubscriptionMeters = createAction( + '[SubscriptionMeters] Load Summaries', + props<{ subscriptionId: string }>(), +); +export const loadSubscriptionMetersSuccess = createAction( + '[SubscriptionMeters] Load Summaries Success', + props<{ subscriptionId: string; summaries: SubscriptionMeterSummary[] }>(), +); +export const loadSubscriptionMetersFailure = createAction( + '[SubscriptionMeters] Load Summaries Failure', + props<{ error: string }>(), +); + +export const loadMeterEntries = createAction('[SubscriptionMeters] Load Entries', props<{ subscriptionId: string }>()); +export const loadMeterEntriesSuccess = createAction( + '[SubscriptionMeters] Load Entries Success', + props<{ subscriptionId: string; entries: UsageMeterEntryResponse[] }>(), +); +export const loadMeterEntriesFailure = createAction( + '[SubscriptionMeters] Load Entries Failure', + props<{ error: string }>(), +); + +export const createMeterEntry = createAction( + '[SubscriptionMeters] Create Entry', + props<{ subscriptionId: string; entry: CreateUsageMeterEntryDto }>(), +); +export const createMeterEntrySuccess = createAction( + '[SubscriptionMeters] Create Entry Success', + props<{ subscriptionId: string; entry: UsageMeterEntryResponse }>(), +); +export const createMeterEntryFailure = createAction( + '[SubscriptionMeters] Create Entry Failure', + props<{ error: string }>(), +); + +export const updateMeterEntry = createAction( + '[SubscriptionMeters] Update Entry', + props<{ subscriptionId: string; entryId: string; entry: UpdateUsageMeterEntryDto }>(), +); +export const updateMeterEntrySuccess = createAction( + '[SubscriptionMeters] Update Entry Success', + props<{ subscriptionId: string; entry: UsageMeterEntryResponse }>(), +); +export const updateMeterEntryFailure = createAction( + '[SubscriptionMeters] Update Entry Failure', + props<{ error: string }>(), +); + +export const deleteMeterEntry = createAction( + '[SubscriptionMeters] Delete Entry', + props<{ subscriptionId: string; entryId: string }>(), +); +export const deleteMeterEntrySuccess = createAction( + '[SubscriptionMeters] Delete Entry Success', + props<{ subscriptionId: string; entryId: string }>(), +); +export const deleteMeterEntryFailure = createAction( + '[SubscriptionMeters] Delete Entry Failure', + props<{ error: string }>(), +); + +export const clearSubscriptionMeters = createAction('[SubscriptionMeters] Clear'); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.effects.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.effects.spec.ts new file mode 100644 index 000000000..d0d171cde --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.effects.spec.ts @@ -0,0 +1,202 @@ +import { TestBed } from '@angular/core/testing'; +import { Actions } from '@ngrx/effects'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { of, throwError } from 'rxjs'; + +import { AdminBillingService } from '../../services/admin-billing.service'; +import { UsageService } from '../../services/usage.service'; +import type { SubscriptionMeterSummary, UsageMeterEntryResponse } from '../../types/billing.types'; + +import { + createMeterEntry, + createMeterEntryFailure, + createMeterEntrySuccess, + deleteMeterEntry, + deleteMeterEntryFailure, + deleteMeterEntrySuccess, + loadMeterEntries, + loadMeterEntriesFailure, + loadMeterEntriesSuccess, + loadSubscriptionMeters, + loadSubscriptionMetersFailure, + loadSubscriptionMetersSuccess, + updateMeterEntry, + updateMeterEntryFailure, + updateMeterEntrySuccess, +} from './subscription-meters.actions'; +import { + createMeterEntry$, + deleteMeterEntry$, + loadMeterEntries$, + loadSubscriptionMeters$, + updateMeterEntry$, +} from './subscription-meters.effects'; + +describe('subscriptionMetersEffects', () => { + let actions$: Actions; + let usageService: jest.Mocked; + let adminBillingService: jest.Mocked; + const summary: SubscriptionMeterSummary = { + meterId: 'meter-1', + key: 'api_calls', + name: 'API Calls', + aggregator: 'max', + attachmentType: 'plan', + effectiveUnitPriceNet: 0.01, + aggregatedValue: 100, + estimatedChargeNet: 1, + entryCount: 1, + }; + const entry: UsageMeterEntryResponse = { + id: 'entry-1', + subscriptionId: 'sub-1', + meterId: 'meter-1', + value: 50, + attachmentType: 'plan', + periodStart: '2026-01-01T00:00:00Z', + periodEnd: '2026-01-31T23:59:59Z', + usageSource: 'admin', + usagePayload: {}, + createdAt: '2026-01-15T00:00:00Z', + }; + + beforeEach(() => { + usageService = { + getSubscriptionMeters: jest.fn(), + } as never; + adminBillingService = { + listSubscriptionMeterEntries: jest.fn(), + createSubscriptionMeterEntry: jest.fn(), + updateSubscriptionMeterEntry: jest.fn(), + deleteSubscriptionMeterEntry: jest.fn(), + } as never; + + TestBed.configureTestingModule({ + providers: [ + provideMockActions(() => actions$), + { provide: UsageService, useValue: usageService }, + { provide: AdminBillingService, useValue: adminBillingService }, + ], + }); + actions$ = TestBed.inject(Actions); + }); + + it('loads summaries', (done) => { + actions$ = of(loadSubscriptionMeters({ subscriptionId: 'sub-1' })); + usageService.getSubscriptionMeters.mockReturnValue(of([summary])); + + const results: unknown[] = []; + loadSubscriptionMeters$(actions$, usageService).subscribe((result) => { + results.push(result); + if (results.length === 1) { + expect(result).toEqual(loadSubscriptionMetersSuccess({ subscriptionId: 'sub-1', summaries: [summary] })); + done(); + } + }); + }); + + it('maps summary load errors', (done) => { + actions$ = of(loadSubscriptionMeters({ subscriptionId: 'sub-1' })); + usageService.getSubscriptionMeters.mockReturnValue(throwError(() => new Error('Failed'))); + + loadSubscriptionMeters$(actions$, usageService).subscribe((result) => { + expect(result).toEqual(loadSubscriptionMetersFailure({ error: 'Failed' })); + done(); + }); + }); + + it('loads entries', (done) => { + actions$ = of(loadMeterEntries({ subscriptionId: 'sub-1' })); + adminBillingService.listSubscriptionMeterEntries.mockReturnValue(of([entry])); + + loadMeterEntries$(actions$, adminBillingService).subscribe((result) => { + expect(result).toEqual(loadMeterEntriesSuccess({ subscriptionId: 'sub-1', entries: [entry] })); + done(); + }); + }); + + it('maps entry load errors', (done) => { + actions$ = of(loadMeterEntries({ subscriptionId: 'sub-1' })); + adminBillingService.listSubscriptionMeterEntries.mockReturnValue(throwError(() => 'Failed')); + + loadMeterEntries$(actions$, adminBillingService).subscribe((result) => { + expect(result).toEqual(loadMeterEntriesFailure({ error: 'Failed' })); + done(); + }); + }); + + it('creates an entry and reloads summaries', (done) => { + actions$ = of(createMeterEntry({ subscriptionId: 'sub-1', entry: {} as never })); + adminBillingService.createSubscriptionMeterEntry.mockReturnValue(of(entry)); + + const results: unknown[] = []; + createMeterEntry$(actions$, adminBillingService).subscribe((result) => { + results.push(result); + if (results.length === 2) { + expect(results[0]).toEqual(createMeterEntrySuccess({ subscriptionId: 'sub-1', entry })); + expect(results[1]).toEqual(loadSubscriptionMeters({ subscriptionId: 'sub-1' })); + done(); + } + }); + }); + + it('maps create errors', (done) => { + actions$ = of(createMeterEntry({ subscriptionId: 'sub-1', entry: {} as never })); + adminBillingService.createSubscriptionMeterEntry.mockReturnValue(throwError(() => new Error('Create failed'))); + + createMeterEntry$(actions$, adminBillingService).subscribe((result) => { + expect(result).toEqual(createMeterEntryFailure({ error: 'Create failed' })); + done(); + }); + }); + + it('updates an entry and reloads summaries', (done) => { + actions$ = of(updateMeterEntry({ subscriptionId: 'sub-1', entryId: entry.id, entry: { value: 60 } })); + adminBillingService.updateSubscriptionMeterEntry.mockReturnValue(of({ ...entry, value: 60 })); + + const results: unknown[] = []; + updateMeterEntry$(actions$, adminBillingService).subscribe((result) => { + results.push(result); + if (results.length === 2) { + expect(results[0]).toEqual( + updateMeterEntrySuccess({ subscriptionId: 'sub-1', entry: { ...entry, value: 60 } }), + ); + done(); + } + }); + }); + + it('maps update errors', (done) => { + actions$ = of(updateMeterEntry({ subscriptionId: 'sub-1', entryId: entry.id, entry: { value: 60 } })); + adminBillingService.updateSubscriptionMeterEntry.mockReturnValue(throwError(() => new Error('Update failed'))); + + updateMeterEntry$(actions$, adminBillingService).subscribe((result) => { + expect(result).toEqual(updateMeterEntryFailure({ error: 'Update failed' })); + done(); + }); + }); + + it('deletes an entry and reloads summaries', (done) => { + actions$ = of(deleteMeterEntry({ subscriptionId: 'sub-1', entryId: entry.id })); + adminBillingService.deleteSubscriptionMeterEntry.mockReturnValue(of(undefined)); + + const results: unknown[] = []; + deleteMeterEntry$(actions$, adminBillingService).subscribe((result) => { + results.push(result); + if (results.length === 2) { + expect(results[0]).toEqual(deleteMeterEntrySuccess({ subscriptionId: 'sub-1', entryId: entry.id })); + done(); + } + }); + }); + + it('maps delete errors', (done) => { + actions$ = of(deleteMeterEntry({ subscriptionId: 'sub-1', entryId: entry.id })); + adminBillingService.deleteSubscriptionMeterEntry.mockReturnValue(throwError(() => new Error('Delete failed'))); + + deleteMeterEntry$(actions$, adminBillingService).subscribe((result) => { + expect(result).toEqual(deleteMeterEntryFailure({ error: 'Delete failed' })); + done(); + }); + }); +}); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.effects.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.effects.ts new file mode 100644 index 000000000..43a8e378b --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.effects.ts @@ -0,0 +1,111 @@ +import { inject } from '@angular/core'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { catchError, map, mergeMap, of, switchMap } from 'rxjs'; + +import { AdminBillingService } from '../../services/admin-billing.service'; +import { UsageService } from '../../services/usage.service'; + +import { + createMeterEntry, + createMeterEntryFailure, + createMeterEntrySuccess, + deleteMeterEntry, + deleteMeterEntryFailure, + deleteMeterEntrySuccess, + loadMeterEntries, + loadMeterEntriesFailure, + loadMeterEntriesSuccess, + loadSubscriptionMeters, + loadSubscriptionMetersFailure, + loadSubscriptionMetersSuccess, + updateMeterEntry, + updateMeterEntryFailure, + updateMeterEntrySuccess, +} from './subscription-meters.actions'; + +function normalizeError(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + if (error && typeof error === 'object' && 'message' in error) return String(error.message); + + return 'An unexpected error occurred'; +} + +export const loadSubscriptionMeters$ = createEffect( + (actions$ = inject(Actions), usageService = inject(UsageService)) => + actions$.pipe( + ofType(loadSubscriptionMeters), + switchMap(({ subscriptionId }) => + usageService.getSubscriptionMeters(subscriptionId).pipe( + map((summaries) => loadSubscriptionMetersSuccess({ subscriptionId, summaries })), + catchError((error) => of(loadSubscriptionMetersFailure({ error: normalizeError(error) }))), + ), + ), + ), + { functional: true }, +); + +export const loadMeterEntries$ = createEffect( + (actions$ = inject(Actions), adminBillingService = inject(AdminBillingService)) => + actions$.pipe( + ofType(loadMeterEntries), + switchMap(({ subscriptionId }) => + adminBillingService.listSubscriptionMeterEntries(subscriptionId).pipe( + map((entries) => loadMeterEntriesSuccess({ subscriptionId, entries })), + catchError((error) => of(loadMeterEntriesFailure({ error: normalizeError(error) }))), + ), + ), + ), + { functional: true }, +); + +export const createMeterEntry$ = createEffect( + (actions$ = inject(Actions), adminBillingService = inject(AdminBillingService)) => + actions$.pipe( + ofType(createMeterEntry), + switchMap(({ subscriptionId, entry }) => + adminBillingService.createSubscriptionMeterEntry(subscriptionId, entry).pipe( + mergeMap((created) => [ + createMeterEntrySuccess({ subscriptionId, entry: created }), + loadSubscriptionMeters({ subscriptionId }), + ]), + catchError((error) => of(createMeterEntryFailure({ error: normalizeError(error) }))), + ), + ), + ), + { functional: true }, +); + +export const updateMeterEntry$ = createEffect( + (actions$ = inject(Actions), adminBillingService = inject(AdminBillingService)) => + actions$.pipe( + ofType(updateMeterEntry), + switchMap(({ subscriptionId, entryId, entry }) => + adminBillingService.updateSubscriptionMeterEntry(subscriptionId, entryId, entry).pipe( + mergeMap((updated) => [ + updateMeterEntrySuccess({ subscriptionId, entry: updated }), + loadSubscriptionMeters({ subscriptionId }), + ]), + catchError((error) => of(updateMeterEntryFailure({ error: normalizeError(error) }))), + ), + ), + ), + { functional: true }, +); + +export const deleteMeterEntry$ = createEffect( + (actions$ = inject(Actions), adminBillingService = inject(AdminBillingService)) => + actions$.pipe( + ofType(deleteMeterEntry), + switchMap(({ subscriptionId, entryId }) => + adminBillingService.deleteSubscriptionMeterEntry(subscriptionId, entryId).pipe( + mergeMap(() => [ + deleteMeterEntrySuccess({ subscriptionId, entryId }), + loadSubscriptionMeters({ subscriptionId }), + ]), + catchError((error) => of(deleteMeterEntryFailure({ error: normalizeError(error) }))), + ), + ), + ), + { functional: true }, +); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.facade.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.facade.spec.ts new file mode 100644 index 000000000..5a8ac976f --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.facade.spec.ts @@ -0,0 +1,62 @@ +import { TestBed } from '@angular/core/testing'; +import { Store } from '@ngrx/store'; +import { of } from 'rxjs'; + +import type { CreateUsageMeterEntryDto, UpdateUsageMeterEntryDto } from '../../types/billing.types'; + +import { + clearSubscriptionMeters, + createMeterEntry, + deleteMeterEntry, + loadMeterEntries, + loadSubscriptionMeters, + updateMeterEntry, +} from './subscription-meters.actions'; +import { SubscriptionMetersFacade } from './subscription-meters.facade'; + +describe('SubscriptionMetersFacade', () => { + let facade: SubscriptionMetersFacade; + let store: jest.Mocked; + + beforeEach(() => { + store = { select: jest.fn(), dispatch: jest.fn() } as never; + + TestBed.configureTestingModule({ + providers: [SubscriptionMetersFacade, { provide: Store, useValue: store }], + }); + + facade = TestBed.inject(SubscriptionMetersFacade); + store.select.mockReturnValue(of([])); + }); + + it('dispatches load actions', () => { + facade.loadSummaries('sub-1'); + facade.loadEntries('sub-1'); + facade.loadAll('sub-1'); + + expect(store.dispatch).toHaveBeenCalledWith(loadSubscriptionMeters({ subscriptionId: 'sub-1' })); + expect(store.dispatch).toHaveBeenCalledWith(loadMeterEntries({ subscriptionId: 'sub-1' })); + }); + + it('dispatches entry mutations', () => { + const createDto: CreateUsageMeterEntryDto = { + meterId: 'meter-1', + value: 10, + periodStart: '2026-01-01T00:00:00Z', + periodEnd: '2026-01-31T23:59:59Z', + }; + const updateDto: UpdateUsageMeterEntryDto = { value: 20 }; + + facade.createEntry('sub-1', createDto); + facade.updateEntry('sub-1', 'entry-1', updateDto); + facade.deleteEntry('sub-1', 'entry-1'); + facade.clear(); + + expect(store.dispatch).toHaveBeenCalledWith(createMeterEntry({ subscriptionId: 'sub-1', entry: createDto })); + expect(store.dispatch).toHaveBeenCalledWith( + updateMeterEntry({ subscriptionId: 'sub-1', entryId: 'entry-1', entry: updateDto }), + ); + expect(store.dispatch).toHaveBeenCalledWith(deleteMeterEntry({ subscriptionId: 'sub-1', entryId: 'entry-1' })); + expect(store.dispatch).toHaveBeenCalledWith(clearSubscriptionMeters()); + }); +}); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.facade.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.facade.ts new file mode 100644 index 000000000..786e1e255 --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.facade.ts @@ -0,0 +1,86 @@ +import { inject, Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; + +import type { + CreateUsageMeterEntryDto, + SubscriptionMeterSummary, + UpdateUsageMeterEntryDto, + UsageMeterEntryResponse, +} from '../../types/billing.types'; + +import { + clearSubscriptionMeters, + createMeterEntry, + deleteMeterEntry, + loadMeterEntries, + loadSubscriptionMeters, + updateMeterEntry, +} from './subscription-meters.actions'; +import { + selectSubscriptionMeterEntries, + selectSubscriptionMeterSummaries, + selectSubscriptionMetersCreating, + selectSubscriptionMetersDeleting, + selectSubscriptionMetersError, + selectSubscriptionMetersLoadingAny, + selectSubscriptionMetersLoadingEntries, + selectSubscriptionMetersLoadingSummaries, + selectSubscriptionMetersSubscriptionId, + selectSubscriptionMetersUpdating, +} from './subscription-meters.selectors'; + +@Injectable({ + providedIn: 'root', +}) +export class SubscriptionMetersFacade { + private readonly store = inject(Store); + + readonly summaries$ = this.store.select(selectSubscriptionMeterSummaries); + readonly entries$ = this.store.select(selectSubscriptionMeterEntries); + readonly subscriptionId$ = this.store.select(selectSubscriptionMetersSubscriptionId); + readonly loadingSummaries$ = this.store.select(selectSubscriptionMetersLoadingSummaries); + readonly loadingEntries$ = this.store.select(selectSubscriptionMetersLoadingEntries); + readonly creating$ = this.store.select(selectSubscriptionMetersCreating); + readonly updating$ = this.store.select(selectSubscriptionMetersUpdating); + readonly deleting$ = this.store.select(selectSubscriptionMetersDeleting); + readonly error$ = this.store.select(selectSubscriptionMetersError); + readonly loadingAny$ = this.store.select(selectSubscriptionMetersLoadingAny); + + getSummaries$(): Observable { + return this.summaries$; + } + + getEntries$(): Observable { + return this.entries$; + } + + loadSummaries(subscriptionId: string): void { + this.store.dispatch(loadSubscriptionMeters({ subscriptionId })); + } + + loadEntries(subscriptionId: string): void { + this.store.dispatch(loadMeterEntries({ subscriptionId })); + } + + loadAll(subscriptionId: string): void { + this.loadSummaries(subscriptionId); + this.loadEntries(subscriptionId); + } + + createEntry(subscriptionId: string, entry: CreateUsageMeterEntryDto): void { + this.store.dispatch(createMeterEntry({ subscriptionId, entry })); + } + + updateEntry(subscriptionId: string, entryId: string, entry: UpdateUsageMeterEntryDto): void { + this.store.dispatch(updateMeterEntry({ subscriptionId, entryId, entry })); + } + + deleteEntry(subscriptionId: string, entryId: string): void { + this.store.dispatch(deleteMeterEntry({ subscriptionId, entryId })); + } + + clear(): void { + this.store.dispatch(clearSubscriptionMeters()); + } +} diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.reducer.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.reducer.spec.ts new file mode 100644 index 000000000..08d52762e --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.reducer.spec.ts @@ -0,0 +1,128 @@ +import type { SubscriptionMeterSummary, UsageMeterEntryResponse } from '../../types/billing.types'; + +import { + clearSubscriptionMeters, + createMeterEntry, + createMeterEntryFailure, + createMeterEntrySuccess, + deleteMeterEntry, + deleteMeterEntryFailure, + deleteMeterEntrySuccess, + loadMeterEntries, + loadMeterEntriesFailure, + loadMeterEntriesSuccess, + loadSubscriptionMeters, + loadSubscriptionMetersFailure, + loadSubscriptionMetersSuccess, + updateMeterEntry, + updateMeterEntryFailure, + updateMeterEntrySuccess, +} from './subscription-meters.actions'; +import { initialSubscriptionMetersState, subscriptionMetersReducer } from './subscription-meters.reducer'; + +describe('subscriptionMetersReducer', () => { + const summary: SubscriptionMeterSummary = { + meterId: 'meter-1', + key: 'api_calls', + name: 'API Calls', + aggregator: 'max', + attachmentType: 'plan', + effectiveUnitPriceNet: 0.01, + aggregatedValue: 100, + estimatedChargeNet: 1, + entryCount: 2, + }; + const entry: UsageMeterEntryResponse = { + id: 'entry-1', + subscriptionId: 'sub-1', + meterId: 'meter-1', + value: 50, + attachmentType: 'plan', + periodStart: '2026-01-01T00:00:00Z', + periodEnd: '2026-01-31T23:59:59Z', + usageSource: 'admin', + usagePayload: {}, + createdAt: '2026-01-15T00:00:00Z', + }; + + it('returns the initial state', () => { + expect(subscriptionMetersReducer(undefined, { type: 'UNKNOWN' } as never)).toEqual(initialSubscriptionMetersState); + }); + + it('loads summaries and entries', () => { + const loadingSummaries = subscriptionMetersReducer( + initialSubscriptionMetersState, + loadSubscriptionMeters({ subscriptionId: 'sub-1' }), + ); + const loadedSummaries = subscriptionMetersReducer( + loadingSummaries, + loadSubscriptionMetersSuccess({ subscriptionId: 'sub-1', summaries: [summary] }), + ); + const loadingEntries = subscriptionMetersReducer(loadedSummaries, loadMeterEntries({ subscriptionId: 'sub-1' })); + const loadedEntries = subscriptionMetersReducer( + loadingEntries, + loadMeterEntriesSuccess({ subscriptionId: 'sub-1', entries: [entry] }), + ); + + expect(loadingSummaries.loadingSummaries).toBe(true); + expect(loadedSummaries.summaries).toEqual([summary]); + expect(loadedEntries.entries).toEqual([entry]); + }); + + it('stores failures', () => { + const summaryFailed = subscriptionMetersReducer( + { ...initialSubscriptionMetersState, loadingSummaries: true }, + loadSubscriptionMetersFailure({ error: 'Summary failed' }), + ); + const entriesFailed = subscriptionMetersReducer( + { ...initialSubscriptionMetersState, loadingEntries: true }, + loadMeterEntriesFailure({ error: 'Entries failed' }), + ); + + expect(summaryFailed.error).toBe('Summary failed'); + expect(entriesFailed.error).toBe('Entries failed'); + }); + + it('creates, updates, and deletes entries', () => { + const withEntry = subscriptionMetersReducer( + initialSubscriptionMetersState, + loadMeterEntriesSuccess({ subscriptionId: 'sub-1', entries: [entry] }), + ); + const creating = subscriptionMetersReducer( + withEntry, + createMeterEntry({ subscriptionId: 'sub-1', entry: {} as never }), + ); + const created = subscriptionMetersReducer( + creating, + createMeterEntrySuccess({ subscriptionId: 'sub-1', entry: { ...entry, id: 'entry-2' } }), + ); + const updating = subscriptionMetersReducer( + created, + updateMeterEntry({ subscriptionId: 'sub-1', entryId: entry.id, entry: { value: 60 } }), + ); + const updated = subscriptionMetersReducer( + updating, + updateMeterEntrySuccess({ subscriptionId: 'sub-1', entry: { ...entry, value: 60 } }), + ); + const deleting = subscriptionMetersReducer( + updated, + deleteMeterEntry({ subscriptionId: 'sub-1', entryId: entry.id }), + ); + const deleted = subscriptionMetersReducer( + deleting, + deleteMeterEntrySuccess({ subscriptionId: 'sub-1', entryId: entry.id }), + ); + const createFailed = subscriptionMetersReducer(creating, createMeterEntryFailure({ error: 'Create failed' })); + const updateFailed = subscriptionMetersReducer(updating, updateMeterEntryFailure({ error: 'Update failed' })); + const deleteFailed = subscriptionMetersReducer(deleting, deleteMeterEntryFailure({ error: 'Delete failed' })); + const cleared = subscriptionMetersReducer(updated, clearSubscriptionMeters()); + + expect(created.entries).toHaveLength(2); + expect(updated.entries[0].value).toBe(60); + expect(deleted.entries).toHaveLength(1); + expect(createFailed.creating).toBe(false); + expect(updateFailed.updating).toBe(false); + expect(deleteFailed.deleting).toBe(false); + expect(cleared).toEqual(initialSubscriptionMetersState); + }); +}); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.reducer.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.reducer.ts new file mode 100644 index 000000000..fcf749799 --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.reducer.ts @@ -0,0 +1,111 @@ +import { createReducer, on } from '@ngrx/store'; + +import type { SubscriptionMeterSummary, UsageMeterEntryResponse } from '../../types/billing.types'; + +import { + clearSubscriptionMeters, + createMeterEntry, + createMeterEntryFailure, + createMeterEntrySuccess, + deleteMeterEntry, + deleteMeterEntryFailure, + deleteMeterEntrySuccess, + loadMeterEntries, + loadMeterEntriesFailure, + loadMeterEntriesSuccess, + loadSubscriptionMeters, + loadSubscriptionMetersFailure, + loadSubscriptionMetersSuccess, + updateMeterEntry, + updateMeterEntryFailure, + updateMeterEntrySuccess, +} from './subscription-meters.actions'; + +export interface SubscriptionMetersState { + subscriptionId: string | null; + summaries: SubscriptionMeterSummary[]; + entries: UsageMeterEntryResponse[]; + loadingSummaries: boolean; + loadingEntries: boolean; + creating: boolean; + updating: boolean; + deleting: boolean; + error: string | null; +} + +export const initialSubscriptionMetersState: SubscriptionMetersState = { + subscriptionId: null, + summaries: [], + entries: [], + loadingSummaries: false, + loadingEntries: false, + creating: false, + updating: false, + deleting: false, + error: null, +}; + +export const subscriptionMetersReducer = createReducer( + initialSubscriptionMetersState, + on(loadSubscriptionMeters, (state, { subscriptionId }) => ({ + ...state, + subscriptionId, + loadingSummaries: true, + error: null, + })), + on(loadSubscriptionMetersSuccess, (state, { subscriptionId, summaries }) => ({ + ...state, + subscriptionId, + summaries, + loadingSummaries: false, + error: null, + })), + on(loadSubscriptionMetersFailure, (state, { error }) => ({ + ...state, + loadingSummaries: false, + error, + })), + on(loadMeterEntries, (state, { subscriptionId }) => ({ + ...state, + subscriptionId, + loadingEntries: true, + error: null, + })), + on(loadMeterEntriesSuccess, (state, { subscriptionId, entries }) => ({ + ...state, + subscriptionId, + entries, + loadingEntries: false, + error: null, + })), + on(loadMeterEntriesFailure, (state, { error }) => ({ + ...state, + loadingEntries: false, + error, + })), + on(createMeterEntry, (state) => ({ ...state, creating: true, error: null })), + on(createMeterEntrySuccess, (state, { entry }) => ({ + ...state, + entries: [...state.entries, entry], + creating: false, + error: null, + })), + on(createMeterEntryFailure, (state, { error }) => ({ ...state, creating: false, error })), + on(updateMeterEntry, (state) => ({ ...state, updating: true, error: null })), + on(updateMeterEntrySuccess, (state, { entry }) => ({ + ...state, + entries: state.entries.map((item) => (item.id === entry.id ? entry : item)), + updating: false, + error: null, + })), + on(updateMeterEntryFailure, (state, { error }) => ({ ...state, updating: false, error })), + on(deleteMeterEntry, (state) => ({ ...state, deleting: true, error: null })), + on(deleteMeterEntrySuccess, (state, { entryId }) => ({ + ...state, + entries: state.entries.filter((item) => item.id !== entryId), + deleting: false, + error: null, + })), + on(deleteMeterEntryFailure, (state, { error }) => ({ ...state, deleting: false, error })), + on(clearSubscriptionMeters, () => initialSubscriptionMetersState), +); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.selectors.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.selectors.spec.ts new file mode 100644 index 000000000..15ef36e47 --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.selectors.spec.ts @@ -0,0 +1,43 @@ +import { + selectSubscriptionMeterEntries, + selectSubscriptionMeterSummaries, + selectSubscriptionMetersCreating, + selectSubscriptionMetersDeleting, + selectSubscriptionMetersError, + selectSubscriptionMetersLoadingAny, + selectSubscriptionMetersLoadingEntries, + selectSubscriptionMetersLoadingSummaries, + selectSubscriptionMetersSubscriptionId, + selectSubscriptionMetersUpdating, +} from './subscription-meters.selectors'; +import { initialSubscriptionMetersState } from './subscription-meters.reducer'; + +describe('subscriptionMetersSelectors', () => { + const state = { + subscriptionMeters: { + ...initialSubscriptionMetersState, + subscriptionId: 'sub-1', + summaries: [{ meterId: 'meter-1' } as never], + entries: [{ id: 'entry-1' } as never], + loadingSummaries: true, + loadingEntries: false, + creating: false, + updating: false, + deleting: false, + error: 'Failed', + }, + }; + + it('selects subscription meter data', () => { + expect(selectSubscriptionMetersSubscriptionId(state)).toBe('sub-1'); + expect(selectSubscriptionMeterSummaries(state)).toHaveLength(1); + expect(selectSubscriptionMeterEntries(state)).toHaveLength(1); + expect(selectSubscriptionMetersLoadingSummaries(state)).toBe(true); + expect(selectSubscriptionMetersLoadingEntries(state)).toBe(false); + expect(selectSubscriptionMetersCreating(state)).toBe(false); + expect(selectSubscriptionMetersUpdating(state)).toBe(false); + expect(selectSubscriptionMetersDeleting(state)).toBe(false); + expect(selectSubscriptionMetersError(state)).toBe('Failed'); + expect(selectSubscriptionMetersLoadingAny(state)).toBe(true); + }); +}); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.selectors.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.selectors.ts new file mode 100644 index 000000000..188db83a4 --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/subscription-meters/subscription-meters.selectors.ts @@ -0,0 +1,39 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; + +import type { SubscriptionMetersState } from './subscription-meters.reducer'; + +export const selectSubscriptionMetersState = createFeatureSelector('subscriptionMeters'); +export const selectSubscriptionMeterSummaries = createSelector( + selectSubscriptionMetersState, + (state) => state.summaries, +); +export const selectSubscriptionMeterEntries = createSelector(selectSubscriptionMetersState, (state) => state.entries); +export const selectSubscriptionMetersSubscriptionId = createSelector( + selectSubscriptionMetersState, + (state) => state.subscriptionId, +); +export const selectSubscriptionMetersLoadingSummaries = createSelector( + selectSubscriptionMetersState, + (state) => state.loadingSummaries, +); +export const selectSubscriptionMetersLoadingEntries = createSelector( + selectSubscriptionMetersState, + (state) => state.loadingEntries, +); +export const selectSubscriptionMetersCreating = createSelector( + selectSubscriptionMetersState, + (state) => state.creating, +); +export const selectSubscriptionMetersUpdating = createSelector( + selectSubscriptionMetersState, + (state) => state.updating, +); +export const selectSubscriptionMetersDeleting = createSelector( + selectSubscriptionMetersState, + (state) => state.deleting, +); +export const selectSubscriptionMetersError = createSelector(selectSubscriptionMetersState, (state) => state.error); +export const selectSubscriptionMetersLoadingAny = createSelector( + selectSubscriptionMetersState, + (state) => state.loadingSummaries || state.loadingEntries || state.creating || state.updating || state.deleting, +); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/billing.types.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/billing.types.ts index 6f0c6284e..c6cec29d9 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/billing.types.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/billing.types.ts @@ -24,12 +24,35 @@ export interface ProviderEnvDefaultField { type: 'string'; } +export type MeterAggregator = 'max' | 'min' | 'avg' | 'first' | 'last' | 'sum' | 'sum_positive_deltas'; + export interface ProviderDetail { id: string; displayName: string; configSchema?: Record; envDefaultFields?: ProviderEnvDefaultField[]; supportsAddons?: boolean; + supportsServerTypeUpgrade?: boolean; + supportsServerTypeDowngrade?: boolean; + /** Required meters declared by the provider implementation. */ + meters?: DeclaredMeterDefinition[]; +} + +export interface DeclaredMeterDefinition { + key: string; + name: string; + description?: string; + unitLabel?: string; + aggregator: MeterAggregator; + defaultUnitPriceNet: number; + /** When set, the meter-collect job pulls on this interval (ms). */ + collectionIntervalMs?: number; +} + +export interface AddonModuleDetail { + key: string; + displayName: string; + meters: DeclaredMeterDefinition[]; } // Provider server type with specs and pricing (GET .../providers/:id/server-types) @@ -250,10 +273,123 @@ export interface AddonResponse { priceIntervalType?: BillingIntervalType | null; priceIntervalValue?: number | null; isActive: boolean; + meters?: AttachedMeterResponse[]; + createdAt: string; + updatedAt: string; +} + +// Usage meters +export type UsageAttachmentType = 'plan' | 'addon'; + +export interface MeterResponse { + id: string; + key: string; + name: string; + description?: string | null; + unitLabel?: string | null; + aggregator: MeterAggregator; + defaultUnitPriceNet: number; + isActive: boolean; createdAt: string; updatedAt: string; } +export interface CreateMeterDto { + key: string; + name: string; + description?: string; + unitLabel?: string; + aggregator: MeterAggregator; + defaultUnitPriceNet: number; + isActive?: boolean; +} + +export interface UpdateMeterDto { + name?: string; + description?: string | null; + unitLabel?: string | null; + aggregator?: MeterAggregator; + defaultUnitPriceNet?: number; + isActive?: boolean; +} + +export interface AttachedMeterResponse { + meterId: string; + key: string; + name: string; + description?: string | null; + unitLabel?: string | null; + aggregator: MeterAggregator; + defaultUnitPriceNet: number; + unitPriceNetOverride?: number | null; + effectiveUnitPriceNet: number; + isActive: boolean; + source?: 'manual' | 'module' | 'provider'; + required?: boolean; + /** True when listed on a plan but sourced from the plan's service type. */ + inherited?: boolean; +} + +export interface AttachMeterDto { + meterId: string; + unitPriceNet?: number | null; +} + +export interface UpdateAttachedMeterDto { + unitPriceNet?: number | null; +} + +export interface SubscriptionMeterSummary { + meterId: string; + key: string; + name: string; + unitLabel?: string | null; + aggregator: MeterAggregator; + attachmentType: UsageAttachmentType; + addonId?: string | null; + addonName?: string | null; + effectiveUnitPriceNet: number; + aggregatedValue: number; + estimatedChargeNet: number; + entryCount: number; + periodStart?: string | null; + periodEnd?: string | null; +} + +export interface UsageMeterEntryResponse { + id: string; + subscriptionId: string; + meterId: string; + value: number; + attachmentType: UsageAttachmentType; + addonId?: string | null; + periodStart: string; + periodEnd: string; + usageSource: string; + usagePayload: Record; + createdAt: string; +} + +export interface CreateUsageMeterEntryDto { + meterId: string; + value: number; + attachmentType?: UsageAttachmentType; + addonId?: string; + periodStart: string; + periodEnd: string; + usagePayload?: Record; +} + +export interface UpdateUsageMeterEntryDto { + meterId?: string; + value?: number; + attachmentType?: UsageAttachmentType; + addonId?: string | null; + periodStart?: string; + periodEnd?: string; + usagePayload?: Record; +} + export interface PlanAddonOptionDto { id: string; key: string; @@ -266,6 +402,8 @@ export interface PlanAddonOptionDto { periodPrice: number; /** Customer-visible config fields (no secret values). */ orderFields: AddonConfigOrderField[]; + /** Attached usage meters (read-only on customer order). */ + meters?: AttachedMeterResponse[]; } export interface CreateAddonDto { @@ -333,6 +471,7 @@ export interface ServicePlanResponse { taxCategory?: TaxCategory; withdrawalPolicy: WithdrawalPolicy; isActive: boolean; + meters?: AttachedMeterResponse[]; createdAt: string; updatedAt: string; } @@ -406,6 +545,7 @@ export interface SubscriptionResponse { withdrawalEligibility?: WithdrawalEligibility; withdrawalResult?: WithdrawalResult; periodTotalPrice?: number; + meters?: SubscriptionMeterSummary[]; createdAt: string; updatedAt: string; } @@ -796,7 +936,11 @@ export interface CreateUsageRecordDto { subscriptionId: string; periodStart: string; periodEnd: string; - usagePayload: Record; + usagePayload?: Record; + meterId?: string; + value?: number; + attachmentType?: UsageAttachmentType; + addonId?: string; } export interface UsageRecordResponse { @@ -806,6 +950,10 @@ export interface UsageRecordResponse { periodEnd: string; usageSource: string; usagePayload: Record; + meterId?: string; + value?: number; + attachmentType?: UsageAttachmentType; + addonId?: string | null; createdAt: string; } diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/index.ts b/libs/domains/decabill/frontend/feature-billing-console/src/index.ts index 0c5db3619..01c005f7d 100644 --- a/libs/domains/decabill/frontend/feature-billing-console/src/index.ts +++ b/libs/domains/decabill/frontend/feature-billing-console/src/index.ts @@ -1,2 +1,3 @@ export * from './lib/billing-console.routes'; export * from './lib/admin-billing-page/admin-billing-page.component'; +export * from './lib/meters-page/meters-page.component'; diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/addons-page/addons-page.component.html b/libs/domains/decabill/frontend/feature-billing-console/src/lib/addons-page/addons-page.component.html index 12863bae1..cbe6b2358 100644 --- a/libs/domains/decabill/frontend/feature-billing-console/src/lib/addons-page/addons-page.component.html +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/addons-page/addons-page.component.html @@ -79,6 +79,12 @@
Addons
>{{ configFieldCount(addon) }} config fields + @if (addonMeterCount(addon) > 0) { + + {{ addonMeterCount(addon) }} + meters + + } @@ -538,6 +544,189 @@
Module defaults
[disabled]="disabled || !form.basePrice.trim()" /> +
+
+
Usage meters
+

+ Attach usage meters to this addon with optional unit price overrides. +

+ @if (prefix === 'edit' && addonMeterAttachError) { +
{{ addonMeterAttachError }}
+ } + @if (prefix === 'edit' && addonMeterAttachLoading && attachedMetersFor('edit').length === 0) { +
Loading meters…
+ } + @for (attached of attachedMetersFor(prefix); track attached.meterId) { +
+
+
{{ attached.name }}
+
+ {{ attached.key }} · {{ attached.aggregator }} · €{{ attached.effectiveUnitPriceNet }} + @if (attached.unitLabel) { + / {{ attached.unitLabel }} + } +
+
+
+ + +
+
+ + @if (!attached.required) { + + } @else { + Required + } +
+
+ } @empty { + @if (prefix === 'create' || !addonMeterAttachLoading) { +

+ No meters attached to this addon yet. +

+ } + } + @if (activeMeters$ | async; as meters) { + @if (availableMetersForAddonAttach(meters, prefix); as availableMeters) { + @if (availableMeters.length > 0) { +
+
+ + @if (prefix === 'create') { + + } @else { + + } +
+
+ + @if (prefix === 'create') { + + } @else { + + } +
+
+ +
+
+ } @else if (meters.length === 0) { +

+ No active meters in the catalog. Create meters under Administration → Usage meters first. +

+ } @else { +

+ All active catalog meters are already attached to this addon. +

+ } + } + } @else { +

Loading meter catalog…

+ } +
+
providers.filter((provider) => provider.supportsAddons === true))); + readonly activeMeters$ = this.metersFacade.getActiveMeters$(); readonly loading$ = this.facade.getAddonsLoading$(); readonly loadingAny$ = this.facade.getAddonsLoadingAny$(); readonly creating$ = this.facade.getAddonsCreating$(); @@ -98,19 +104,33 @@ export class AddonsPageComponent implements OnInit { createForm = this.defaultForm(); editForm: AddonForm & { id: string } = { ...this.defaultForm(), id: '' }; addonToDelete: AddonResponse | null = null; + createAttachedMeters: AttachedMeterResponse[] = []; + editAttachedMeters: AttachedMeterResponse[] = []; + createAddonMeterAttachMeterId = ''; + createAddonMeterAttachUnitPrice: string | number | null = ''; + editAddonMeterAttachMeterId = ''; + editAddonMeterAttachUnitPrice: string | number | null = ''; + addonMeterAttachLoading = false; + addonMeterAttachError: string | null = null; ngOnInit(): void { this.facade.loadAddons(); + this.metersFacade.loadMeters(); this.serviceTypesFacade.loadProviderDetails(); this.registerModalCloseWatchers(); } openCreateModal(): void { this.createForm = this.defaultForm(); + this.metersFacade.loadMeters(); + this.createAttachedMeters = []; + this.resetAddonMeterAttachForm('create'); + this.addonMeterAttachError = null; this.showModalWithMonacoLayout(this.createModal); } openEditModal(addon: AddonResponse): void { + this.metersFacade.loadMeters(); this.addonsService .getAddon(addon.id) .pipe(take(1)) @@ -154,10 +174,257 @@ export class AddonsPageComponent implements OnInit { ), }; showBillingModal(this.editModal); + this.resetAddonMeterAttachForm('edit'); + this.addonMeterAttachError = null; + this.editAttachedMeters = detail.meters ?? []; + this.loadAddonAttachedMeters(detail.id); this.scheduleMonacoLayout(this.editModal); }); } + attachedMetersFor(mode: string): AttachedMeterResponse[] { + return mode === 'create' ? this.createAttachedMeters : this.editAttachedMeters; + } + + availableMetersForAddonAttach(meters: MeterResponse[] | null, mode: string): MeterResponse[] { + const attachedIds = new Set(this.attachedMetersFor(mode).map((item) => item.meterId)); + + return (meters ?? []).filter((meter) => !attachedIds.has(meter.id)); + } + + loadAddonAttachedMeters(addonId: string): void { + this.addonMeterAttachLoading = true; + this.addonMeterAttachError = null; + this.addonsService + .listAddonMeters(addonId) + .pipe(take(1)) + .subscribe({ + next: (meters) => { + this.editAttachedMeters = meters; + this.addonMeterAttachLoading = false; + }, + error: (error: unknown) => { + this.addonMeterAttachError = this.formatMeterHttpError(error, 'Failed to load addon meters'); + this.addonMeterAttachLoading = false; + }, + }); + } + + resetAddonMeterAttachForm(mode: string): void { + if (mode === 'create') { + this.createAddonMeterAttachMeterId = ''; + this.createAddonMeterAttachUnitPrice = ''; + return; + } + + this.editAddonMeterAttachMeterId = ''; + this.editAddonMeterAttachUnitPrice = ''; + } + + attachAddonMeter(mode: string): void { + const meterId = mode === 'create' ? this.createAddonMeterAttachMeterId : this.editAddonMeterAttachMeterId; + const unitPriceRaw = optionalNumberInputValue( + mode === 'create' ? this.createAddonMeterAttachUnitPrice : this.editAddonMeterAttachUnitPrice, + ); + + if (!meterId) return; + + if (mode === 'create') { + this.addonMeterAttachError = null; + this.activeMeters$.pipe(take(1)).subscribe((meters) => { + const meter = meters.find((item) => item.id === meterId); + + if (!meter) { + this.addonMeterAttachError = 'Selected meter is not available'; + return; + } + + const override = unitPriceRaw ? Number(unitPriceRaw) : null; + + this.createAttachedMeters = [ + ...this.createAttachedMeters, + this.toPendingAttachedMeter(meter, Number.isFinite(override as number) ? override : null), + ]; + this.resetAddonMeterAttachForm('create'); + }); + + return; + } + + if (!this.editForm.id) return; + + this.addonMeterAttachLoading = true; + this.addonMeterAttachError = null; + this.addonsService + .attachAddonMeter(this.editForm.id, { + meterId, + unitPriceNet: unitPriceRaw ? Number(unitPriceRaw) : undefined, + }) + .pipe(take(1)) + .subscribe({ + next: () => { + this.resetAddonMeterAttachForm('edit'); + this.loadAddonAttachedMeters(this.editForm.id); + this.facade.loadAddons(); + }, + error: (error: unknown) => { + this.addonMeterAttachError = this.formatMeterHttpError(error, 'Failed to attach meter'); + this.addonMeterAttachLoading = false; + }, + }); + } + + updateAttachedAddonMeter(mode: string, meter: AttachedMeterResponse, unitPriceInput: string): void { + const trimmed = unitPriceInput.trim(); + const override = trimmed ? Number(trimmed) : null; + + if (mode === 'create') { + this.createAttachedMeters = this.createAttachedMeters.map((item) => + item.meterId === meter.meterId + ? { + ...item, + unitPriceNetOverride: Number.isFinite(override as number) ? override : null, + effectiveUnitPriceNet: + Number.isFinite(override as number) && override != null ? override : item.defaultUnitPriceNet, + } + : item, + ); + + return; + } + + if (!this.editForm.id) return; + + this.addonMeterAttachLoading = true; + this.addonMeterAttachError = null; + this.addonsService + .updateAddonMeter(this.editForm.id, meter.meterId, { + unitPriceNet: trimmed ? Number(trimmed) : null, + }) + .pipe(take(1)) + .subscribe({ + next: () => { + this.loadAddonAttachedMeters(this.editForm.id); + this.facade.loadAddons(); + }, + error: (error: unknown) => { + this.addonMeterAttachError = this.formatMeterHttpError(error, 'Failed to update meter price'); + this.addonMeterAttachLoading = false; + }, + }); + } + + detachAddonMeter(mode: string, meterId: string): void { + if (mode === 'create') { + this.createAttachedMeters = this.createAttachedMeters.filter((item) => item.meterId !== meterId); + + return; + } + + if (!this.editForm.id) return; + + this.addonMeterAttachLoading = true; + this.addonMeterAttachError = null; + this.addonsService + .detachAddonMeter(this.editForm.id, meterId) + .pipe(take(1)) + .subscribe({ + next: () => { + this.loadAddonAttachedMeters(this.editForm.id); + this.facade.loadAddons(); + }, + error: (error: unknown) => { + this.addonMeterAttachError = this.formatMeterHttpError(error, 'Failed to detach meter'); + this.addonMeterAttachLoading = false; + }, + }); + } + + attachedMeterOverrideInput(meter: AttachedMeterResponse): string { + return meter.unitPriceNetOverride != null ? String(meter.unitPriceNetOverride) : ''; + } + + addonMeterCount(addon: AddonResponse): number { + return addon.meters?.length ?? 0; + } + + private toPendingAttachedMeter(meter: MeterResponse, unitPriceNetOverride: number | null): AttachedMeterResponse { + return { + meterId: meter.id, + key: meter.key, + name: meter.name, + description: meter.description ?? null, + unitLabel: meter.unitLabel ?? null, + aggregator: meter.aggregator, + defaultUnitPriceNet: meter.defaultUnitPriceNet, + unitPriceNetOverride, + effectiveUnitPriceNet: unitPriceNetOverride != null ? unitPriceNetOverride : meter.defaultUnitPriceNet, + isActive: meter.isActive, + source: 'manual', + required: false, + }; + } + + private flushPendingCreateAddonMeters(pendingMeters?: AttachedMeterResponse[]): void { + const pending = pendingMeters ?? [...this.createAttachedMeters]; + + if (pending.length === 0) { + return; + } + + this.facade + .getSelectedAddon$() + .pipe( + take(1), + switchMap((addon) => { + if (!addon) { + return of(null); + } + + return forkJoin( + pending.map((meter) => + this.addonsService + .attachAddonMeter(addon.id, { + meterId: meter.meterId, + unitPriceNet: meter.unitPriceNetOverride ?? undefined, + }) + .pipe(catchError(() => of(null))), + ), + ); + }), + take(1), + ) + .subscribe(() => this.facade.loadAddons()); + } + + private formatMeterHttpError(error: unknown, fallback: string): string { + if (error && typeof error === 'object' && 'error' in error) { + const body = (error as { error?: unknown }).error; + + if (typeof body === 'string' && body.trim()) { + return body.trim(); + } + + if (body && typeof body === 'object' && 'message' in body) { + const message = (body as { message?: unknown }).message; + + if (typeof message === 'string' && message.trim()) { + return message.trim(); + } + + if (Array.isArray(message) && message.length > 0) { + return message.map(String).join(', '); + } + } + } + + if (error instanceof Error && error.message.trim()) { + return error.message.trim(); + } + + return fallback; + } + openDeleteConfirm(addon: AddonResponse): void { this.addonToDelete = addon; showBillingModal(this.deleteConfirmModal); @@ -471,7 +738,12 @@ export class AddonsPageComponent implements OnInit { modal: () => this.createModal, destroyRef: this.destroyRef, onSuccess: () => { + const pendingMeters = [...this.createAttachedMeters]; this.createForm = this.defaultForm(); + this.createAttachedMeters = []; + this.resetAddonMeterAttachForm('create'); + this.addonMeterAttachError = null; + this.flushPendingCreateAddonMeters(pendingMeters); }, }); watchBillingMutationModalClose({ @@ -481,6 +753,9 @@ export class AddonsPageComponent implements OnInit { destroyRef: this.destroyRef, onSuccess: () => { this.editForm = { ...this.defaultForm(), id: '' }; + this.editAttachedMeters = []; + this.resetAddonMeterAttachForm('edit'); + this.addonMeterAttachError = null; }, }); watchBillingMutationModalClose({ diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-subscriptions-page/admin-subscriptions-page.component.html b/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-subscriptions-page/admin-subscriptions-page.component.html index e1e80f801..4c3f91a71 100644 --- a/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-subscriptions-page/admin-subscriptions-page.component.html +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-subscriptions-page/admin-subscriptions-page.component.html @@ -98,6 +98,12 @@
{{ formatPeriodPrice(sub) }} + @if (hasSubscriptionMeters(sub)) { + + Has usage meters + + }
@@ -107,6 +113,15 @@
+ @if (sub.withdrawalEligibility?.canWithdraw) { + + + + + + + + @@ -886,6 +892,16 @@
Customer-selectable addons< } } } + @if (supportsCustomerLocationSelection(data.serviceTypes, data.providerDetails, createForm.serviceTypeId)) {
Customer-selectable addons< } } } + @if ( editingPlan && supportsCustomerLocationSelection(data.serviceTypes, data.providerDetails, editingPlan.serviceTypeId) @@ -1969,3 +1991,190 @@
Provider defaults
} } +
Provider defaults
} } +
Provider defaults
+ +
+
Usage meters
+

+ Provider-declared meters are required. Attach extra catalog meters with optional unit price overrides. +

+ @if (mode === 'edit' && meterAttachError) { +
{{ meterAttachError }}
+ } + @if (mode === 'create') { + @if (providerDetails$ | async; as providers) { + @for (declared of getProviderDeclaredMeters(providerId, providers); track declared.key) { +
+
+
{{ declared.name }}
+
+ {{ declared.key }} · {{ declared.aggregator }} · €{{ declared.defaultUnitPriceNet }} + @if (declared.unitLabel) { + / {{ declared.unitLabel }} + } + · Required +
+
+
+ } + } + } + @if (mode === 'edit' && meterAttachLoading && attachedMetersFor('edit').length === 0) { +
Loading meters…
+ } + @for (attached of attachedMetersFor(mode); track attached.meterId) { +
+
+
{{ attached.name }}
+
+ {{ attached.key }} · {{ attached.aggregator }} · €{{ attached.effectiveUnitPriceNet }} + @if (attached.unitLabel) { + / {{ attached.unitLabel }} + } +
+ @if (attached.required) { +
Required
+ } +
+
+ + +
+
+ + @if (!attached.required) { + + } +
+
+ } @empty { + @if (mode === 'edit' && !meterAttachLoading) { +

+ No meters attached to this service type yet. +

+ } + } + @if (activeMeters$ | async; as meters) { + @if (providerDetails$ | async; as providers) { + @if (availableMetersForAttach(meters, mode, providers); as availableMeters) { + @if (availableMeters.length > 0) { +
+
+ + @if (mode === 'create') { + + } @else { + + } +
+
+ + @if (mode === 'create') { + + } @else { + + } +
+
+ +
+
+ } @else if (meters.length === 0) { +

+ No active meters in the catalog. Create meters under Administration → Usage meters first. +

+ } + } + } + } @else { +

Loading meter catalog…

+ } +
+
+ + }
Order }
+ @if (addon.meters?.length) { +
    + @for (meter of addon.meters!; track meter.meterId) { +
  • + Meter: + {{ meter.name }} · {{ meter.aggregator }} · €{{ meter.effectiveUnitPriceNet + }}{{ meter.unitLabel ? ' / ' + meter.unitLabel : '' }} +
  • + } +
+ } } @@ -516,6 +545,28 @@
Order [innerHTML]="selectedPlan!.description" > } + @if (selectedPlan?.meters?.length) { +
+
+ Plan usage meters +
+

+ Usage for these meters is recorded in arrears and billed separately from the base plan + price. +

+
    + @for (meter of selectedPlan!.meters!; track meter.meterId) { +
  • + {{ meter.name }} + + · {{ meter.aggregator }} · €{{ meter.effectiveUnitPriceNet + }}{{ meter.unitLabel ? ' / ' + meter.unitLabel : '' }} + +
  • + } +
+
+ } @if (selectedPlan?.orderingHighlights?.length) {
+ @for (meter of addon.meters!; track meter.meterId) { +
  • + Meter: + {{ meter.name }} · {{ meter.aggregator }} · €{{ meter.effectiveUnitPriceNet + }}{{ meter.unitLabel ? ' / ' + meter.unitLabel : '' }} +
  • + } + + } @if ( isConfigChangeAddonActive(addon.id) && isConfigChangeAddonSelected(addon.id) && diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/subscriptions/subscriptions.component.ts b/libs/domains/decabill/frontend/feature-billing-console/src/lib/subscriptions/subscriptions.component.ts index 2fe00abb0..ade3a7c29 100644 --- a/libs/domains/decabill/frontend/feature-billing-console/src/lib/subscriptions/subscriptions.component.ts +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/subscriptions/subscriptions.component.ts @@ -41,6 +41,7 @@ import { type ServiceTypeResponse, type ServerType, type SubscriptionResponse, + type SubscriptionMeterSummary, formatBillingProviderLocationLabel, formatServerTypeOption, isNoneServiceTypeId, @@ -623,6 +624,32 @@ export class SubscriptionsComponent implements OnInit, AfterViewInit { return this.formatEntryPeriodPrice(sub.periodTotalPrice, this.getSelectedPlan(plans, sub.planId)); } + subscriptionMeters(sub: SubscriptionResponse): SubscriptionMeterSummary[] { + return sub.meters ?? []; + } + + hasSubscriptionMeters(sub: SubscriptionResponse): boolean { + return this.subscriptionMeters(sub).length > 0; + } + + formatMeterCharge(amount: number): string { + return `${amount.toFixed(2)} EUR`; + } + + formatMeterValue(meter: SubscriptionMeterSummary): string { + const unit = meter.unitLabel ? ` ${meter.unitLabel}` : ''; + + return `${meter.aggregatedValue}${unit}`; + } + + meterAttachmentLabel(meter: SubscriptionMeterSummary): string { + if (meter.attachmentType === 'addon') { + return meter.addonName?.trim() || meter.addonId || $localize`:@@featureSubscriptions-meterAddon:Addon meter`; + } + + return $localize`:@@featureSubscriptions-meterPlan:Plan meter`; + } + formatBackorderPeriodPrice(bo: BackorderResponse, plans: ServicePlanResponse[] | null): string { return this.formatEntryPeriodPrice(bo.periodTotalPrice, this.getSelectedPlan(plans, bo.planId)); }