# Phase 9 — Finance and Operations apps (Finance, SCM, Commerce, HR)

> **Verification status.** Licensing discovery, template discovery and the full provisioning
> wizard were executed and verified against a real Caldova tenant (Aug 2026), through to a
> successful deployment kicking off. Post-deployment steps (demo-data verification, the
> Sandbox→Production conversion) were **not yet executed** — those are flagged inline.

F&O apps do not install like the CE apps in phase 2. Almost everything about them is
different, and the differences will cost you an afternoon if you meet them cold.

---

## The four things that will catch you out

### 1. F&O apps are environment *templates*, not installable apps

`D365_FinOps_Finance` and `D365_FinOps_SCM` are **environment templates**, applied
**only at environment-creation time**. You cannot add Finance or Supply Chain Management to
an existing Dataverse environment the way you install Sales or Customer Service.

**F&O therefore needs its own new environment.** That is also what you want — never deploy
F&O into the environment holding your CE apps, Customer Insights and reskinned demo data.

### 2. The demo-data template is Sandbox-only

> 🚨 **AND SELECTING IT IS NOT ENOUGH.** Verified Aug 2026: a deployment with the DemoHub
> All-in-One template selected and Type = Sandbox still produced an environment with **1 legal
> entity (`dat`), no USMF, and zero customers / vendors / products / main accounts** — while
> reporting `Create — Succeeded` throughout. **Always verify by legal entity after deployment.**
> Full detail and the 30-second check in "The DemoHub template can silently deploy an EMPTY
> environment" below.

> ## ⭐ ASK BEFORE YOU BUILD — Murray Fife maintains F&O templates
>
> **Murray Fife** — Sr Solution Engineer, AMI TMG Specialist Sales US — `{{USER_EMAIL}}`
> maintains a set of **Dynamics 365 F&O configuration / demo-data templates**, and **access is
> requested directly from him**. There is no self-service portal, alias or form.
>
> **Do this first.** If you land an empty F&O environment, the instinct is either to redeploy
> (~3h41m, and it can fail *again*) or to hand-build the foundation over OData (documented at length
> further down — it works, but it is a long tail of chart of accounts, posting profiles, number
> sequences and inventory posting). **A template short-circuits all of it.**
>
> Treat the OData build-from-scratch recipe in this file as the **fallback**, not the default.
>
> Checked Aug 2026: there was no prior correspondence with Murray in the mailbox and no documented
> request workflow anywhere in M365 — so expect to reach out cold, and **allow time for the ask**
> rather than discovering the need mid-build.


The biggest trap. The "Automatically deploy these apps" dropdown contains:

> **Finance and Operations DemoHub All-in-One**

— the Finance + SCM + Contoso demo-data bundle. It is **only offered when environment
Type = Sandbox**. Switch Type to Production and it silently vanishes:

| Environment Type | Options in dropdown | DemoHub All-in-One |
|---|---|---|
| Sandbox | 15 | **present** |
| Production | 14 | **absent** |

Under Production you get bare `Finance` and `Supply Chain Management` that deploy **without
demo data**. This is deliberate: demo data is a dev/test artifact, so Production F&O
environments come up empty.

**"Production" and "complete with demo data" are mutually exclusive at creation time.**

**Workaround:** deploy as **Sandbox with DemoHub All-in-One**, then convert **Sandbox →
Production** afterwards (same conversion as `reference/03-branding-and-polish.md`).
*Unverified for F&O specifically — well-established for CE. Confirm before promising it.*

### 3. Commerce is probably not available to you

On the tenant tested Commerce had **0 of 0 licences** — not "none assigned", none exist. It
was absent from both the template list and the provisioning dropdown.

Even with licences, Commerce requires a **Commerce Scale Unit** — separate Azure
infrastructure on top of the F&O environment. It is never a checkbox. Set expectations early.

### 4. Security group is a required field and blocks Save

The **Security group** field on the Add Dataverse page is marked `*` **required**, but ships
empty showing only a "Select" button. Leave it and **Save stays greyed out with no error
message explaining why.**

Fix: click **Select** → under **Open access**, choose **None**
("All users across your tenant will have access to the environment") → **Done**.

Easy to misread as the app dropdown being at fault. It is not — it is the security group.

---

## Step 1 — Check what the tenant actually licenses

Two independent checks. Run both; they answer different questions.

**Which environment templates exist:**

```powershell
pac admin list-app-templates -r unitedstates
```

The flag is `-r` / `--region`. `--environment-region` is rejected with an unknown-argument
error. Look for `D365_FinOps_Finance` / `D365_FinOps_SCM` with `Is Disabled = False`.

**How many user licences exist** — PPAC → **Licensing → Products → Finance and Operations**.
Reach it via the tab strip on the Capacity page; the direct URLs 404. Gives a per-app
Base/Attach assigned-vs-available table.

Result on the tenant tested:

| App | Base licences | Deployable |
|---|---|---|
| Supply Chain Management | 20 | yes |
| Finance | 20 | yes |
| Human Resources | 20 | licensed, but no template offered |
| Commerce | **0** | **no** |
| Project Operations (F&O) | **0** | **no** |

