Skip to main content

Scenario: Post project transactions and invoice the project

Goal. Your time-tracking, expense, field-service or billing system records work against a Dynamics 365 project: hours worked by a worker, expenses paid, items consumed and fees charged. Lattice posts each batch as the matching project journal, and when it is time to bill, creates the project invoice proposal from the posted billable transactions and, if you ask, posts it as the customer invoice.

When to use. Time and material billing driven from an external system, or any integration that has to put cost and revenue on a project. Ledger-only entries go through General and intercompany journals. For the retry-safe calling pattern, see Post a journal exactly-once.

Prerequisites​

  1. Security. All five operations need privilege MGCLatticeProjectServiceMaintain, in duty MGCLatticeIntegrationServiceOperationsDuty (integration operator and administrator roles). Without it D365 refuses the call with HTTP 403.

  2. A project to post to. An In process project whose project group has the ledger posting setup for its transactions. To invoice it, the project must belong to a project contract with a funding source. USMF: project 000184 San Diego Subscriptions, a time and material project for customer US-001 on contract 000182.

  3. Journal names, one kind per operation, each with a voucher number sequence:

    OperationJournal kindWhere it is set upUSMF
    postHourJournalProject journal of type HourProject management and accounting > Setup > Journals > Journal namesHour
    postFeeJournalProject journal of type Revenuesame pageFee
    postExpenseJournalLedger journal of type CostGeneral ledger > Journal setup > Journal namesPrjJrn
    postItemJournalInventory journal of type ProjectInventory management > Setup > Journal names > InventoryProjItem
  4. Project categories of the matching type (Project management and accounting > Setup > Categories > Project categories): Hour, Cost, Item and Revenue categories respectively. USMF: Journeyman (hour), Travel (cost), ProjItem (item), Setup (revenue), all with the billable line property.

  5. For hour lines, the worker exists and has a resource (USMF 000001 Jodi Christiansen). For item lines, the item has on-hand where it is consumed (USMF D0001, site 1, warehouse 11). For expense lines, the account the expense is paid from allows manual entry (USMF 110180 Petty Cash).

  6. Lattice. A valid license (a never-registered environment is allowed through; an adverse verdict refuses posting with LicenseNotValid). If your system knows projects or items by its own keys, register them as cross-references and send ProjIdExternalKey (header or line) and line ItemIdExternalKey with ExternalSystem; see Cross-reference keys.

Steps​

