diff --git a/.changeset/getag-fees-optional-inputs.md b/.changeset/getag-fees-optional-inputs.md new file mode 100644 index 0000000..6d4e8f3 --- /dev/null +++ b/.changeset/getag-fees-optional-inputs.md @@ -0,0 +1,9 @@ +--- +'@epilot/pricing': patch +--- + +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()` 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`. 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 new file mode 100644 index 0000000..0dabb40 --- /dev/null +++ b/src/variables/getag/details.test.ts @@ -0,0 +1,126 @@ +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('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; + + 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('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', + 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'); + + 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 60397fa..350f4fc 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}`), @@ -51,6 +53,7 @@ export const processExternalFeesDetails = ( i18n, billingPeriod, unitPricePeriod, + feesType, tax, formattedUnit, ); @@ -73,6 +76,7 @@ export const processExternalFeesDetails = ( i18n, billingPeriod, unitPricePeriod, + feesType, tax, formattedUnit, ); diff --git a/src/variables/getag/network-fees-details.ts b/src/variables/getag/network-fees-details.ts index 13e65e8..06096bd 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 type { ExternalFeesType } from './resolve-fees-type'; import { getDetailsFee } from './utils'; export const processNetworkOperatingFeesDetails = ( @@ -10,11 +11,10 @@ export const processNetworkOperatingFeesDetails = ( i18n: I18n, billingPeriod: TimeFrequency, unitPricePeriod: TimeFrequency, + type: ExternalFeesType, tax?: Tax | TaxItem, variableUnit?: string, ) => { - 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..494233f 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 type { ExternalFeesType } from './resolve-fees-type'; import { getDetailsFee } from './utils'; export const processOtherFeesDetails = ( @@ -12,11 +13,10 @@ export const processOtherFeesDetails = ( i18n: I18n, billingPeriod: TimeFrequency, unitPricePeriod: TimeFrequency, + type: ExternalFeesType, tax?: Tax | TaxItem, variableUnit?: string, ) => { - const type = externalFeesMetadata.inputs.type || 'power'; - 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..b9e07e5 --- /dev/null +++ b/src/variables/getag/resolve-fees-type.test.ts @@ -0,0 +1,124 @@ +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; + +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: { + 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' }), itemWithGetAg({}))).toBe('gas'); + expect(resolveExternalFeesType(metadata(gasBreakdown, { type: 'power' }), itemWithGetAg({}))).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('uses the getag category of a base_price item', () => { + const item = itemWithGetAg({ category: 'gas', consumption_type: 'household', type: 'base_price' }); + + expect(resolveExternalFeesType(metadata(powerBreakdown), item)).toBe('gas'); + }); + + it('uses the getag category even when get_ag omits the optional type', () => { + const item = itemWithGetAg({ category: 'gas', consumption_type: 'household' }); + + 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, + 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(metadata(powerBreakdown), item)).toBe('gas'); + }); + + 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', () => { + 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 new file mode 100644 index 0000000..7cd1154 --- /dev/null +++ b/src/variables/getag/resolve-fees-type.ts @@ -0,0 +1,39 @@ +import type { CompositePriceItem, PriceItem } from '@epilot/sdk/pricing'; +import { isCompositePrice } from '../../prices/utils'; +import type { ExternalFeesMetadata } from '../types'; + +export type ExternalFeesType = 'power' | 'gas'; + +const isExternalFeesType = (value: unknown): value is ExternalFeesType => value === 'power' || value === 'gas'; + +/** + * 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 resolveTypeFromPriceConfig = (item: PriceItem | CompositePriceItem): ExternalFeesType | undefined => { + const categories = isCompositePrice(item) + ? (item.item_components ?? []).map((component) => component.get_ag?.category) + : [item.get_ag?.category]; + + return categories.find(isExternalFeesType); +}; + +/** + * Resolves whether getag fee metadata describes a power or a gas tariff. + * + * 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, +): ExternalFeesType => { + const declaredType = externalFeesMetadata.inputs?.type; + + if (isExternalFeesType(declaredType)) { + return declaredType; + } + + return resolveTypeFromPriceConfig(item) ?? 'power'; +}; 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..484faa1 100644 --- a/src/variables/types.ts +++ b/src/variables/types.ts @@ -23,7 +23,8 @@ export type GetTieredUnitAmountOptions = { export type ExternalFeesMetadata = { billing_period: string; - inputs: { + /** Inputs the price was computed with. */ + inputs?: { consumptionHT?: number; consumptionNT?: number; type?: 'power' | 'gas';