A template in `list-app-templates` does **not** imply licences — check both. Project
Operations appeared as an available template while having zero F&O licences.

---

## Step 2 — Provisioning walkthrough (verified)

PPAC → **Manage → Environments → New**.

| # | Field | Value | Notes |
|---|---|---|---|
| 1 | Type | **Sandbox** | set FIRST; Production hides DemoHub |
| 2 | Region | United States | match your tenant |
| 3 | Name | e.g. `Caldova F&O Demo` | |
| 4 | *Change default settings* | | expands the rest |
| 5 | Add a Dataverse data store? | **Yes** | Save button becomes **Next** |
| 6 | → **Next** | | second page ("Add Dataverse") |
| 7 | **Security group** | **None** (Open access) | **required — blocks Save if unset** |
| 8 | Enable Dynamics 365 apps? | **Yes** | **IRREVERSIBLE** |
| 9 | Automatically deploy these apps | **Finance and Operations DemoHub All-in-One** | |
| 10 | **Save** | | deployment starts |

### Wizard behaviours that bite

- **Set Type first.** Changing Type later **resets the whole form** — the name reverts to the
  `test-na-xxxxxxxx` default and the Dataverse toggle flips back to No.
- **Do not press Escape to close the app dropdown.** Escape closes the **entire New
  environment panel** and discards everything. Click the combobox itself to collapse it.
- **The app dropdown is multi-select and easy to over-select.** A stray click (e.g. landing on
  the listbox after the security-group dialog) can check *every* enabled app. Always re-read
  the combobox's `aria-label` before saving — it lists the current selection in full. It should
  read exactly `Finance and Operations DemoHub All-in-One` and nothing else.
- Option rows are checkboxes behind `<label>` elements that intercept pointer events. Clicking
  the option node directly times out; click the **label text**, or use a forced click.
- Nothing is created until the final **Save**. You can walk the whole wizard to inspect
  options and cancel safely; `Next` and `Back` are non-destructive.

### What "it worked" looks like

Save changes to `Loading...`, then the environment appears in the grid within ~1 minute:

| Environment | Type | State | Dataverse |
|---|---|---|---|
| Caldova F&O Demo | Sandbox | **Preparing** | No |

`Preparing` with Dataverse `No` is normal at the start — Dataverse flips to `Yes` and State to
`Ready` when provisioning completes.

---

## Step 3 — Expected timing

**✅ MEASURED END TO END (Aug 2026): 3 hours 41 minutes.**

| Event | Time |
|---|---|
| Environment created (`Create` operation start) | 11:04:38 AM |
| `Create` operation end / State → `Ready` | 2:45:49 PM |
| **Total** | **3h 41m** |

Result: State `Ready`, Dataverse `Yes`, `Recent operations → Create → Succeeded`. The environment
gets **two URLs** — a Dataverse one (`<name>.crm.dynamics.com`) and a **Finance and Operations
URL** (`<name>.operations.dynamics.com`). Both appear on the environment hub Details panel.

**Throughout those 3h41m there is no progress indication of any kind.** State stayed `Preparing`
and the "Last activity" column never moved off the creation timestamp until the moment it
completed. Intermediate observations: still `Preparing` at 37 min, still `Preparing` at 2h20m.

Do not expect it to be quick: F&O deploys AOS + SQL infrastructure on top of Dataverse. The
phase-2 rule (**never call it failed before 60 minutes**) is far too generous here — for F&O,
**budget half a day and check back**. Compare the CI – Data measurement in
`reference/06-customer-insights.md`: ~14 hours, ~13 of them silent.
**Silence is not failure, and a static "Last activity" timestamp is not evidence of a stall.**

A useful sanity check while waiting: the environment row for an F&O environment mid-provision is
**not clickable** in the PPAC grid (no hub page exists yet, because there is no Dataverse database).
That is expected, not a symptom. Once it completes, the row becomes a link.

### ⚠️ Signing in to the F&O client requires an interactive password

Opening `https://<name>.operations.dynamics.com/` triggers a **fresh password prompt** — *"Because
you're accessing sensitive info, you need to verify your password"* — even with an active browser
session against the same tenant. SSO and existing cookies do **not** carry over.

This is a hard stop for automation: it cannot be scripted, and it should not be. The operator has
to type the password. Plan for a manual step here, and do not build any unattended flow that
depends on reaching the F&O client.

Watch State in the PPAC environments grid rather than sitting on a spinner.

### ⚠️ `pac admin list` cannot see an F&O environment while it provisions

Verified 11:41 AM: PPAC showed **three** environments in this tenant; `pac admin list` returned
**one**.

| Environment | Type | State | Dataverse | In `pac admin list`? |
|---|---|---|---|---|
| Caldova D365 Demo | Production | Ready | **Yes** | ✅ yes |
| Caldova F&O Demo | Sandbox | Preparing | No | ❌ **no** |
| Caldova (default) | Default | Ready | No | ❌ **no** |

The pattern is exact: **`pac admin list` only returns environments that already have a Dataverse
database.** An F&O environment reports `Dataverse: No` for its entire provisioning window, so it
is invisible to `pac` from creation until Dataverse lands — precisely the window you want to
monitor.