The journal operations are on MGCLatticeProjectJournalService, the invoice operation on MGCLatticeProjectInvoiceService, both in service group MGCLatticeProjectServiceGroup. Send a POST with an OAuth 2.0 bearer token and the request in _request (see Trial and day-one guide). The four journal operations share one request contract: header ProjId is the default project of the lines (a line's ProjId overrides it), PostingDate the default line date. Each operation creates the journal, posts it, and answers success only when it is posted: DocumentId = PostedDocumentId = the journal number, message "Hour (Expense, Item, Fee) journal ... posted successfully."

  1. Post hours. One line per worker and category; Qty is the hours. WorkerPersonnelNumber becomes the line's resource. LinePropertyId (for example Billable) defaults to the project's line property setup; ActivityNumber is optional.

    POST https://<your-environment>/api/services/MGCLatticeProjectServiceGroup/MGCLatticeProjectJournalService/postHourJournal
    {
    "_request": {
    "RequestId": "1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
    "LegalEntityId": "USMF",
    "UserContext": "timesheets",
    "ProjId": "000184",
    "JournalNameId": "Hour",
    "Description": "Week 42 timesheet",
    "PostingDate": "2026-10-15",
    "Lines": [
    {
    "CategoryId": "Journeyman",
    "Qty": 8.0,
    "CostPrice": 75.00,
    "SalesPrice": 125.00,
    "CurrencyCode": "USD",
    "Description": "Installation work",
    "WorkerPersonnelNumber": "000001"
    }
    ]
    }
    }
  2. Post expenses. Each line debits the project for Qty x CostPrice (Qty defaults to 1; the project's ledger account comes from its posting setup) and credits OffsetMainAccountId, the account the expense was paid from, which every expense line needs. DocumentId is the ledger journal number.

    POST https://<your-environment>/api/services/MGCLatticeProjectServiceGroup/MGCLatticeProjectJournalService/postExpenseJournal
    {
    "_request": {
    "RequestId": "3d4e5f6a-7b8c-4d9e-8f0a-1b2c3d4e5f6a",
    "LegalEntityId": "USMF",
    "UserContext": "expenses",
    "ProjId": "000184",
    "JournalNameId": "PrjJrn",
    "Description": "Travel expenses",
    "Lines": [
    {
    "CategoryId": "Travel",
    "Qty": 1.0,
    "CostPrice": 350.00,
    "CurrencyCode": "USD",
    "Description": "Site visit travel",
    "OffsetMainAccountId": "110180"
    }
    ]
    }
    }
  3. Post item consumption. Each line issues Qty of ItemId onto the project. InventDimId names the inventory dimension (site and warehouse) the item is taken from; leave it out to use the item's default order settings. Inventory dimension ids differ per environment: look up the id of site 1 / warehouse 11 in yours (in USMF demo data it is typically 000031). DocumentId is the inventory journal number.

    POST https://<your-environment>/api/services/MGCLatticeProjectServiceGroup/MGCLatticeProjectJournalService/postItemJournal
    {
    "_request": {
    "RequestId": "5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c",
    "LegalEntityId": "USMF",
    "UserContext": "field-service",
    "ProjId": "000184",
    "JournalNameId": "ProjItem",
    "Description": "Parts used on site",
    "Lines": [
    { "CategoryId": "ProjItem", "ItemId": "D0001", "InventDimId": "000031", "Qty": 2.0, "CostPrice": 25.00, "CurrencyCode": "USD" }
    ]
    }
    }
  4. Post fees. A fee line carries revenue only: SalesPrice per unit, usually with CostPrice 0.

    POST https://<your-environment>/api/services/MGCLatticeProjectServiceGroup/MGCLatticeProjectJournalService/postFeeJournal
    {
    "_request": {
    "RequestId": "7b8c9d0e-1f2a-4b3c-8d4e-5f6a7b8c9d0e",
    "LegalEntityId": "USMF",
    "UserContext": "billing",
    "ProjId": "000184",
    "JournalNameId": "Fee",
    "Description": "Setup fee",
    "Lines": [
    { "CategoryId": "Setup", "Qty": 1.0, "CostPrice": 0, "SalesPrice": 1500.00, "CurrencyCode": "USD", "Description": "Project setup fee" }
    ]
    }
    }
  5. Create the invoice proposal and post it. createInvoiceProposal creates the proposal the way the periodic Create invoice proposals job does: from the project's posted, chargeable hour, expense, item, fee and on-account transactions up to InvoiceDate (default: today) that are not invoiced yet. With PostAfterCreate true it then posts the proposal as the project invoice.

    POST https://<your-environment>/api/services/MGCLatticeProjectServiceGroup/MGCLatticeProjectInvoiceService/createInvoiceProposal
    {
    "_request": {
    "RequestId": "9d0e1f2a-3b4c-4d5e-8f6a-7b8c9d0e1f2a",
    "LegalEntityId": "USMF",
    "UserContext": "billing",
    "ProjId": "000184",
    "InvoiceDate": "2026-10-31",
    "PostAfterCreate": true
    }
    }
  6. See whether it was posted. With PostAfterCreate true, Success true means the invoice was posted: the service checks that the invoice journal exists before it answers. DocumentId is the proposal, PostedDocumentId the invoice, and the message reads "Project invoice proposal ... created and posted as invoice ... for project 000184." The proposal and invoice numbers come from separate number sequences and can be equal, so tell the two outcomes apart by what you sent and by the message, not by comparing the ids. With PostAfterCreate false (or left out), only the proposal is created: DocumentId = PostedDocumentId = the proposal, message "Project invoice proposal ... created for project 000184."; review and post it in D365 on Project management and accounting > Project invoices > Project invoice proposals.

Bulk variants (postHourJournalBatch, postExpenseJournalBatch, postItemJournalBatch, postFeeJournalBatch, createInvoiceProposalBatch) take a _requests list. The project operations are not available for asynchronous submission; call them directly.

How to verify​

  • Each journal response: Success true, Status 3, DocumentId = PostedDocumentId = the journal number.
  • The invoice response: Success true, the proposal in DocumentId, the invoice in PostedDocumentId and the "created and posted as invoice" message.
  • In D365: the posted journals on the Hour, Expense, Item and Fee pages under Project management and accounting > Journals; the transactions on Project management and accounting > Transactions > Posted project transactions; the invoice on Project management and accounting > Project invoices > Project invoices.
  • Over OData: LatticeProjectPostedTransactions filtered on ProjectId and dataAreaId, with cross-company=true, lists the ledger postings of the project's transactions with category, date, amount and quantity.
  • If you subscribe to business events, Project invoice posted fires when the invoice is posted.
  • The calls: System administration > Inquiries > Lattice > Services > Service call log, or LatticeServiceCallLogs filtered on your RequestId.

When it goes wrong​

A failed request answers Success false, Status 4, an ErrorCode and a generic Message; the reasons are in Errors.

ErrorCodeTypical cause (text in Errors)What to do
ValidationFailed (1)No RequestId or unknown LegalEntityId; "JournalNameId is required."; "At least one journal line is required."; "Journal name ... was not found."; "Journal name ... is not a Project Hour journal (its type is ...)." (and the expense and item variants); "CategoryId is required on every ... journal line."; "Project category '...' was not found."; "Project category '...' is of type ...; a ... journal line needs a ... category."; "Worker '...' was not found (send the worker's personnel number)."; "OffsetMainAccountId is required on every Expense journal line..."; "ItemId is required on every Item journal line."; invoice: "ProjId is required."; "Project ... is not attached to an invoice project (contract), so no proposal can be created."Correct the request or the project setup, then resend.
DocumentNotFound (2)"Project '...' was not found." (journals) or "Project ... was not found." (invoice); "Item '...' was not found."Send an existing project or item of the legal entity.
DimensionValueNotFound (5)"Offset main account '...' could not be resolved in the chart of accounts."Use an account of the legal entity's chart of accounts.
ConfigurationMissing (11)"Journal name ... has no voucher number sequence configured." (expense journals)Set the voucher series on the journal name.
BusinessRule (4)D365 refused the post (no on-hand for the item, missing project posting setup, a closed period); "... journal ... was not posted: posting returned without posting it."; invoice: "No invoice proposal was created for project ... (invoice project ...): it has no posted, chargeable transactions up to ... that are not invoiced yet."; "Invoice proposal ... for project ... was created but posting it produced no invoice."See below.
PreviewUnsupported (10)PreviewMode true: project operations have no previewSend without PreviewMode. Nothing was run.
TransientRuntime (6)Infrastructure fault; IsRetryable trueResend with the same RequestId.

Failed posts leave nothing behind. When a journal operation fails after creating its journal, the unposted journal (project, ledger or inventory journal) is deleted, and the failed request releases its RequestId: fix the cause and resend the same request. When an invoice post fails, the proposal the request created is deleted and its transactions return to invoiceable, so resending the same request proposes them again.

Nothing to invoice. The proposal takes only transactions that are posted, chargeable (a billable line property), dated on or before InvoiceDate and not yet invoiced. Post the journals first, check their line property, or use a later InvoiceDate.

Replays and stranded requests work as for every posting service: a repeated RequestId returns the first result, and the reconciliation sweep resolves a journal request left in progress; see Post a journal exactly-once.

Reference​