From efa4efe31703184e583895e27119c44b1b4fe0c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20P=C3=A4rna?= Date: Thu, 10 Sep 2026 11:46:56 +0300 Subject: [PATCH 1/6] fix(getag): tolerate external_fees_metadata without inputs processExternalFeesDetails threw 'Cannot read properties of undefined (reading consumptionHT)' for order line items whose getag fee metadata has no inputs object. Make inputs optional, guard the reads, and add regression tests. --- .changeset/getag-fees-optional-inputs.md | 7 ++ src/variables/getag/details.test.ts | 78 +++++++++++++++++++++ src/variables/getag/network-fees-details.ts | 2 +- src/variables/getag/other-fees-details.ts | 2 +- src/variables/getag/utils.test.ts | 75 ++++++++++++++++++++ src/variables/getag/utils.ts | 8 +-- src/variables/types.ts | 7 +- 7 files changed, 172 insertions(+), 7 deletions(-) create mode 100644 .changeset/getag-fees-optional-inputs.md create mode 100644 src/variables/getag/details.test.ts diff --git a/.changeset/getag-fees-optional-inputs.md b/.changeset/getag-fees-optional-inputs.md new file mode 100644 index 0000000..e263ec7 --- /dev/null +++ b/.changeset/getag-fees-optional-inputs.md @@ -0,0 +1,7 @@ +--- +'@epilot/pricing': patch +--- + +fix(getag): tolerate `external_fees_metadata` without `inputs` + +`processExternalFeesDetails` crashed with `Cannot read properties of undefined (reading 'consumptionHT')` for order line items whose getag fee metadata has no `inputs` object (orders created via the 360 cockpit, the public API, or journeys before the consumption inputs were attached). The `inputs` field is now optional and consumption-based yearly amounts render as `-` when it is absent; the fee type falls back to `power`. diff --git a/src/variables/getag/details.test.ts b/src/variables/getag/details.test.ts new file mode 100644 index 0000000..99941b7 --- /dev/null +++ b/src/variables/getag/details.test.ts @@ -0,0 +1,78 @@ +import type { PriceItem } from '@epilot/sdk/pricing'; +import { describe, expect, it } from 'vitest'; +import type { Currency, I18n } from '../../shared/types'; +import type { ExternalFeesMetadata } from '../types'; +import { processExternalFeesDetails } from './details'; + +const i18n: I18n = { + t: ((key: string) => key) as never, + language: 'en', +}; + +/** + * Regression for a production crash in template-variables (`replaceTemplates`): + * `TypeError: Cannot read properties of undefined (reading 'consumptionHT')`. + * + * `external_fees_metadata.inputs` is only attached by the journey app. Orders created + * through the 360 cockpit, the public API, or journeys before the inputs were introduced + * carry the raw getag compute result without it. + */ +describe('processExternalFeesDetails', () => { + const item = { + _id: 'item-1', + price_id: 'price-1', + quantity: 1, + currency: 'EUR', + pricing_model: 'external_getag', + get_ag: { + category: 'power', + type: 'work_price', + tariff_type: 'HT', + markup_amount: 10, + markup_amount_decimal: '0.10', + markup_amount_gross_decimal: '0.10', + unit_amount_gross: 30, + unit_amount_net: 25, + additional_markups_enabled: true, + additional_markups: { + procurement: { amount: 5, amount_decimal: '0.05', amount_gross_decimal: '0.05' }, + }, + }, + } as unknown as PriceItem; + + const metadataWithoutInputs: ExternalFeesMetadata = { + billing_period: 'monthly', + breakdown: { + static: { basic_fee: { amount: 500, amount_decimal: '5.00' } }, + variable: { concession: { amount: 1, amount_decimal: '0.01', unit_amount: 1, unit_amount_decimal: '0.01' } }, + variable_ht: {}, + }, + } as ExternalFeesMetadata; + + it('does not throw when external_fees_metadata has no inputs', () => { + expect(() => processExternalFeesDetails(item, metadataWithoutInputs, 'EUR' as Currency, i18n, 'kWh')).not.toThrow(); + }); + + it('renders consumption-based yearly amounts as "-" and still builds every fee group', () => { + const result = processExternalFeesDetails(item, metadataWithoutInputs, 'EUR' as Currency, i18n, 'kWh'); + + expect(Object.keys(result.groups ?? {})).toEqual( + expect.arrayContaining(['sales_and_procurement_costs', 'network_operating_fees', 'other_fees']), + ); + + const markups = result.groups?.['sales_and_procurement_costs'].fees as Record< + string, + { amount: string; amount_yearly: string } + >; + + expect(markups.markup_work_price).toMatchObject({ amount: '10.00 cents/kWh', amount_yearly: '-' }); + expect(markups.markup_procurement).toMatchObject({ amount: '5.00 cents/kWh', amount_yearly: '-' }); + }); + + it('falls back to the power fee set when inputs.type is missing', () => { + const result = processExternalFeesDetails(item, metadataWithoutInputs, 'EUR' as Currency, i18n, 'kWh'); + + expect(result.groups?.['other_fees'].fees).toHaveProperty('concession'); + expect(result.groups?.['other_fees'].fees).not.toHaveProperty('gas_tax'); + }); +}); diff --git a/src/variables/getag/network-fees-details.ts b/src/variables/getag/network-fees-details.ts index 13e65e8..8ce3ca9 100644 --- a/src/variables/getag/network-fees-details.ts +++ b/src/variables/getag/network-fees-details.ts @@ -13,7 +13,7 @@ export const processNetworkOperatingFeesDetails = ( tax?: Tax | TaxItem, variableUnit?: string, ) => { - const type = externalFeesMetadata.inputs.type || 'power'; + const type = externalFeesMetadata.inputs?.type || 'power'; if (!result.groups) { result.groups = {}; diff --git a/src/variables/getag/other-fees-details.ts b/src/variables/getag/other-fees-details.ts index 1e74abe..b114023 100644 --- a/src/variables/getag/other-fees-details.ts +++ b/src/variables/getag/other-fees-details.ts @@ -15,7 +15,7 @@ export const processOtherFeesDetails = ( tax?: Tax | TaxItem, variableUnit?: string, ) => { - const type = externalFeesMetadata.inputs.type || 'power'; + const type = externalFeesMetadata.inputs?.type || 'power'; if (!result.groups) { result.groups = {}; diff --git a/src/variables/getag/utils.test.ts b/src/variables/getag/utils.test.ts index 2650ccd..dcabbe7 100644 --- a/src/variables/getag/utils.test.ts +++ b/src/variables/getag/utils.test.ts @@ -451,4 +451,79 @@ describe('getMarkupDetailsFee', () => { }); }); }); + + describe('when externalFeesMetadata.inputs is missing', () => { + // Orders created outside the journey app (360 cockpit, public API, pre-2024 journeys) + // carry getag fee metadata without the client-side `inputs` object. + const externalFeesMetadataWithoutInputs: ExternalFeesMetadata = { + billing_period: mockExternalFeesMetadata.billing_period, + breakdown: mockExternalFeesMetadata.breakdown, + }; + + const priceGetAgConfig: PriceGetAg = { + category: 'power', + markup_amount: 10, + markup_amount_decimal: '0.10', + markup_amount_gross_decimal: '0.10', + unit_amount_gross: 0, + unit_amount_net: 0, + additional_markups_enabled: true, + additional_markups: { + procurement: { + amount: 5, + amount_decimal: '0.05', + amount_gross_decimal: '0.05', + }, + }, + } as PriceGetAg; + + it.each(['HT', 'NT'] as TariffTypeGetAg[])( + 'should not throw and omit the yearly amount for the %s work price markup', + (tariffType) => { + const result = getMarkupDetailsFee({ + ...defaultParams, + priceGetAgConfig, + externalFeesMetadata: externalFeesMetadataWithoutInputs, + options: { type: 'work_price', tariffType }, + }); + + expect(result).toEqual({ + amount: '10.00 cents/kWh', + amount_decimal: '0.10', + amount_yearly_decimal: '0', + amount_yearly: '-', + label: 'Work Price Markup', + }); + }, + ); + + it.each(['HT', 'NT'] as TariffTypeGetAg[])( + 'should not throw and omit the yearly amount for the %s procurement markup', + (tariffType) => { + const result = getMarkupDetailsFee({ + ...defaultParams, + priceGetAgConfig, + externalFeesMetadata: externalFeesMetadataWithoutInputs, + options: { type: 'additional_markup', tariffType, key: 'procurement' }, + }); + + expect(result).toMatchObject({ + amount: '5.00 cents/kWh', + amount_yearly: '-', + }); + }, + ); + + it('should still return the base price markup', () => { + const result = getMarkupDetailsFee({ + ...defaultParams, + priceGetAgConfig, + externalFeesMetadata: externalFeesMetadataWithoutInputs, + options: { type: 'base_price' }, + }); + + expect(result).toBeDefined(); + expect(result?.label).toBe('Base Price Markup'); + }); + }); }); diff --git a/src/variables/getag/utils.ts b/src/variables/getag/utils.ts index dc7b0a8..e982472 100644 --- a/src/variables/getag/utils.ts +++ b/src/variables/getag/utils.ts @@ -198,8 +198,8 @@ const getMarkupDetailsFee = ({ ? getConsumptionBasedAmounts( procurementMarkup?.amount_gross_decimal, options.tariffType === 'HT' - ? externalFeesMetadata.inputs.consumptionHT - : externalFeesMetadata.inputs.consumptionNT, + ? externalFeesMetadata.inputs?.consumptionHT + : externalFeesMetadata.inputs?.consumptionNT, billingPeriod, ).yearlyAmountDecimal : undefined; @@ -266,8 +266,8 @@ const getMarkupDetailsFee = ({ ? getConsumptionBasedAmounts( priceGetAgConfig?.markup_amount_gross_decimal, options.tariffType === 'HT' - ? externalFeesMetadata.inputs.consumptionHT - : externalFeesMetadata.inputs.consumptionNT, + ? externalFeesMetadata.inputs?.consumptionHT + : externalFeesMetadata.inputs?.consumptionNT, billingPeriod, currency, ).yearlyAmountDecimal diff --git a/src/variables/types.ts b/src/variables/types.ts index 445204e..58fe42b 100644 --- a/src/variables/types.ts +++ b/src/variables/types.ts @@ -23,7 +23,12 @@ export type GetTieredUnitAmountOptions = { export type ExternalFeesMetadata = { billing_period: string; - inputs: { + /** + * Consumption inputs the price was computed with. + * Only attached by the journey app; orders created through other channels + * (360 cockpit, public API, pre-2024 journeys) may not carry it. + */ + inputs?: { consumptionHT?: number; consumptionNT?: number; type?: 'power' | 'gas'; From 8d81a139d1199b77dfd8e3f4e43dc3f69a98323a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20P=C3=A4rna?= Date: Thu, 10 Sep 2026 12:42:14 +0300 Subject: [PATCH 2/6] fix(getag): derive power/gas from the fee breakdown when inputs.type is missing Add resolveExternalFeesType(): inputs.type, then commodity-specific breakdown keys, then the price getag category, then power. Verivox journey orders carry no inputs and several attach gas compute results to power prices. --- .changeset/getag-fees-optional-inputs.md | 6 +- src/exports.test.ts | 1 + src/index.ts | 1 + src/variables/getag/details.test.ts | 50 +++++++++- src/variables/getag/details.ts | 4 + src/variables/getag/network-fees-details.ts | 4 +- src/variables/getag/other-fees-details.ts | 4 +- src/variables/getag/resolve-fees-type.test.ts | 95 +++++++++++++++++++ src/variables/getag/resolve-fees-type.ts | 89 +++++++++++++++++ 9 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 src/variables/getag/resolve-fees-type.test.ts create mode 100644 src/variables/getag/resolve-fees-type.ts diff --git a/.changeset/getag-fees-optional-inputs.md b/.changeset/getag-fees-optional-inputs.md index e263ec7..ff20dbc 100644 --- a/.changeset/getag-fees-optional-inputs.md +++ b/.changeset/getag-fees-optional-inputs.md @@ -2,6 +2,8 @@ '@epilot/pricing': patch --- -fix(getag): tolerate `external_fees_metadata` without `inputs` +fix(getag): tolerate `external_fees_metadata` without `inputs` and derive the commodity from the breakdown -`processExternalFeesDetails` crashed with `Cannot read properties of undefined (reading 'consumptionHT')` for order line items whose getag fee metadata has no `inputs` object (orders created via the 360 cockpit, the public API, or journeys before the consumption inputs were attached). The `inputs` field is now optional and consumption-based yearly amounts render as `-` when it is absent; the fee type falls back to `power`. +`processExternalFeesDetails` crashed with `Cannot read properties of undefined (reading 'consumptionHT')` for order line items whose getag fee metadata has no `inputs` object (orders created via the 360 cockpit, the public API, or journey flows that forward the raw compute result). `inputs` is now optional and consumption-based yearly amounts render as `-` when it is absent. + +The commodity used to pick the power vs. gas fee groups is no longer hard-defaulted to `power` when `inputs.type` is missing. New `resolveExternalFeesType()` derives it from the fee keys the pricing API emitted (e.g. `gas_tax`, `gas_storage` vs. `power_tax`, `chp`), then from the price's getag `category`, and only then falls back to `power`. diff --git a/src/exports.test.ts b/src/exports.test.ts index 34e202d..72f9166 100644 --- a/src/exports.test.ts +++ b/src/exports.test.ts @@ -34,6 +34,7 @@ const expectedNamedExports = [ 'processOrderTableData', 'formatFeeAmountFromString', 'extractGetAgConfig', + 'resolveExternalFeesType', 'getAmountWithTax', 'getTaxValue', 'computePriceDiff', diff --git a/src/index.ts b/src/index.ts index 9d2ef9e..b03e78b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,7 @@ export type { export { processOrderTableData } from './variables/process-order-table-data'; export { formatFeeAmountFromString } from './getag/formatters'; export { extractGetAgConfig } from './getag/extract-config'; +export { resolveExternalFeesType, type ExternalFeesType } from './variables/getag/resolve-fees-type'; export { getTaxValue } from './taxes/get-tax-value'; export { getAmountWithTax } from './taxes/get-amount-with-tax'; export { computePriceDiff } from './prices/compute-price-diff'; diff --git a/src/variables/getag/details.test.ts b/src/variables/getag/details.test.ts index 99941b7..7ab9510 100644 --- a/src/variables/getag/details.test.ts +++ b/src/variables/getag/details.test.ts @@ -69,10 +69,54 @@ describe('processExternalFeesDetails', () => { expect(markups.markup_procurement).toMatchObject({ amount: '5.00 cents/kWh', amount_yearly: '-' }); }); - it('falls back to the power fee set when inputs.type is missing', () => { - const result = processExternalFeesDetails(item, metadataWithoutInputs, 'EUR' as Currency, i18n, 'kWh'); + it('uses the power fee set when the breakdown carries power-only keys', () => { + const powerMetadata: ExternalFeesMetadata = { + ...metadataWithoutInputs, + breakdown: { + ...metadataWithoutInputs.breakdown, + variable: { ...metadataWithoutInputs.breakdown.variable, power_tax: { amount: 1, amount_decimal: '0.01' } }, + }, + } as ExternalFeesMetadata; - expect(result.groups?.['other_fees'].fees).toHaveProperty('concession'); + const result = processExternalFeesDetails(item, powerMetadata, 'EUR' as Currency, i18n, 'kWh'); + + expect(result.groups?.['other_fees'].fees).toHaveProperty('power_tax'); expect(result.groups?.['other_fees'].fees).not.toHaveProperty('gas_tax'); }); + + it('uses the gas fee set when inputs is missing but the breakdown is a gas compute result', () => { + // Order OR-2143 (org 16582003): composite "Strom" price with get_ag.category "power", + // but the fee metadata attached at checkout is the gas compute result and has no `inputs`. + const gasFee = { amount: 1, amount_decimal: '0.01', unit_amount: 1, unit_amount_decimal: '0.01' }; + const gasMetadata: ExternalFeesMetadata = { + billing_period: 'monthly', + breakdown: { + static: { + basic_fee: { amount: 67, amount_decimal: '0.67' }, + invoice_fee: { amount: 0, amount_decimal: '0' }, + maintenance_fee: { amount: 67, amount_decimal: '0.67' }, + metering_reading_fee: { amount: 33, amount_decimal: '0.33' }, + }, + variable: { + concession: gasFee, + grid_fee: gasFee, + performance: gasFee, + co2: gasFee, + control_energy: gasFee, + neutrality_charge: gasFee, + gas_tax: gasFee, + gas_storage: gasFee, + gas_conversion_charge: gasFee, + }, + variable_ht: {}, + }, + } as ExternalFeesMetadata; + + const result = processExternalFeesDetails(item, gasMetadata, 'EUR' as Currency, i18n, 'kWh'); + + expect(result.groups?.['other_fees'].fees).toHaveProperty('gas_tax'); + expect(result.groups?.['other_fees'].fees).toHaveProperty('co2'); + expect(result.groups?.['other_fees'].fees).not.toHaveProperty('power_tax'); + expect(result.groups?.['meter_fees'] ?? result.groups?.['network_operating_fees']).toBeDefined(); + }); }); diff --git a/src/variables/getag/details.ts b/src/variables/getag/details.ts index 60397fa..a49ff66 100644 --- a/src/variables/getag/details.ts +++ b/src/variables/getag/details.ts @@ -9,6 +9,7 @@ import { processMarkupsFeesDetails } from './markup-fees-details'; import { processMeterFeesDetails } from './meter-fees-details'; import { processNetworkOperatingFeesDetails } from './network-fees-details'; import { processOtherFeesDetails } from './other-fees-details'; +import { resolveExternalFeesType } from './resolve-fees-type'; export const processExternalFeesDetails = ( item: PriceItem | CompositePriceItem, @@ -22,6 +23,7 @@ export const processExternalFeesDetails = ( const tax = extractTaxFromPriceItem(item); const taxRate = tax?.rate; const formattedUnit = formatPriceUnit(unit, true); + const feesType = resolveExternalFeesType(externalFeesMetadata, item); const result: Partial = { unit_price_period: i18n.t(`table_order.recurrences.billing_period.${unitPricePeriod}`), @@ -53,6 +55,7 @@ export const processExternalFeesDetails = ( unitPricePeriod, tax, formattedUnit, + feesType, ); processMeterFeesDetails( @@ -75,6 +78,7 @@ export const processExternalFeesDetails = ( unitPricePeriod, tax, formattedUnit, + feesType, ); processExternalDisplayFeesDetails(result as ExternalFeesDetails); diff --git a/src/variables/getag/network-fees-details.ts b/src/variables/getag/network-fees-details.ts index 8ce3ca9..a7260c9 100644 --- a/src/variables/getag/network-fees-details.ts +++ b/src/variables/getag/network-fees-details.ts @@ -1,6 +1,7 @@ import type { Currency, I18n, Tax, TaxItem } from '../../shared/types'; import type { TimeFrequency } from '../../time-frequency/types'; import type { ExternalFeesMetadata, ExternalFeesDetails, ExternalFeesDetailsGroup } from '../types'; +import { resolveExternalFeesType, type ExternalFeesType } from './resolve-fees-type'; import { getDetailsFee } from './utils'; export const processNetworkOperatingFeesDetails = ( @@ -12,8 +13,9 @@ export const processNetworkOperatingFeesDetails = ( unitPricePeriod: TimeFrequency, tax?: Tax | TaxItem, variableUnit?: string, + feesType?: ExternalFeesType, ) => { - const type = externalFeesMetadata.inputs?.type || 'power'; + const type = feesType ?? resolveExternalFeesType(externalFeesMetadata); if (!result.groups) { result.groups = {}; diff --git a/src/variables/getag/other-fees-details.ts b/src/variables/getag/other-fees-details.ts index b114023..e943962 100644 --- a/src/variables/getag/other-fees-details.ts +++ b/src/variables/getag/other-fees-details.ts @@ -3,6 +3,7 @@ import type { Tax, TaxItem } from '../../shared/types'; import type { I18n } from '../../shared/types'; import type { TimeFrequency } from '../../time-frequency/types'; import type { ExternalFeesMetadata, ExternalFeesDetails, ExternalFeesDetailsGroup } from '../types'; +import { resolveExternalFeesType, type ExternalFeesType } from './resolve-fees-type'; import { getDetailsFee } from './utils'; export const processOtherFeesDetails = ( @@ -14,8 +15,9 @@ export const processOtherFeesDetails = ( unitPricePeriod: TimeFrequency, tax?: Tax | TaxItem, variableUnit?: string, + feesType?: ExternalFeesType, ) => { - const type = externalFeesMetadata.inputs?.type || 'power'; + const type = feesType ?? resolveExternalFeesType(externalFeesMetadata); if (!result.groups) { result.groups = {}; diff --git a/src/variables/getag/resolve-fees-type.test.ts b/src/variables/getag/resolve-fees-type.test.ts new file mode 100644 index 0000000..3e9e07d --- /dev/null +++ b/src/variables/getag/resolve-fees-type.test.ts @@ -0,0 +1,95 @@ +import type { CompositePriceItem, PriceItem } from '@epilot/sdk/pricing'; +import { describe, expect, it } from 'vitest'; +import type { ExternalFeesMetadata } from '../types'; +import { resolveExternalFeesType } from './resolve-fees-type'; + +const fee = { amount: 1, amount_decimal: '0.01' }; + +const metadata = ( + breakdown: Partial, + inputs?: ExternalFeesMetadata['inputs'], +): ExternalFeesMetadata => + ({ + billing_period: 'monthly', + ...(inputs && { inputs }), + breakdown: { static: {}, variable: {}, variable_ht: {}, ...breakdown }, + }) as ExternalFeesMetadata; + +/** Shape of the pricing API gas compute result, as seen on order OR-2143 (org 16582003). */ +const gasBreakdown: Partial = { + static: { basic_fee: fee, invoice_fee: fee, maintenance_fee: fee, metering_reading_fee: fee }, + variable: { + concession: fee, + grid_fee: fee, + performance: fee, + co2: fee, + control_energy: fee, + neutrality_charge: fee, + gas_tax: fee, + gas_storage: fee, + gas_conversion_charge: fee, + }, +}; + +const powerBreakdown: Partial = { + static: { basic_fee: fee, maintenance_fee: fee }, + variable: { concession: fee, power_tax: fee, chp: fee, extra_charge: fee, offshore_liability_fee: fee }, +}; + +describe('resolveExternalFeesType', () => { + it('uses inputs.type when the journey attached it', () => { + expect(resolveExternalFeesType(metadata(powerBreakdown, { type: 'gas' }))).toBe('gas'); + expect(resolveExternalFeesType(metadata(gasBreakdown, { type: 'power' }))).toBe('power'); + }); + + it('derives gas from gas-only breakdown keys when inputs is missing', () => { + expect(resolveExternalFeesType(metadata(gasBreakdown))).toBe('gas'); + }); + + it('derives gas from gas-only static keys alone', () => { + expect(resolveExternalFeesType(metadata({ static: { basic_fee: fee, invoice_fee: fee } }))).toBe('gas'); + }); + + it('derives power from power-only breakdown keys when inputs is missing', () => { + expect(resolveExternalFeesType(metadata(powerBreakdown))).toBe('power'); + }); + + it('derives power from the HT/NT network fee keys', () => { + expect(resolveExternalFeesType(metadata({ variable: { power_kwh_ht: fee, power_kwh_nt: fee } }))).toBe('power'); + }); + + it('ignores inputs without a valid type and falls through to the breakdown', () => { + expect(resolveExternalFeesType(metadata(gasBreakdown, { consumptionHT: 1000 }))).toBe('gas'); + }); + + describe('when the breakdown only has commodity-agnostic keys', () => { + const agnostic = metadata({ static: { basic_fee: fee }, variable: { concession: fee } }); + + it('falls back to the getag category of a simple price item', () => { + const item = { get_ag: { type: 'work_price', tariff_type: 'HT', category: 'gas' } } as unknown as PriceItem; + + expect(resolveExternalFeesType(agnostic, item)).toBe('gas'); + }); + + it('falls back to the getag category of a composite price component', () => { + const item = { + is_composite_price: true, + item_components: [ + { get_ag: { type: 'base_price', category: 'gas' } }, + { get_ag: { type: 'work_price', tariff_type: 'HT', category: 'gas' } }, + ], + } as unknown as CompositePriceItem; + + expect(resolveExternalFeesType(agnostic, item)).toBe('gas'); + }); + + it('defaults to power when nothing else is known', () => { + expect(resolveExternalFeesType(agnostic)).toBe('power'); + expect(resolveExternalFeesType(agnostic, {} as PriceItem)).toBe('power'); + }); + }); + + it('tolerates a missing breakdown entirely', () => { + expect(resolveExternalFeesType({ billing_period: 'monthly' } as ExternalFeesMetadata)).toBe('power'); + }); +}); diff --git a/src/variables/getag/resolve-fees-type.ts b/src/variables/getag/resolve-fees-type.ts new file mode 100644 index 0000000..b0830cf --- /dev/null +++ b/src/variables/getag/resolve-fees-type.ts @@ -0,0 +1,89 @@ +import type { CompositePriceItem, PriceItem } from '@epilot/sdk/pricing'; +import { extractGetAgConfig } from '../../getag/extract-config'; +import type { ExternalFeesMetadata } from '../types'; + +export type ExternalFeesType = 'power' | 'gas'; + +/** + * Breakdown keys that only the getag *gas* computation emits. + * @see pricing-api `getComputedGasPriceDetails` + */ +const GAS_ONLY_FEE_KEYS = [ + 'gas_tax', + 'gas_storage', + 'gas_conversion_charge', + 'co2', + 'grid_fee', + 'performance', + 'control_energy', + 'neutrality_charge', + 'invoice_fee', + 'metering_reading_fee', +] as const; + +/** + * Breakdown keys that only the getag *power* computation emits. + * @see pricing-api `getComputedPowerPriceDetails` + */ +const POWER_ONLY_FEE_KEYS = [ + 'power_tax', + 'power_kwh_ht', + 'power_kwh_nt', + 'chp', + 'extra_charge', + 'offshore_liability_fee', + 'interruptible_load', +] as const; + +const isExternalFeesType = (value: unknown): value is ExternalFeesType => value === 'power' || value === 'gas'; + +const collectBreakdownKeys = (externalFeesMetadata: ExternalFeesMetadata): Set => { + const { static: staticFees, variable, variable_ht, variable_nt } = externalFeesMetadata.breakdown ?? {}; + + return new Set([staticFees, variable, variable_ht, variable_nt].flatMap((fees) => (fees ? Object.keys(fees) : []))); +}; + +const resolveTypeFromBreakdown = (externalFeesMetadata: ExternalFeesMetadata): ExternalFeesType | undefined => { + const keys = collectBreakdownKeys(externalFeesMetadata); + + if (GAS_ONLY_FEE_KEYS.some((key) => keys.has(key))) { + return 'gas'; + } + + if (POWER_ONLY_FEE_KEYS.some((key) => keys.has(key))) { + return 'power'; + } + + return undefined; +}; + +const resolveTypeFromPriceConfig = (item: PriceItem | CompositePriceItem): ExternalFeesType | undefined => { + const category = ( + extractGetAgConfig(item, { type: 'work_price', tariffType: 'HT' }) ?? + extractGetAgConfig(item, { type: 'work_price', tariffType: 'NT' }) ?? + extractGetAgConfig(item, { type: 'base_price' }) + )?.category; + + return isExternalFeesType(category) ? category : undefined; +}; + +/** + * Resolves whether getag fee metadata describes a power or a gas tariff. + * + * `external_fees_metadata.inputs.type` is only attached by the journey app; orders created through + * other channels carry the raw compute result without it. In that case the commodity is derived from + * the fee keys the pricing API emitted (they differ per commodity), then from the price's getag + * category, and only as a last resort defaults to `power`. + */ +export const resolveExternalFeesType = ( + externalFeesMetadata: ExternalFeesMetadata, + item?: PriceItem | CompositePriceItem, +): ExternalFeesType => { + const declaredType = externalFeesMetadata.inputs?.type; + + if (isExternalFeesType(declaredType)) { + return declaredType; + } + + return resolveTypeFromBreakdown(externalFeesMetadata) ?? (item && resolveTypeFromPriceConfig(item)) ?? 'power'; +}; From c66c627452dc2f611d5bf2b63e8ac247134db2d7 Mon Sep 17 00:00:00 2001 From: Alexandre Marques Date: Thu, 10 Sep 2026 12:36:49 +0100 Subject: [PATCH 3/6] refactor(getag): streamline external fees processing and enhance test coverage Updated the logic in processExternalFeesDetails to prioritize the getag category over breakdown fees when inputs are missing. Adjusted related tests to reflect this change, ensuring that gas fees are excluded when a power category is present. Additionally, refactored the resolveExternalFeesType function to simplify the determination of fee types based on the price item's getag category. --- src/variables/getag/details.test.ts | 16 ++-- src/variables/getag/details.ts | 4 +- src/variables/getag/network-fees-details.ts | 6 +- src/variables/getag/other-fees-details.ts | 6 +- src/variables/getag/resolve-fees-type.test.ts | 84 ++++++++++++------- src/variables/getag/resolve-fees-type.ts | 76 +++-------------- 6 files changed, 81 insertions(+), 111 deletions(-) diff --git a/src/variables/getag/details.test.ts b/src/variables/getag/details.test.ts index 7ab9510..0dabb40 100644 --- a/src/variables/getag/details.test.ts +++ b/src/variables/getag/details.test.ts @@ -84,9 +84,9 @@ describe('processExternalFeesDetails', () => { expect(result.groups?.['other_fees'].fees).not.toHaveProperty('gas_tax'); }); - it('uses the gas fee set when inputs is missing but the breakdown is a gas compute result', () => { - // Order OR-2143 (org 16582003): composite "Strom" price with get_ag.category "power", - // but the fee metadata attached at checkout is the gas compute result and has no `inputs`. + it('follows the getag category, not the breakdown, when inputs is missing', () => { + // Seen in production: a `category: 'power'` price whose attached metadata is the gas compute + // result, with no `inputs`. The category decides, so the gas fees are dropped. const gasFee = { amount: 1, amount_decimal: '0.01', unit_amount: 1, unit_amount_decimal: '0.01' }; const gasMetadata: ExternalFeesMetadata = { billing_period: 'monthly', @@ -114,9 +114,13 @@ describe('processExternalFeesDetails', () => { const result = processExternalFeesDetails(item, gasMetadata, 'EUR' as Currency, i18n, 'kWh'); - expect(result.groups?.['other_fees'].fees).toHaveProperty('gas_tax'); - expect(result.groups?.['other_fees'].fees).toHaveProperty('co2'); - expect(result.groups?.['other_fees'].fees).not.toHaveProperty('power_tax'); + const otherFees = result.groups?.['other_fees'].fees as Record; + + expect(otherFees['gas_tax']).toBeUndefined(); + expect(otherFees['co2']).toBeUndefined(); + expect(Object.keys(otherFees)).toEqual( + expect.arrayContaining(['concession', 'chp', 'extra_charge', 'offshore_liability_fee', 'power_tax']), + ); expect(result.groups?.['meter_fees'] ?? result.groups?.['network_operating_fees']).toBeDefined(); }); }); diff --git a/src/variables/getag/details.ts b/src/variables/getag/details.ts index a49ff66..350f4fc 100644 --- a/src/variables/getag/details.ts +++ b/src/variables/getag/details.ts @@ -53,9 +53,9 @@ export const processExternalFeesDetails = ( i18n, billingPeriod, unitPricePeriod, + feesType, tax, formattedUnit, - feesType, ); processMeterFeesDetails( @@ -76,9 +76,9 @@ export const processExternalFeesDetails = ( i18n, billingPeriod, unitPricePeriod, + feesType, tax, formattedUnit, - feesType, ); processExternalDisplayFeesDetails(result as ExternalFeesDetails); diff --git a/src/variables/getag/network-fees-details.ts b/src/variables/getag/network-fees-details.ts index a7260c9..06096bd 100644 --- a/src/variables/getag/network-fees-details.ts +++ b/src/variables/getag/network-fees-details.ts @@ -1,7 +1,7 @@ import type { Currency, I18n, Tax, TaxItem } from '../../shared/types'; import type { TimeFrequency } from '../../time-frequency/types'; import type { ExternalFeesMetadata, ExternalFeesDetails, ExternalFeesDetailsGroup } from '../types'; -import { resolveExternalFeesType, type ExternalFeesType } from './resolve-fees-type'; +import type { ExternalFeesType } from './resolve-fees-type'; import { getDetailsFee } from './utils'; export const processNetworkOperatingFeesDetails = ( @@ -11,12 +11,10 @@ export const processNetworkOperatingFeesDetails = ( i18n: I18n, billingPeriod: TimeFrequency, unitPricePeriod: TimeFrequency, + type: ExternalFeesType, tax?: Tax | TaxItem, variableUnit?: string, - feesType?: ExternalFeesType, ) => { - const type = feesType ?? resolveExternalFeesType(externalFeesMetadata); - if (!result.groups) { result.groups = {}; } diff --git a/src/variables/getag/other-fees-details.ts b/src/variables/getag/other-fees-details.ts index e943962..494233f 100644 --- a/src/variables/getag/other-fees-details.ts +++ b/src/variables/getag/other-fees-details.ts @@ -3,7 +3,7 @@ import type { Tax, TaxItem } from '../../shared/types'; import type { I18n } from '../../shared/types'; import type { TimeFrequency } from '../../time-frequency/types'; import type { ExternalFeesMetadata, ExternalFeesDetails, ExternalFeesDetailsGroup } from '../types'; -import { resolveExternalFeesType, type ExternalFeesType } from './resolve-fees-type'; +import type { ExternalFeesType } from './resolve-fees-type'; import { getDetailsFee } from './utils'; export const processOtherFeesDetails = ( @@ -13,12 +13,10 @@ export const processOtherFeesDetails = ( i18n: I18n, billingPeriod: TimeFrequency, unitPricePeriod: TimeFrequency, + type: ExternalFeesType, tax?: Tax | TaxItem, variableUnit?: string, - feesType?: ExternalFeesType, ) => { - const type = feesType ?? resolveExternalFeesType(externalFeesMetadata); - if (!result.groups) { result.groups = {}; } diff --git a/src/variables/getag/resolve-fees-type.test.ts b/src/variables/getag/resolve-fees-type.test.ts index 3e9e07d..4aac275 100644 --- a/src/variables/getag/resolve-fees-type.test.ts +++ b/src/variables/getag/resolve-fees-type.test.ts @@ -15,7 +15,9 @@ const metadata = ( breakdown: { static: {}, variable: {}, variable_ht: {}, ...breakdown }, }) as ExternalFeesMetadata; -/** Shape of the pricing API gas compute result, as seen on order OR-2143 (org 16582003). */ +const itemWithGetAg = (getAg: Record) => ({ get_ag: getAg }) as unknown as PriceItem; + +/** Shape of the pricing API gas compute result. */ const gasBreakdown: Partial = { static: { basic_fee: fee, invoice_fee: fee, maintenance_fee: fee, metering_reading_fee: fee }, variable: { @@ -38,58 +40,76 @@ const powerBreakdown: Partial = { describe('resolveExternalFeesType', () => { it('uses inputs.type when the journey attached it', () => { - expect(resolveExternalFeesType(metadata(powerBreakdown, { type: 'gas' }))).toBe('gas'); - expect(resolveExternalFeesType(metadata(gasBreakdown, { type: 'power' }))).toBe('power'); - }); - - it('derives gas from gas-only breakdown keys when inputs is missing', () => { - expect(resolveExternalFeesType(metadata(gasBreakdown))).toBe('gas'); - }); - - it('derives gas from gas-only static keys alone', () => { - expect(resolveExternalFeesType(metadata({ static: { basic_fee: fee, invoice_fee: fee } }))).toBe('gas'); + expect(resolveExternalFeesType(metadata(powerBreakdown, { type: 'gas' }), itemWithGetAg({}))).toBe('gas'); + expect(resolveExternalFeesType(metadata(gasBreakdown, { type: 'power' }), itemWithGetAg({}))).toBe('power'); }); - it('derives power from power-only breakdown keys when inputs is missing', () => { - expect(resolveExternalFeesType(metadata(powerBreakdown))).toBe('power'); - }); - - it('derives power from the HT/NT network fee keys', () => { - expect(resolveExternalFeesType(metadata({ variable: { power_kwh_ht: fee, power_kwh_nt: fee } }))).toBe('power'); - }); + describe('when inputs.type is missing', () => { + it('uses the getag category of a work_price item', () => { + const item = itemWithGetAg({ + category: 'gas', + consumption_type: 'household', + tariff_type: 'HT', + type: 'work_price', + markup_amount: 11, + markup_amount_decimal: '0.1054', + }); + + expect(resolveExternalFeesType(metadata(powerBreakdown), item)).toBe('gas'); + }); - it('ignores inputs without a valid type and falls through to the breakdown', () => { - expect(resolveExternalFeesType(metadata(gasBreakdown, { consumptionHT: 1000 }))).toBe('gas'); - }); + it('uses the getag category of a base_price item', () => { + const item = itemWithGetAg({ category: 'gas', consumption_type: 'household', type: 'base_price' }); - describe('when the breakdown only has commodity-agnostic keys', () => { - const agnostic = metadata({ static: { basic_fee: fee }, variable: { concession: fee } }); + expect(resolveExternalFeesType(metadata(powerBreakdown), item)).toBe('gas'); + }); - it('falls back to the getag category of a simple price item', () => { - const item = { get_ag: { type: 'work_price', tariff_type: 'HT', category: 'gas' } } as unknown as PriceItem; + it('uses the getag category even when get_ag omits the optional type', () => { + const item = itemWithGetAg({ category: 'gas', consumption_type: 'household' }); - expect(resolveExternalFeesType(agnostic, item)).toBe('gas'); + expect(resolveExternalFeesType(metadata(powerBreakdown), item)).toBe('gas'); }); - it('falls back to the getag category of a composite price component', () => { + it('uses the getag category of the first composite component that carries one', () => { const item = { is_composite_price: true, item_components: [ + { _id: 'no-getag-component' }, { get_ag: { type: 'base_price', category: 'gas' } }, { get_ag: { type: 'work_price', tariff_type: 'HT', category: 'gas' } }, ], } as unknown as CompositePriceItem; - expect(resolveExternalFeesType(agnostic, item)).toBe('gas'); + expect(resolveExternalFeesType(metadata(powerBreakdown), item)).toBe('gas'); }); - it('defaults to power when nothing else is known', () => { - expect(resolveExternalFeesType(agnostic)).toBe('power'); - expect(resolveExternalFeesType(agnostic, {} as PriceItem)).toBe('power'); + it('ignores inputs without a valid type', () => { + const item = itemWithGetAg({ category: 'gas', type: 'work_price', tariff_type: 'HT' }); + + expect(resolveExternalFeesType(metadata(gasBreakdown, { consumptionHT: 1000 }), item)).toBe('gas'); + }); + + /** Seen in production: a `category: 'power'` price carrying a gas compute result. */ + it('follows the getag category even when the breakdown disagrees with it', () => { + const item = itemWithGetAg({ category: 'power', type: 'work_price', tariff_type: 'HT' }); + + expect(resolveExternalFeesType(metadata(gasBreakdown), item)).toBe('power'); + }); + }); + + describe('when the item carries no getag category', () => { + it('defaults to power', () => { + expect(resolveExternalFeesType(metadata(gasBreakdown), {} as PriceItem)).toBe('power'); + expect(resolveExternalFeesType(metadata(powerBreakdown), itemWithGetAg({}))).toBe('power'); + expect(resolveExternalFeesType(metadata(gasBreakdown), { is_composite_price: true } as CompositePriceItem)).toBe( + 'power', + ); }); }); it('tolerates a missing breakdown entirely', () => { - expect(resolveExternalFeesType({ billing_period: 'monthly' } as ExternalFeesMetadata)).toBe('power'); + const item = itemWithGetAg({ category: 'gas', type: 'work_price', tariff_type: 'HT' }); + + expect(resolveExternalFeesType({ billing_period: 'monthly' } as ExternalFeesMetadata, item)).toBe('gas'); }); }); diff --git a/src/variables/getag/resolve-fees-type.ts b/src/variables/getag/resolve-fees-type.ts index b0830cf..7cd1154 100644 --- a/src/variables/getag/resolve-fees-type.ts +++ b/src/variables/getag/resolve-fees-type.ts @@ -1,83 +1,33 @@ import type { CompositePriceItem, PriceItem } from '@epilot/sdk/pricing'; -import { extractGetAgConfig } from '../../getag/extract-config'; +import { isCompositePrice } from '../../prices/utils'; import type { ExternalFeesMetadata } from '../types'; export type ExternalFeesType = 'power' | 'gas'; -/** - * Breakdown keys that only the getag *gas* computation emits. - * @see pricing-api `getComputedGasPriceDetails` - */ -const GAS_ONLY_FEE_KEYS = [ - 'gas_tax', - 'gas_storage', - 'gas_conversion_charge', - 'co2', - 'grid_fee', - 'performance', - 'control_energy', - 'neutrality_charge', - 'invoice_fee', - 'metering_reading_fee', -] as const; +const isExternalFeesType = (value: unknown): value is ExternalFeesType => value === 'power' || value === 'gas'; /** - * Breakdown keys that only the getag *power* computation emits. - * @see pricing-api `getComputedPowerPriceDetails` + * Read `category` directly rather than through `extractGetAgConfig`, which matches on the optional + * `get_ag.type`. Components of a composite getag price all share the category, so the first wins. */ -const POWER_ONLY_FEE_KEYS = [ - 'power_tax', - 'power_kwh_ht', - 'power_kwh_nt', - 'chp', - 'extra_charge', - 'offshore_liability_fee', - 'interruptible_load', -] as const; - -const isExternalFeesType = (value: unknown): value is ExternalFeesType => value === 'power' || value === 'gas'; - -const collectBreakdownKeys = (externalFeesMetadata: ExternalFeesMetadata): Set => { - const { static: staticFees, variable, variable_ht, variable_nt } = externalFeesMetadata.breakdown ?? {}; - - return new Set([staticFees, variable, variable_ht, variable_nt].flatMap((fees) => (fees ? Object.keys(fees) : []))); -}; - -const resolveTypeFromBreakdown = (externalFeesMetadata: ExternalFeesMetadata): ExternalFeesType | undefined => { - const keys = collectBreakdownKeys(externalFeesMetadata); - - if (GAS_ONLY_FEE_KEYS.some((key) => keys.has(key))) { - return 'gas'; - } - - if (POWER_ONLY_FEE_KEYS.some((key) => keys.has(key))) { - return 'power'; - } - - return undefined; -}; - const resolveTypeFromPriceConfig = (item: PriceItem | CompositePriceItem): ExternalFeesType | undefined => { - const category = ( - extractGetAgConfig(item, { type: 'work_price', tariffType: 'HT' }) ?? - extractGetAgConfig(item, { type: 'work_price', tariffType: 'NT' }) ?? - extractGetAgConfig(item, { type: 'base_price' }) - )?.category; + const categories = isCompositePrice(item) + ? (item.item_components ?? []).map((component) => component.get_ag?.category) + : [item.get_ag?.category]; - return isExternalFeesType(category) ? category : undefined; + return categories.find(isExternalFeesType); }; /** * Resolves whether getag fee metadata describes a power or a gas tariff. * - * `external_fees_metadata.inputs.type` is only attached by the journey app; orders created through - * other channels carry the raw compute result without it. In that case the commodity is derived from - * the fee keys the pricing API emitted (they differ per commodity), then from the price's getag - * category, and only as a last resort defaults to `power`. + * Only the journey app attaches `inputs.type`; API submissions carry the raw compute result without + * it. The price's getag category is the same value the journey sends to getag (`type = category`), + * so it decides — even when the attached breakdown looks like the other commodity. */ export const resolveExternalFeesType = ( externalFeesMetadata: ExternalFeesMetadata, - item?: PriceItem | CompositePriceItem, + item: PriceItem | CompositePriceItem, ): ExternalFeesType => { const declaredType = externalFeesMetadata.inputs?.type; @@ -85,5 +35,5 @@ export const resolveExternalFeesType = ( return declaredType; } - return resolveTypeFromBreakdown(externalFeesMetadata) ?? (item && resolveTypeFromPriceConfig(item)) ?? 'power'; + return resolveTypeFromPriceConfig(item) ?? 'power'; }; From fc40777d0e1dc43601ef3c981b301779360d8129 Mon Sep 17 00:00:00 2001 From: Alexandre Marques Date: Thu, 10 Sep 2026 12:39:13 +0100 Subject: [PATCH 4/6] test(getag): add test for composite component category handling Introduced a new test case in resolveExternalFeesType to verify that the function correctly uses a composite component category even when the component omits the optional type. This enhances test coverage for scenarios involving composite price items. --- src/variables/getag/resolve-fees-type.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/variables/getag/resolve-fees-type.test.ts b/src/variables/getag/resolve-fees-type.test.ts index 4aac275..b9e07e5 100644 --- a/src/variables/getag/resolve-fees-type.test.ts +++ b/src/variables/getag/resolve-fees-type.test.ts @@ -70,6 +70,15 @@ describe('resolveExternalFeesType', () => { expect(resolveExternalFeesType(metadata(powerBreakdown), item)).toBe('gas'); }); + it('uses a composite component category even when the component omits the optional type', () => { + const item = { + is_composite_price: true, + item_components: [{ get_ag: { category: 'gas', consumption_type: 'household' } }], + } as unknown as CompositePriceItem; + + expect(resolveExternalFeesType(metadata(powerBreakdown), item)).toBe('gas'); + }); + it('uses the getag category of the first composite component that carries one', () => { const item = { is_composite_price: true, From 8deef151ccdc9928d7d1abdd2e5d185a60887091 Mon Sep 17 00:00:00 2001 From: Alexandre Marques Date: Thu, 10 Sep 2026 13:43:29 +0100 Subject: [PATCH 5/6] refactor(types): simplify documentation for ExternalFeesMetadata inputs Updated the documentation for the inputs property in ExternalFeesMetadata to provide a clearer description. Removed unnecessary details about consumption inputs being attached only by the journey app, streamlining the comment for better readability. --- src/variables/types.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/variables/types.ts b/src/variables/types.ts index 58fe42b..484faa1 100644 --- a/src/variables/types.ts +++ b/src/variables/types.ts @@ -23,11 +23,7 @@ export type GetTieredUnitAmountOptions = { export type ExternalFeesMetadata = { billing_period: string; - /** - * Consumption inputs the price was computed with. - * Only attached by the journey app; orders created through other channels - * (360 cockpit, public API, pre-2024 journeys) may not carry it. - */ + /** Inputs the price was computed with. */ inputs?: { consumptionHT?: number; consumptionNT?: number; From 0c1f217731dc05cfae0de2198b4c136038aadbe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20P=C3=A4rna?= Date: Thu, 10 Sep 2026 17:56:31 +0300 Subject: [PATCH 6/6] docs(changeset): describe category-based commodity resolution The resolver was reworked to follow the price's getag category instead of inferring the commodity from breakdown keys; align the changeset with that behaviour. Co-Authored-By: Claude Fable 5.1 --- .changeset/getag-fees-optional-inputs.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/getag-fees-optional-inputs.md b/.changeset/getag-fees-optional-inputs.md index ff20dbc..6d4e8f3 100644 --- a/.changeset/getag-fees-optional-inputs.md +++ b/.changeset/getag-fees-optional-inputs.md @@ -2,8 +2,8 @@ '@epilot/pricing': patch --- -fix(getag): tolerate `external_fees_metadata` without `inputs` and derive the commodity from the breakdown +fix(getag): tolerate `external_fees_metadata` without `inputs` and resolve the commodity from the price `processExternalFeesDetails` crashed with `Cannot read properties of undefined (reading 'consumptionHT')` for order line items whose getag fee metadata has no `inputs` object (orders created via the 360 cockpit, the public API, or journey flows that forward the raw compute result). `inputs` is now optional and consumption-based yearly amounts render as `-` when it is absent. -The commodity used to pick the power vs. gas fee groups is no longer hard-defaulted to `power` when `inputs.type` is missing. New `resolveExternalFeesType()` derives it from the fee keys the pricing API emitted (e.g. `gas_tax`, `gas_storage` vs. `power_tax`, `chp`), then from the price's getag `category`, and only then falls back to `power`. +The commodity used to pick the power vs. gas fee groups is no longer hard-defaulted to `power` when `inputs.type` is missing. New `resolveExternalFeesType()` uses `inputs.type` when present, otherwise the price's getag `category` (read from the item or its composite components), and only then falls back to `power`.