Two consequences:

1. **Never use `pac admin list` to check whether an F&O deployment is running.** It will look like
   the environment was never created. Use the PPAC environments grid.
2. This is a **fifth** entry for the "`pac` lies" table in `SKILL.md`, and the most dangerous one,
   because the others returned wrong data while this one returns *nothing* — which reads as
   "creation failed" rather than "tool limitation".

A reliable scripted check, from the PPAC environments page, is the grid itself:

```js
// DevTools on https://admin.powerplatform.microsoft.com/manage/environments
const g = document.querySelector('[role="grid"]');
console.log(Array.from(g.querySelectorAll('[role="row"]'))
  .map(r => Array.from(r.querySelectorAll('[role="gridcell"]'))
    .map(c => c.innerText.replace(/\s+/g,' ').trim()).filter(Boolean).join(' | '))
  .join('\n'));
```

Returns Environment / Type / State / Dataverse / Created on for every environment, including the
ones `pac` omits.

---

## Post-deployment (verified)

1. ✅ **Verified:** the environment reaches **Ready** with Dataverse **Yes**, and the hub Details
   panel exposes both the Dataverse URL and a separate **Finance and Operations URL**
   (`<name>.operations.dynamics.com`). `Recent operations` shows `Create — Succeeded`.
2. ❌ **VERIFIED FAILURE: the DemoHub template did NOT apply.** See below.
3. To drop the SANDBOX badge, convert **Sandbox → Production** (`Convert to production` is on the
   environment hub command bar).
4. For CE ↔ F&O integration, configure **dual-write** — a separate exercise and the usual
   source of pain. It links the F&O environment to a Dataverse environment.

---

## 🚨 The DemoHub template can silently deploy an EMPTY environment

**This is the most important finding in this file.** Verified Aug 2026 on a deployment where the
**"Finance and Operations DemoHub All-in-One"** template *was* selected and environment Type *was*
Sandbox — exactly per the guidance above — and the result still contained **no Contoso demo data
whatsoever**.

### What "it worked" looked like

Everything upstream reported success:

- PPAC: State **Ready**, Dataverse **Yes**, `Recent operations → Create → Succeeded`
- F&O client loads normally, full module set present (Finance, SCM, HR, Commerce, Demand planning,
  Business performance analytics, Invoice capture…)
- No error surfaced anywhere at any point

### What was actually there

| Check | Result |
|---|---|
| Legal entities | **1** — `dat / Company accounts data` |
| **USMF** | **absent** |
| Customers · Vendors · Released products | **0 · 0 · 0** |
| Sales orders · Purchase orders | **0 · 0** |
| Main accounts (chart of accounts) | **0** |
| Workers | **0** |

An F&O environment with **zero main accounts** is not a demo environment. The apps deployed; the
data did not.

### How to check it in 30 seconds

The company code in the F&O banner is the first tell — a hydrated demo org opens on a real operating
company (**USMF** for the Contoso dataset), an empty one opens on **DAT | Company accounts data**.
`DAT` is the system/default company and exists everywhere, so *landing* on it proves nothing on its
own — confirm with a row count.

Then confirm via OData in the browser (already authenticated, no tooling needed):

```
https://<name>.operations.dynamics.com/data/LegalEntities?$top=50
```

A hydrated DemoHub org returns a dozen or so rows (USMF, USRT, DEMF, GBSI, INMF…). An empty
one returns exactly one row: `dat`. **Count the rows — don't look for a specific code**, since which
companies exist depends entirely on which dataset was deployed.

Row-count probes on any entity, `$count=true` gives a straight number:

```
/data/CustomersV3?$top=1&$count=true
/data/MainAccounts?$top=1&$count=true
/data/ReleasedProductsV2?$top=1&$count=true
```

⚠️ `$select` on `LegalEntities` with a guessed column name returns **HTTP 400**, not an empty
result. `DataArea` is wrong; the field is `LegalEntityId`. Query without `$select` first and read
the shape from the response.

> 🔴 **Do not "force a company that does not exist" to prove the point.** Passing an unknown
> `?cmp=` value sticks to the user's session and breaks every subsequent page load until you
> navigate back to `?cmp=DAT`. Full explanation in *Re-verified 2026-08-27* below. The OData row
> count above answers the same question with no side effects.

### If you hit this

The environment is a shell. Options, roughly in order of preference:

0. **Ask Murray Fife for a template** (`{{USER_EMAIL}}`) — see the starred box near the
   top of this file. This is the cheapest path by a wide margin and should be tried *before*
   spending ~4 hours on a redeploy that has already failed once.
1. **Delete and redeploy**, watching the template selection carefully — the app dropdown
   multi-selects and Type resets the form, both of which can silently drop the DemoHub choice
   (see "Wizard behaviours that bite"). Budget another ~3h41m.
2. **Restore from a demo-data backup** if your tenant offers one under Backup + Restore.
3. **Import demo data manually** via Data management — slow, and you must source the package.

There is **no post-hoc "apply DemoHub template" action** on an existing environment.

### The lesson

