This article demonstrates how to create an Invoice Proposal programmatically starting from a Project Fee Journal in Dynamics 365 Finance & Operations.
The solution encapsulates the required logic into a reusable class that can be invoked from X++ code, batch processes, custom services, or integrations.
By leveraging the standard Project Management and Accounting framework, the implementation automatically determines the appropriate Funding Source (when not explicitly provided) and generates the corresponding Invoice Proposal while preserving the standard business logic.
Entry Point
The implementation has been encapsulated in a dedicated class named InvoiceProjInvoiceProposalProcessing.
Creating an invoice proposal becomes extremely simple:
InvoiceProjInvoiceProposalProcessing processing =new InvoiceProjInvoiceProposalProcessing();
processing.parmProjJournalId(journalId);
processing.parmProjFundingSource(projFundingSource);
processing.invoiceProposalCreation();
The only mandatory parameter is the JournalId.
The Funding Source is optional. If it isn’t provided, the class automatically determines the correct one before creating the proposal.
/// <summary>
/// InvoiceProjInvoiceProposalProcessing class
/// </summary>
public class InvoiceProjInvoiceProposalProcessing
{
ProjJournalId projJournalId;
ProjFundingSource projFundingSource;
/// <summary>
/// Get or set ProjJournalId
/// </summary>
/// <param name = "_projJournalId">The ProjJournalId to set</param>
/// <returns>The current ProjJournalId</returns>
public ProjJournalId parmProjJournalId(ProjJournalId _projJournalId = projJournalId)
{
projJournalId = _projJournalId;
return projJournalId;
}
/// <summary>
/// Get or set ProjFundingSource
/// </summary>
/// <param name = "_projFundingSource">The ProjFundingSource to set</param>
/// <returns>The current ProjFundingSource</returns>
public ProjFundingSource parmProjFundingSource(ProjFundingSource _projFundingSource = projFundingSource)
{
projFundingSource = _projFundingSource;
return projFundingSource;
}
/// <summary>
/// invoiceProposalCreation
/// </summary>
/// <returns>ProjProposalId</returns>
public ProjProposalId invoiceProposalCreation()
{
ProjProposalId proposalId;
if (!projFundingSource.RecId)
{
this.calculateProjFundingSource();
}
if ((projJournalId != "") && projFundingSource.RecId)
{
proposalId = ProjInvoiceProposalCreateExt::createDataInTmp(projFundingSource.RecId, projJournalId);
Info("Invoice proposal created nr.: " + proposalId);
}
else
{
throw error("JournalId or ProjFundingSource missing");
}
return proposalId;
}
/// <summary>
/// Calculate ProjFundingSource
/// </summary>
private void calculateProjFundingSource()
{
ProjJournalTrans projJournalTrans;
ProjTable projTable, parentprojTable;
ProjFundingSourceRefId fundingSourceRecId;
ProjFundingSource projFundingSourceLoc;
select firstonly ParentId, defaultfundingsource, ProjInvoiceProjId from projTable
join ProjId from projJournalTrans
where projTable.ProjId == projJournalTrans.ProjId
&& ProjJournalTrans.JournalId == projJournalId
&& ProjJournalTrans.LineNum == 1;
select firstonly ProjInvoiceProjId, ProjGroupId, defaultfundingsource from parentprojTable
where parentprojTable.ProjId == projTable.ParentId;
fundingSourceRecId = projTable.DefaultFundingSource != 0 ? projTable.DefaultFundingSource : parentprojTable.DefaultFundingSource;
if (!fundingSourceRecId)
{
select firstonly projFundingSourceLoc
where projFundingSourceLoc.ContractId == projTable.ProjInvoiceProjId
&& projFundingSourceLoc.CustAccount == projTable.CustAccount
&& projFundingSourceLoc.FundingType == ProjFundingType::Customer;
if (projFundingSourceLoc)
{
fundingSourceRecId = projFundingSourceLoc.RecId;
}
}
if (fundingSourceRecId)
{
ProjFundingSource projFundingSourceFin = ProjFundingSource::find(fundingSourceRecId, false);
if (projFundingSourceFin.RecId)
{
projFundingSource = projFundingSourceFin;
}
}
}
}
The class tries to identify the correct funding source by following the project hierarchy.
The logic performs the following steps:
- Retrieves the project associated with the journal.
- Checks whether the project has a Default Funding Source.
- If not found, checks the parent project.
- If still unavailable, searches for a customer funding source associated with the project’s invoice project.
- Loads the corresponding
ProjFundingSourcerecord.
This makes the component reusable in most scenarios without requiring the caller to know the funding source in advance.
Creating the Invoice Proposal
Once both the JournalId and Funding Source are available, the process delegates the actual creation to a custom helper class:
ProjInvoiceProposalCreateExt::createDataInTmp(projFundingSource.RecId, projJournalId);
This class reproduces the standard Invoice Proposal creation process while allowing the proposal to be generated directly from the transactions belonging to the selected Fee Journal.
The implementation performs the following operations:
- Creates the standard
ProjInvoiceProposalCreateLinesobject. - Retrieves the voucher associated with the journal.
- Reads all
ProjRevenueTransrecords generated by that voucher. - Finds the matching
ProjRevenueTransSalerecords for the selected funding source. - Populates the temporary table
PSATmpProjProposalTrans. - Calls a custom extension of the standard
ProjInvoiceProposalInsertLinesclass to generate the proposal.
Because the implementation relies almost entirely on the standard framework, all standard business logic is preserved, including:
- Proposal line creation
- Revenue processing
- Proposal totals calculation
- Retention calculation
- Standard project validation
/// <summary>
/// ProjInvoiceProposalCreateExt class
/// </summary>
class ProjInvoiceProposalCreateExt extends projInvoiceProposalInsertLines
{
public void createInvoiceProposals(PSATmpProjProposalTrans _tmpProjProposalTrans)
{
int i;
ProjProposalJour proposalJour;
ProjTable projTable;
this.progressInit("@SYS54552", 0);
while select _tmpProjProposalTrans
{
if (i == 0)
{
this.parmDefaultDimension(_tmpProjProposalTrans.DefaultDimension);
this.setProjProposalJour(_tmpProjProposalTrans.ProjInvoiceProjId, _tmpProjProposalTrans.ProjId,
_tmpProjProposalTrans.FundingSourceRefId, _tmpProjProposalTrans.CurrencyCode,
_tmpProjProposalTrans.TaxInformation_IN, _tmpProjProposalTrans.FixedExchRate);
i++;
}
ttsbegin;
this.setProjProposalJourPost(_tmpProjProposalTrans);
select firstonly RecId from projTable
where projTable.ProjId == _tmpProjProposalTrans.ProjId &&
projTable.ProjInvoiceProjId == _tmpProjProposalTrans.ProjInvoiceProjId
exists join proposalJour
where proposalJour.ProjInvoiceProjId == projTable.ProjInvoiceProjId &&
proposalJour.ProposalId == projProposalJour.ProposalId;
if (projTable.RecId)
{
this.doRevenue(_tmpProjProposalTrans.ProjInvoiceProjId, _tmpProjProposalTrans.RefRecId);
}
if (!this.parmSkipRecalculateProposalTotals())
{
this.updateInvoice();
// When an invoice proposal is generated, the system will
// automatically create retainage withholding records and/or retainage billing records at the project level.
this.calcRetention();
}
ttscommit;
}
}
public static ProjProposalId createDataInTmp(ProjFundingSourceRefId _fundingSource, JournalId _journalId)
{
ProjInvoiceProposalCreateLinesParams proposalCreateLinesParams = ProjInvoiceProposalCreateLinesParams::construct();
ProjInvoiceProposalCreateLines proposalCreateLines;
ProjInvoiceProposalInsertLines projInvoiceProposalInsertLines;
ProjRevenueTrans projRevenueTrans;
ProjRevenueTransSale projRevenueTransSale;
PSATmpProjProposalTrans tmpProjProposalTrans;
ProjJournalTrans projJournalTrans;
TransDate invoiceDate = DateTimeUtil::getToday(DateTimeUtil::getUserPreferredTimeZone());
ProjProposalId proposalId;
proposalCreateLinesParams.parmInvoiceDate(invoiceDate);
proposalCreateLinesParams.parmInvoiceTypeSelection(ProjInvoiceTypeSelection::Both);
proposalCreateLinesParams.parmIncludeSubProjects(true);
proposalCreateLinesParams.parmIsQueryRevenueTrans(true);
proposalCreateLines = ProjInvoiceProposalCreateLines::newStandard(proposalCreateLinesParams.pack());
proposalCreateLines.parmProposalCreateLinesParams().parmInvoiceDate(invoiceDate);
projInvoiceProposalInsertLines = new ProjInvoiceProposalInsertLines(proposalCreateLines, false);
select firstonly Voucher from ProjJournalTrans
where ProjJournalTrans.JournalId == _journalId;
while select projRevenueTrans
where projRevenueTrans.VoucherJournal == projJournalTrans.Voucher
{
select firstonly projRevenueTransSale
where projRevenueTransSale.TransId == projRevenueTrans.TransId
&& projRevenueTransSale.FundingSource == _fundingSource;
if (!projRevenueTransSale.RecId)
{
continue;
}
tmpProjProposalTrans.clear();
tmpProjProposalTrans.initFromProjRevenueTrans(projRevenueTrans);
tmpProjProposalTrans.FundingSourceRefId = _fundingSource;
tmpProjProposalTrans.LineAmount = projRevenueTransSale.LineAmount * -1;
tmpProjProposalTrans.SalesPrice = projRevenueTransSale.SalesPrice;
tmpProjProposalTrans.Selected = true;
tmpProjProposalTrans.RefRecId = projRevenueTransSale.RecId;
tmpProjProposalTrans.RefTableId = projRevenueTransSale.TableId;
tmpProjProposalTrans.insert();
}
new ProjInvoiceProposalCreateExt(proposalCreateLines, false).createInvoiceProposals(tmpProjProposalTrans);
select firstonly projRevenueTrans
where projRevenueTrans.VoucherJournal == projJournalTrans.Voucher
join projRevenueTransSale
where projRevenueTransSale.TransId == projRevenueTrans.TransId
&& projRevenueTransSale.FundingSource == _fundingSource;
if (projRevenueTrans.RecId && projRevenueTransSale.RecId)
{
proposalId = ProjTrans::newProjRevenueTransSale(projRevenueTrans, projRevenueTransSale).proposalId();
}
return proposalId;
}
}
Benefits of This Approach
Encapsulating the entire logic inside a dedicated processing class provides several advantages:
- Only the JournalId is required.
- The Funding Source can be calculated automatically.
- Standard Microsoft business logic is reused instead of duplicated.
- The solution is easy to invoke from X++, batch jobs, custom services, or integrations.
- The generated Proposal ID is immediately available for subsequent processing.
Conclusion
Generating an Invoice Proposal from a Fee Journal isn’t directly exposed through a simple standard API, but the underlying framework already contains almost everything required.
By wrapping the standard classes inside a reusable processing component, it’s possible to automate the entire process while keeping the implementation clean, maintainable, and aligned with the standard application.
This approach has proven particularly useful in integration scenarios where invoice proposals must be generated automatically without user interaction.