Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/getag-fees-optional-inputs.md
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions src/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const expectedNamedExports = [
'processOrderTableData',
'formatFeeAmountFromString',
'extractGetAgConfig',
'resolveExternalFeesType',
'getAmountWithTax',
'getTaxValue',
'computePriceDiff',
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
126 changes: 126 additions & 0 deletions src/variables/getag/details.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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();
});
});
4 changes: 4 additions & 0 deletions src/variables/getag/details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<ExternalFeesDetails> = {
unit_price_period: i18n.t(`table_order.recurrences.billing_period.${unitPricePeriod}`),
Expand Down Expand Up @@ -51,6 +53,7 @@ export const processExternalFeesDetails = (
i18n,
billingPeriod,
unitPricePeriod,
feesType,
tax,
formattedUnit,
);
Expand All @@ -73,6 +76,7 @@ export const processExternalFeesDetails = (
i18n,
billingPeriod,
unitPricePeriod,
feesType,
tax,
formattedUnit,
);
Expand Down
4 changes: 2 additions & 2 deletions src/variables/getag/network-fees-details.ts
Original file line number Diff line number Diff line change
@@ -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 = (
Expand All @@ -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 = {};
}
Expand Down
4 changes: 2 additions & 2 deletions src/variables/getag/other-fees-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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 = {};
}
Expand Down
124 changes: 124 additions & 0 deletions src/variables/getag/resolve-fees-type.test.ts
Original file line number Diff line number Diff line change
@@ -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<ExternalFeesMetadata['breakdown']>,
inputs?: ExternalFeesMetadata['inputs'],
): ExternalFeesMetadata =>
({
billing_period: 'monthly',
...(inputs && { inputs }),
breakdown: { static: {}, variable: {}, variable_ht: {}, ...breakdown },
}) as ExternalFeesMetadata;

const itemWithGetAg = (getAg: Record<string, unknown>) => ({ get_ag: getAg }) as unknown as PriceItem;

/** Shape of the pricing API gas compute result. */
const gasBreakdown: Partial<ExternalFeesMetadata['breakdown']> = {
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<ExternalFeesMetadata['breakdown']> = {
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');
});
});
39 changes: 39 additions & 0 deletions src/variables/getag/resolve-fees-type.ts
Original file line number Diff line number Diff line change
@@ -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';
};
Loading
Loading