SPM Financials: Fix Cost Plan / Actual Cost mismatches with dry-run
New article articles in ServiceNow Community
·
Aug 24, 2026
·
article
SPM Financials: Fix Cost Plan / Actual Cost mismatches with dry-run
Problem statement
A common ServiceNow SPM financials issue is when a project or demand shows incorrect or zero actual costs in the Financials tab / Portfolio Planning Workspace, even though fm_expense_line records exist.
Typical symptoms:
-
cost_plan.total_actual_costdoes not equal the sum of processedfm_expense_lineamounts. -
pm_project.work_cost/dmn_demand.work_costdoes not equal the sum ofcost_plan_breakdownrows wherebreakdown_type = task. -
cost_plan_breakdownrows have stalebreakdown_type = requirementactuals or duplicated task-level rows. - Task-level planned cost breakdowns are missing or out of sync with requirement-level planned cost.
This script rebuilds task-level planned and actual cost rollups for a project or a demand, using existing breakdown_type = requirement rows as the source of truth.
Root cause
The SPM financial rollup chain is split into two pipelines:
Planned cost
cost_plan
-> (existing) cost_plan_breakdown (breakdown_type = requirement) with planned cost
-> PPMCostRollupManager
-> cost_plan_breakdown (breakdown_type = task) with planned cost
-> task planned totals
Actual cost
fm_expense_line (state = processed)
-> PPMFundManager.updateActualsForTask() / updateActualsForCostPlan()
-> cost_plan_breakdown (breakdown_type = requirement) with actuals
-> cost_plan_breakdown (breakdown_type = task) with actuals
-> ExpenseLinesHelper.setTaskActualCosts()
-> pm_project.work_cost / dmn_demand.work_cost
The mismatch usually happens when one of those layers is updated and the downstream layers are not refreshed, for example:
- Custom cost types,
source_id/source_tablechanges, ortop_taskchanges. - Duplicate
cost_plan_breakdownrows from past data corruption / partial rollbacks. - Fiscal period changes without rebuilding breakdowns.
- Processed expense lines are deleted.
- Expense lines are imported.
- Processed expense lines are reparented with a different cost plan.
- The amount on a processed expense line is changed.
Any of the above can leave the cost_plan_breakdown task- and requirement-level rows out of sync with the expense-line source of truth. The script below rebuilds those breakdown layers.
Before you run this script
-
Back up the affected project(s) / demand(s). The script deletes
breakdown_type = taskcost_plan_breakdownrows and rewritesactualonbreakdown_type = requirementrows. -
Task-type breakdowns are deleted and recreated. Existing
breakdown_type = taskrows are removed, then regenerated from thebreakdown_type = requirementrows (planned cost) and fromfm_expense_linerecords (actual cost). Any manual edits made directly to task-type breakdown rows will be lost. - Test in a non-production instance first.
- The script is intended to be run as a Fix Script or in Scripts - Background by an admin user.
- The script is a data-correction workaround, not a product code fix. If the symptom is reproducible on a clean instance, open a new PRB / case.
Configuration
Edit the variables at the top of the script:
| Variable | Purpose |
|---|---|
DRY_RUN |
true = only logs what would be changed; false = applies the fix. |
TABLE |
pm_project or dmn_demand. |
SYS_IDS |
Array of affected sys_id values. Keep the list small. |
How to use
- Copy the script below into Scripts - Background or a Fix Script.
- Set
DRY_RUN = trueandSYS_IDSto the affected project/demandsys_idvalues. - Run the script and read the
gs.infooutput. - If the preview looks correct, set
DRY_RUN = falseand run again.
The script
var DRY_RUN = true;
var TABLE = 'pm_project'; // use 'dmn_demand' for demands
var SYS_IDS = ['35c600505301561042c7730330e5e6c8']; // replace with affected sys_ids
function resetRequirementTypeActualsToZero(sys_id) {
gs.info('Zeroing requirement breakdown actuals for task ' + sys_id);
var grBreakdown = new GlideRecord('cost_plan_breakdown');
grBreakdown.addQuery('breakdown_type', 'requirement');
grBreakdown.addQuery('task', sys_id);
grBreakdown.query();
while (grBreakdown.next()) {
grBreakdown.setValue('actual', 0);
grBreakdown.update();
}
}
function resetTaskBreakdownsCost(sys_id, table, taskRef) {
var gr = new GlideAggregate('cost_plan_breakdown');
gr.addQuery('breakdown_type', 'requirement');
gr.addNotNullQuery('cost_plan');
gr.addQuery('task', sys_id);
gr.groupBy('fiscal_period');
gr.groupBy('expense_type');
gr.query();
while (gr.next()) {
var util = new PPMCostRollupManager(gr.getValue('fiscal_period'), gr.getValue('expense_type'));
if (table == 'pm_project') {
util.updateProjectEstimatedCostByFiscalPeriod(taskRef.getUniqueValue());
} else if (table == 'dmn_demand') {
util.updateDemandEstimatedCostByFiscalPeriod(taskRef.getUniqueValue());
}
}
}
function resetTaskBreakdownsActual(sys_id) {
var gr = new GlideRecord('fm_expense_line');
gr.addQuery('task', sys_id);
gr.query();
while (gr.next()) {
var fundManager = new PPMFundManager();
if (!gr.cost_plan.nil()) {
fundManager.updateActualsForCostPlan(gr);
}
fundManager.updateActualsForTask(gr);
}
}
function updateTaskAndCpRecordActuals(taskId) {
var expenseLinesGr = new GlideRecord('fm_expense_line');
expenseLinesGr.addQuery('task', taskId);
expenseLinesGr.addQuery('state', 'processed');
expenseLinesGr.query();
while (expenseLinesGr.next()) {
try {
var helper = new ExpenseLinesHelper(expenseLinesGr);
helper.setTaskActualCosts();
var costPlan = new CostPlan(expenseLinesGr.cost_plan.getRefRecord());
var updated = costPlan.updateTotalActualCost();
if (updated) costPlan.update();
} catch (e) {
gs.error('Error updating task actuals for ' + taskId + ': ' + (e.message || e));
}
}
}
function resetTaskBreakdowns(sys_id, table) {
var taskRef = new GlideRecord(table);
if (!taskRef.get(sys_id)) {
gs.warn('Record not found: ' + table + ' ' + sys_id);
return;
}
if (DRY_RUN) {
var taskBreakdowns = new GlideRecord('cost_plan_breakdown');
taskBreakdowns.addQuery('breakdown_type', 'task');
taskBreakdowns.addQuery('task', sys_id);
taskBreakdowns.query();
var reqBreakdowns = new GlideRecord('cost_plan_breakdown');
reqBreakdowns.addQuery('breakdown_type', 'requirement');
reqBreakdowns.addQuery('task', sys_id);
reqBreakdowns.query();
var expenseLines = new GlideRecord('fm_expense_line');
expenseLines.addQuery('task', sys_id);
expenseLines.query();
gs.info('[DRY RUN] Would reset planned/actual breakdowns for ' + table + ' ' + sys_id +
' (task breakdowns: ' + taskBreakdowns.getRowCount() +
', requirement breakdowns: ' + reqBreakdowns.getRowCount() +
', expense lines: ' + expenseLines.getRowCount() + ')');
return;
}
resetRequirementTypeActualsToZero(sys_id);
var grDel = new GlideRecord('cost_plan_breakdown');
grDel.addQuery('breakdown_type', 'task');
grDel.addQuery('task', sys_id);
grDel.deleteMultiple();
resetTaskBreakdownsCost(sys_id, table, taskRef);
resetTaskBreakdownsActual(sys_id);
updateTaskAndCpRecordActuals(sys_id);
}
for (var i = 0; i < SYS_IDS.length; i++) {
resetTaskBreakdowns(SYS_IDS[i], TABLE);
}
Important notes
-
Dry run first. Never run with
DRY_RUN = falseon the first attempt. -
Task-type breakdowns are deleted and recreated. Existing
breakdown_type = taskcost_plan_breakdownrows are removed, then regenerated from the requirement-level planned cost and from the processedfm_expense_lineactuals. Any direct manual changes to task-type breakdown rows will be lost. -
Requirement breakdowns are the source of truth. This script does not recreate
breakdown_type = requirementrows. It only rolls up from existing requirement breakdowns intobreakdown_type = taskbreakdowns. -
Actual fix zeros and rebuilds requirement actuals. The script sets
cost_plan_breakdown.actualto0for all requirement rows, then reprocesses everyfm_expense_linethroughPPMFundManager. This makes the requirement-level actuals match the expense lines. -
Scope. The script operates on a single
pm_projectordmn_demandrecord. If child project tasks also have their own expense lines, run the script for each childsys_idor adjust the query to include descendants.
Related cases and PRBs
These cases describe the same or related symptoms:
-
CSTASK1543676— Actual costs not shown in portfolio planning workspace. -
CSTASK1524920— Expense lines and cost plans out of sync. -
CSTASK1493068/PRB2003945/KB1318242— Duplicate cost plan breakdowns. -
CSTASK1541990— Difference between project actual cost and cost plan actual cost. -
CSTASK1532786— Expense lines add up to 0 but project actual cost is $0.02. -
CSTASK1539415— Expense line associated with generic resource internal OPEX instead of role-based. -
CSTASK1545300— Inconsistent total planned cost rollup from cost plans to program. -
CSTASK1361083— Total planned cost on program greater than cost plans. -
CSTASK1537249— Duplicate cost plan breakdown issue not resolved. -
CSTASK1496364— Project cost plan migrated as internal after resource plan migration. -
CSTASK1545115— Duplicate investment cost plan breakdowns. -
CSTASK1545092— Budget decreasing because of negative expense line.
https://www.servicenow.com/community/spm-articles/spm-financials-fix-cost-plan-actual-cost-mismatches-with-dry-run/ta-p/3590634