`Create — Succeeded` means *the environment provisioned*. It says nothing about whether the
**content** landed. This is the same class of error as everything in the "progress indicators lie"
table in `SKILL.md`: a green status on one layer being read as success on a different layer.

**Always verify F&O demo data by legal entity, not by deployment status** — and do it before you
spend three hours building a demo narrative on top of an empty org.


---

## Gotcha summary

- F&O = environment templates → **new environment required**, cannot be added to an existing one.
- DemoHub All-in-One → **Sandbox only**; Production deploys empty.
- **Security group is required** and silently disables Save. Set it to **None**.
- **Escape closes the whole panel**, not just the dropdown.
- The app dropdown **multi-selects**; verify the selection before saving.
- Set environment **Type first** — changing it resets the form.
- Commerce → check for **0 licences**; also needs a Commerce Scale Unit.
- A template in `list-app-templates` ≠ licences available. Check both surfaces.
- PPAC direct URLs for licensing/apps pages **404**; navigate via the tab strip.
- **`pac admin list` will not show the F&O environment at all** while it provisions — it only
  returns environments that already have a Dataverse DB. Absence there is not failure.
- **Budget ~3.5–4 hours** for provisioning (measured: 3h41m), with **zero progress indication**
  the entire time.
- Reaching the **F&O client requires an interactive password prompt** even with a live browser
  session. Not automatable — plan a manual step.
- 🚨 **Selecting DemoHub does not guarantee demo data.** Verify with
  `/data/LegalEntities?$top=50` — **more than one row**, plus a non-zero `MainAccounts` count. One
  row reading `dat` means the environment is empty and must be redeployed. `Create — Succeeded`
  does **not** cover content. (Don't test with `?cmp=USMF` — see the warning below.)

---

## Re-verified 2026-08-27 — and why we did not redeploy

A second look at the same environment confirmed it is still an empty shell, and turned up two
cheaper checks worth doing before you commit ~4 hours.

**The 10-second confirmation.** Query OData — it is read-only and has no session side effects:

```
https://<env>.operations.dynamics.com/data/LegalEntities?$top=50
→ a single row (`dat`) means no demo data was ever loaded
```

> ### 🔴 Do NOT use `?cmp=USMF` as a diagnostic
>
> An earlier version of this file recommended browsing to `…/?cmp=USMF` and reading the resulting
> error as proof. **Don't.** F&O *persists* the company parameter to your user's session, so passing
> a company that does not exist leaves the session pinned to it and every subsequent page load
> fails with:
>
> ```
> Your user account or request is associated with a company which does not exist in the system
> ```
>
> It reads like a broken environment or a permissions fault, when you simply poisoned your own
> session. **Recovery:** navigate explicitly to a company that does exist —
> `…/?cmp=DAT&mi=DefaultDashboard`. `DAT` is the system/default company and is present in every
> F&O environment, so it is always a safe target.
>
> Never pass a `cmp` value you have not first confirmed in `LegalEntities`.

### USMF is not universal — its absence is not a fault

`USMF` (Contoso Entertainment System USA) ships **only with Microsoft's F&O demo dataset**. An
environment deployed without that dataset — or any production-shaped deployment — will *never* have
it, and never should.

So do not treat "does USMF exist" as the pass/fail test. The question is **"is there any business
data at all"**:

| Signal | Empty shell | Has demo data |
|---|---|---|
| `LegalEntities` row count | **1** (`dat` only) | a dozen or so (`USMF`, `USRT`, `DEMF`, `GBSI`, `INMF`…) |
| `MainAccounts` `$count` | **0** | thousands |
| `CustomersV3` `$count` | **0** | hundreds |

**Zero main accounts is the cleanest single tell** — no real F&O environment, demo or production,
has an empty chart of accounts.

**PPAC tells you something too.** The environment showed **Type = N/A** and offered
**"Add Dataverse"** — this F&O environment has no Dataverse database at all, which is consistent
with `pac admin list` omitting it (see SKILL.md, "progress indicators lie").

**Backup + Restore was not available** on this environment, so option 2 in the list above was not
open to us.

> ⚠️ **Think before redeploying.** The failure documented above happened *with* DemoHub All-in-One
> selected *and* Type = Sandbox — i.e. everything already done correctly. Repeating the same
> deployment unattended has a real chance of producing the same empty environment and costing
> another 3h41m. If the Finance/SCM story matters, drive the template selection **interactively**
> and verify by legal entity before walking away, or raise support referencing the silent
> empty-deploy. Do not automate this one blind.

---

# The F&O MCP server (2026-08-27)

F&O ships a **Dynamics 365 ERP Model Context Protocol server**, so agents like Microsoft Scout,
Copilot Studio and VS Code can query the ERP directly. On a CDX demo environment it is **already on**
— there was nothing to enable.

## Where the setting lives

**System administration → Setup → Allowed MCP clients**, or deep-link straight to it:

```
https://<fno-env>.operations.dynamics.com/?cmp=DAT&mi=McpAllowedClient
```

The menu item name is **`McpAllowedClient`** (singular). The page carries the toolbar commands
`New`, `Delete`, `EnableAll`, `DisableAll`.

## Microsoft ships a seeded first-party allow-list

The Caldova environment already contained seven entries, every one `IsFirstParty: true`:

| Name | ClientId |
|---|---|
| Cowork | `<guid>` |
| CopilotStudio | `<guid>` |
| **Microsoft Scout** | **`<guid>`** |
| Finance Agent | `<guid>` |
| ClawPilot | `<guid>` |
| VsCode | `<guid>` |
| Finance Agent (Sydney) | `<guid>` |

Because these are first-party defaults, the identical list appears in *every* F&O demo environment.
**Check before you add anything** — a hand-typed duplicate row is the likely outcome otherwise.

Only add a `New` row for a **custom / third-party** agent registration.

## ⚠️ The grid renders empty for ~10 seconds

Reading `document.body.innerText` right after the page loads returns only the column headers
(`Name / ClientId / Allowed`) — the rows arrive later, and F&O **virtualizes grid cells** so
`[role="gridcell"]` comes back as empty strings even once they are visible.

That combination reads exactly like "no MCP clients are registered". Do not act on it.

**Verify over OData instead — it is authoritative and instant:**

```js
await fetch('/data/McpAllowedClients?$top=20', {credentials:'include'}).then(r=>r.json())
// → [{ ClientId, Allowed, IsFirstParty, ClientName }, …]
```

## Confirming the MCP server is actually running

Two unauthenticated probes settle it:

| Probe | Healthy response | Meaning |
|---|---|---|
| `GET /mcp` | **401** + `WWW-Authenticate: Bearer resource_metadata="…"` | Endpoint exists and is enforcing auth |
| `GET /.well-known/oauth-protected-resource` | **200** + JSON metadata | Server is provisioned |

A **404** on `/mcp` would mean genuinely not enabled. Caldova returned 401 + 200 — live.

Metadata returned:

```json
{
  "resource_name": "Dynamics 365 ERP MCP Server",
  "resource": "https://caldovaf-odemo.operations.dynamics.com/mcp",
  "authorization_servers": ["https://login.microsoftonline.com/<tenant>/v2.0"],
  "scopes_supported": ["https://<env>.operations.dynamics.com/mcp/mcp.tools",
                       "openid","profile","offline_access"],
  "code_challenge_methods_supported": ["plain","S256"]
}
```

So to point a client at it:

- **Endpoint** — `https://<env>.operations.dynamics.com/mcp`
- **Scope** — `https://<env>.operations.dynamics.com/mcp/mcp.tools`
- **Authority** — the **demo tenant**, not the corporate one

`/api/mcp` and `/mcp/v1` both 404. There is one path and it is `/mcp`.

## 🔴 …but the ERP behind it is still empty

Re-verified the same day the MCP server was confirmed working:

```
/data/LegalEntities   → 1 row: DAT — "Company accounts data"
/data/CustomersV3     → @odata.count = 0
```

A working MCP server over an empty ERP still demos nothing. **The transport being healthy says
nothing about the data**, and the two failure modes look identical from the client: an agent that
returns "no records found" could be blocked, mis-scoped, or simply pointed at an empty environment.

Prove the data exists first — **more than one row in `LegalEntities` and a non-zero `MainAccounts`
count** — *then* wire up the agent. Don't look for `USMF` specifically; it only exists if the
Contoso demo dataset was deployed.

---

# Building an F&O foundation from scratch over OData (2026-08-27)

The empty environment turned out to be an **opportunity, not a blocker**. Rather than a ~4 hour
redeploy that had already failed once, the whole financial foundation was authored directly through
the OData endpoint in about twenty minutes.

Caldova's two legal entities:

| Code | Name | Role |
|---|---|---|
| **USRT** | Caldova Retail USA | retail / D2C arm |
| **USMT** | Caldova Manufacturing USA | manufacturing arm |

## Just how empty "empty" is

Worth internalising before planning — the environment had **no master data whatsoever**:

```
Currencies       0     ← not even USD
FiscalCalendars  0
MainAccounts     0
CountryRegions   (entity set not even exposed)
Ledger           1 row (dat) with COA, calendar and currency all blank
```

So "create the legal entities first" is not actually the first step. **Currency comes first**, and
several reference tables you would assume exist simply do not.

## The real dependency chain

```
Currencies (USD)
   └─> Legal entities (USRT, USMT)
   └─> Fiscal calendar ──> fiscal years
   └─> Chart of accounts ──> main accounts
                         └─> account structure
                                └─> Ledger  (binds COA + calendar + accounting currency, per LE)
```

`Ledger` is last because it references everything else.

## 🔑 Four OData rules that make or break this

**1. Omit system RecId fields entirely — do not send `0`.**
`$metadata` marks `ChartOfAccountsRecId`, `MainAccountRecId` and `LedgerRecId` as
`Nullable="false"`, which reads as "required". Sending `0` fails hard:

```
403  insert not allowed for field 'ChartOfAccountsRecId'
```

Leave them out and F&O assigns them. **`Nullable="false"` in F&O metadata does not mean
"you must supply it" — it often means "the system owns it."**

**2. Some non-key fields are also insert-blocked.** `Ledger.Name` derives from the legal entity:

```
403  insert not allowed for field 'Name'
```

**3. Read `$metadata` before guessing field names.** The first `Currencies` POST failed inside the
*deserializer* (`AxODataEntityDeserializer`, a `TargetInvocationException`) — an unhelpful stack
trace that actually just meant "those fields don't exist." `Symbol` and `RoundOffRuleAmount` were
invented; the real ones are `GeneralRoundingRule`, `RoundingRulePrices`,
`RoundingRuleSalesOrders`, `RoundingRulePurchaseOrders`,
`RoundingRuleFixedAssetDepreciation`, `DecimalsCount_MX`.

The metadata document is **~52 MB** — fetch it *once*, cache it on `window`, and regex out the
`<EntityType>` blocks you need. Also map `EntityType` → `EntitySet`, because they differ
(`FiscalCalendarEntity` → **`FiscalCalendarsEntity`**).

**4. Watch for duplicate entities with the same concept.** `FiscalCalendars` is a *date-level*
projection needing RecIds and is useless for creation. The creatable one is
**`FiscalCalendarsEntity`** (`CalendarId` only). Same for years: **`FiscalCalendarYearsEntity`**
(`FiscalCalendar_CalendarId`, `Name`, `StartDate`, `EndDate`).

## Working payloads

```jsonc
// POST /data/Currencies
{ "CurrencyCode":"USD","Name":"US Dollar","GeneralRoundingRule":0.01,
  "RoundingRulePrices":0.01,"RoundingRuleSalesOrders":0.01,
  "RoundingRulePurchaseOrders":0.01,"RoundingRuleFixedAssetDepreciation":0.01,
  "DecimalsCount_MX":2 }

// POST /data/LegalEntities   — NOTE: no AddressState (see trap below)
{ "LegalEntityId":"USRT","Name":"Caldova Retail USA","NameAlias":"Caldova Retail",
  "AddressLatitude":0,"AddressLongitude":0,
  "AddressValidFrom":"1900-01-01T00:00:00Z","AddressValidTo":"2154-12-31T23:59:59Z",
  "StartDateOfBusiness":"2020-01-01T12:00:00Z",
  "PrintCorrectiveInvoiceStartingDate":"1900-01-01T12:00:00Z",
  "InitialCapitalInvestment":0 }

// POST /data/FiscalCalendarsEntity
{ "CalendarId":"Caldova","Description":"Caldova fiscal calendar (calendar year)" }

// POST /data/FiscalCalendarYearsEntity
{ "FiscalCalendar_CalendarId":"Caldova","Name":"FY2026",
  "StartDate":"2026-01-01T12:00:00Z","EndDate":"2026-12-31T12:00:00Z" }

// POST /data/ChartOfAccounts        (RecId omitted)
{ "ChartOfAccounts":"Caldova","Description":"Caldova shared chart of accounts" }

// POST /data/MainAccounts           (RecIds omitted)
{ "ChartOfAccounts":"Caldova","MainAccountId":"110100","Name":"Cash and cash equivalents",
  "MainAccountType":"Asset",
  "ActiveFrom":"1900-01-01T12:00:00Z","ActiveTo":"2154-12-31T12:00:00Z" }

// POST /data/Ledgers                (no Name, no RecIds)
{ "LegalEntityId":"USRT","ChartOfAccounts":"Caldova",
  "FiscalCalendar":"Caldova","AccountingCurrency":"USD" }
```

`MainAccountType` accepts `Asset` · `Liability` · `Equity` · `Revenue` · `Expense`.

## 🔴 Trap — address fields validate against master data that isn't there

Setting `AddressState:'IL'` fails:

```
Warning: The value 'IL' in field 'State' is not found in the related table 'States'.
```

On a blank environment the geography tables are unpopulated, so **any** state code is rejected.
`AddressCity` / `AddressZipCode` / `AddressStreet` are free text and fine — it is specifically the
validated lookups (`State`, and likely `CountryRegionId`) that fail. **Omit them.** Address detail
is cosmetic for a demo and not worth loading the geography master data over.

## 🔴 Trap — OData is company-scoped

`GET /data/Ledgers` returns only the **current company's** row, so a freshly-created company's
ledger looks missing when it is merely out of scope. Append **`?cross-company=true`** to see all of
them. Applies to every company-scoped entity.

## ⚠️ OData legal-entity creation does NOT fully initialise a company

Creating the legal entity over OData writes the org records but **does not run the initialisation
the Legal entities form performs**. Concretely, `Ledger` rows were **not** auto-created — they had
to be POSTed explicitly. Assume the same for number sequences, posting profiles and other
per-company setup: check, don't assume.

The company *is* real and usable — `…/?cmp=USRT&mi=DefaultDashboard` loads and the banner reads
**USRT | Caldova Retail USA**.

## ⚠️ Two finishing steps OData will not do for you

Both are UI-driven and neither has a clean write path:

1. **Fiscal periods are not generated.** `FiscalCalendarYearsEntity` creates the *year* only —
   `LedgerFiscalPeriods` stayed at **0**. The UI's *New fiscal year* action is what fans a year out
   into 12 monthly periods. Generate them at
   **General ledger → Calendars → Fiscal calendars**.
2. **The account structure is created in `Draft`.** `POST /data/AccountStructures` accepts a name
   and description but leaves `SegmentName01..11` empty. Configure and activate it at
   **General ledger → Chart of accounts → Structures → Configure account structures**
   (menu item **`DimensionConfigureAccountStructure`**).

   Three things that surprised us there:

   - **`Validate` passes on a segment-less structure** — it reported
     *"Account structure Caldova P&L is OK."* despite `Segments` being blank in the grid. So a green
     validation is **not** evidence the structure is usable.
   - **`Activate` opens a flyout whose footer buttons live OUTSIDE `[role="dialog"]`.** Querying
     `dialog.querySelectorAll('button')` returns an empty array. Enumerate buttons at document level
     — note there are then *two* buttons reading `Activate` (toolbar and dialog); the dialog's is the
     later one in DOM order.
   - **Activation is a BATCH job, not a synchronous action.** The confirmation reads
     *"The Activate account structure Caldova P&L job is added to the batch queue"* and the grid
     **stays `Draft`** until the batch service runs it. On a freshly-deployed environment the batch
     service may be idle, so this can sit unprocessed. Check
     **System administration → Inquiries → Batch jobs** if the status does not flip.

   Because the status stays `Draft` either way, **do not read `Draft` as "the activation failed"** —
   read it as "the batch has not run yet".

**Until both are done you cannot post a journal entry.** The structure is correct and the data is
there — but "44 main accounts exist" is not the same as "the GL is postable", and it is an easy
thing to declare finished too early.

## What Caldova ended up with

| Object | Result |
|---|---|
| Currency | USD |
| Legal entities | **USRT** (Caldova Retail USA), **USMT** (Caldova Manufacturing USA) |
| Fiscal calendar | `Caldova` + FY2025 / FY2026 / FY2027 |
| Chart of accounts | `Caldova` — **44 main accounts** |
| ↳ by type | 10 Asset · 6 Liability · 2 Equity · 7 Revenue · 19 Expense |
| Account structure | `Caldova P&L` — validated OK, **activation queued to the batch service** |
| Ledger — USRT | COA `Caldova` · calendar `Caldova` · currency `USD` |
| Ledger — USMT | COA `Caldova` · calendar `Caldova` · currency `USD` |

The chart of accounts is deliberately **CPG-shaped** rather than generic — it carries
`210300 Trade promotion accrual`, `420100 Trade promotions and deductions`,
`420300 Slotting and listing fees` and `620200 Trade marketing`, alongside a three-stage inventory
(`raw materials` / `WIP` / `finished goods`) that supports the USMT manufacturing story and a
revenue split across `retail` / `wholesale and club` / `ecommerce D2C`.

## Posting profiles — the hooks that route subledgers to the GL

A chart of accounts and a configured ledger are **not** enough to post. Every subledger
(AR, AP, inventory, tax) needs *account determination* telling it which main account to hit.
Without these, transactions either fail to post or land in a suspense account.

### What OData can do

Company-scoped entities — **`dataAreaId` is part of the key**, so pass it in the payload and you can
seed every company from one session without switching the F&O company context:

```jsonc
// POST /data/CustomerPostingProfiles
{ "dataAreaId":"usrt","PostingProfile":"GEN",
  "Description":"Caldova general customer posting profile" }

// POST /data/CustomerPostingProfileLines   <- THE AR HOOK
{ "dataAreaId":"usrt","PostingProfile":"GEN",
  "AccountCode":"All","AccountOrGroupNumber":"",
  "SummaryMainAccountIdDisplayValue":"110200",
  "Description":"All customers - AR summary" }

// POST /data/LedgerAutomaticTransactionAccounts
{ "dataAreaId":"usrt","PostingType":"ErrorAccount","MainAccountIdDisplayValue":"990100" }
```

Enum values worth knowing:

- `AccountCode` → **`Table`** (one customer) · **`GroupId`** (a customer group) · **`All`**.
  For `All`, pass `AccountOrGroupNumber:""` — it is a required field but takes an empty string.
- `PostingType` on `LedgerAutomaticTransactionAccounts` has **246 members**. The ones that matter
  for a demo: `ErrorAccount`, `YearResult`, `CustBalance`, `VendBalance`.

Note the naming convention: main-account references on posting entities end in
**`...DisplayValue`** (`SummaryMainAccountIdDisplayValue`, `MainAccountIdDisplayValue`) and take the
**account number as a string**, not a RecId. That is much friendlier than it looks.

**Add a suspense account before wiring `ErrorAccount`** — a generic COA won't have one. Caldova uses
`990100 Suspense and error account`.

### 🔴 What OData CANNOT do — and it includes the important one

| Setup | OData entity? |
|---|---|
| Customer (AR) posting profiles | ✅ `CustomerPostingProfiles` / `...Lines` |
| Accounts for automatic transactions | ✅ `LedgerAutomaticTransactionAccounts` |
| Customer / vendor groups | ✅ `CustomerGroups` / `VendorGroups` |
| **Vendor (AP) posting profiles** | ❌ **none** — only `VendorGroups` is exposed |
| **Inventory posting (`InventPosting`)** | ❌ **none** — only `InventTransPosting*Bi/CDR` read projections |
| Sales tax posting groups | ⚠️ `TaxPostingGroups` exists, untested |

**Inventory posting is the biggest gap and the one that matters most for a manufacturer.** It is
where COGS, inventory receipt/issue, WIP, and production variance accounts are bound, and it has no
write entity. Do it at **Inventory management → Setup → Posting → Posting**.

Searching the metadata for `/PostingProfile/i` is misleading — it returns ten entity sets
(`FixedAssetPostingProfiles`, `RebateAndDeductionsPostingProfiles`, `PriceComponentCodePostingProfiles`…)
which makes it look like posting setup is broadly covered over OData. It is not. **Check for the
specific subledger you need**, because the two you most want for a CPG demo are exactly the two
that are missing.

### What Caldova has

Seeded identically for **USRT** and **USMT**:

| Hook | Value |
|---|---|
| Customer posting profile | `GEN` → AR summary **110200** (AccountCode `All`) |
| `ErrorAccount` | 990100 Suspense and error account |
| `YearResult` | 320100 Retained earnings |
| `CustBalance` | 110200 Accounts receivable - trade |
| `VendBalance` | 210100 Accounts payable - trade |

Customer groups — USRT: `RETAIL` · `WHOLESALE` · `DIRECT`; USMT: `INTERCO` · `WHOLESALE` · `EXPORT`
(the intercompany group carries the manufacturing-sells-to-retail story).
Vendor groups — both: `INGRED` · `PACKAGE` · `SERVICE`.

### Still required before you can post

1. **Account structure activation** (queued to batch — see above).
2. **Fiscal period generation.**
3. **Vendor posting profiles** — UI.
4. **Inventory posting setup** — UI, and mandatory for anything touching stock.
5. **Number sequences** — a company created over OData has none, and they gate customer / vendor /
   sales-order creation.

**Do not describe the GL as "done" until a test journal actually posts.** Each of the five items
above fails at a *different* layer with a *different* error, which makes partial setup genuinely
confusing to debug.

---

## ✅ CORRECTED ROOT CAUSE — "nothing can post" is NOT an idle batch service

An earlier version of this file blamed a stuck/idle **batch service** for the account structure
sitting at `Draft` and for `LedgerFiscalPeriods` being 0. **That was wrong.** Verified Aug 2026:

```js
// F&O OData, from the F&O origin
await (await fetch('/data/FiscalPeriods?$count=true&$top=1&cross-company=true',
  {headers:{Accept:'application/json'}})).json();
// -> count: 0
```

| Object | State |
|---|---|
| `FiscalCalendarsEntity` | **1** — the `Caldova` calendar exists |
| `FiscalCalendarYears` | **3** — FY2025, FY2026, FY2027 headers exist |
| **`FiscalPeriods`** | **0** ← the actual problem |
| `LedgerFiscalPeriods` | 0 (a consequence, not the cause) |

**Creating a fiscal year over OData creates the year header and nothing inside it.** The monthly
periods are generated by the *form's* business logic, not by the entity. A year with no periods
yields no ledger periods, and with no open period **nothing can post** — which is why the GL looks
broken even though the chart of accounts is fine.

### You cannot backfill the periods over OData

```
POST /data/FiscalPeriods {}
-> "Field 'End date' must be filled in.; Field 'Period name' must be filled in.;
    Field 'Period start' must be filled in.; Field 'Calendar' must be filled in.;
    Field 'Fiscal year' must be filled in."

POST /data/FiscalPeriods { Calendar, FiscalYear, PeriodName, PeriodStartDate, EndDate }
-> System.Reflection.TargetInvocationException
```

The first error looks like a normal required-field list and tempts you into guessing payloads. The
second is an **internal X++ exception**, which is the tell that the entity will not accept a
standalone insert at all. Stop guessing payloads at that point.

### The fix is the form

**General ledger → Calendars → Fiscal calendars** — menu item **`mi=FiscalCalendars`**.

> **Menu item names:** `FiscalCalendars` ✅. `LedgerFiscalCalendar`, `BatchJobListPage` ✗ — both
> return *"The menu item with name … does not exist."* The batch jobs form is **`mi=BatchJob`**.

On that form: select the calendar → **delete the three empty year headers** → **`New year`**, which
generates the 12 monthly periods per year. The form also exposes `Divide period` and
`Create closing period`.

Automation warning: clicks on this form land on the **Action Center** rather than the grid, so drive
it by hand or with careful accessibility refs.

### Do not stop at the calendar

Fixing the periods only unblocks *posting*. The ERP is still empty:

| Entity | Count |
|---|---|
| `CustomersV3` · `VendorsV2` · `ReleasedProductsV2` | 0 · 0 · 0 |
| `SalesOrderHeadersV2` · `PurchaseOrderHeadersV2` | 0 · 0 |
| `Warehouses` · `OperationalSites` | 0 · 0 |
| `LedgerJournalLines` | 0 |

Building all of that by hand is a large effort. **Get a template** (see the starred box near the top
of this file) or redeploy the F&O environment with a demo dataset. The from-scratch OData GL recipe
below is genuinely useful for understanding the object model — it is not a fast path to a demoable
ERP.
