11. Cash/Bank GL-Currency Linking
This chapter closes the gap tracked in Chapter 5 §5.2
and Chapter 10 §10.4: a tenant's default
CashAccount/BankAccount (this framework) can now be linked to the correct GL account and currency —
dbo.CashAccountCurrencies/dbo.BankAccountCurrencies — once an accounting chart template has been
applied for that tenant (see
Tenant Starter Kit & Product Catalog, Chapter 6 — Accounting Chart Templates).
Never creates a GL account, never creates a CashAccount/BankAccount, never posts a journal entry,
voucher, or opening balance — only ever creates/updates a CashAccountCurrency/BankAccountCurrency
configuration row.
11.1 Why this reuses the existing mapping-apply pipeline, not a new bridge
Investigation (see the accounting-chart-template mapping-apply flow docs) found that MultiTenancyApi's
TenantAccountingChartTemplateMappingApplyService already forwards every AccountingChartTemplateMapping
row for a template — including the CashOnHand/BankAccount keys — to BackEnd's internal apply-mappings
endpoint, unfiltered. Only BackEnd's AccountingChartTemplateMappingApplyService decides which
MappingKey values have a confirmed destination (SupportedDestinations). Before this phase, only 4 of
12 keys (Inventory, CostOfGoodsSold, SalesRevenue, SalesReturn) resolved anywhere — the rest,
including CashOnHand/BankAccount, were reported Unsupported.
This phase extends exactly that one table (and its per-item write logic) with two new destination types —
CashAccountCurrency and BankAccountCurrency — instead of building a parallel bridge, internal endpoint,
or entitlement gate. No new MultiTenancyApi↔BackEnd bridge was introduced. The existing
apply-mappings internal endpoint, its X-Shumoul-Internal-Key auth, and its Features.AccountManagement
entitlement gate are all reused unchanged.
11.2 New MultiTenancyApi service: ITenantCashBankAccountingLinkService
A thin orchestration wrapper (CashBankAccountingLink/ folder), not a new bridge:
- Resolves which
AccountingChartTemplateIdto ask about — either the caller-provided one (root-admin controller, orTenantStarterKitService's own already-resolved template for the same run), or, when none is given (onboarding's best-effort call), the tenant's last successfully-applied template viaTenantAccountingChartTemplateApplyLog(Status == "Success", most recentCreatedOn). No such reader existed before this phase — these logs were previously write-only audit trails. - Checks
Features.AccountManagemententitlement (same key every accounting apply flow already uses). - Checks the resolved template actually has a
CashOnHand/BankAccountmapping row at all — if not, reportsNoMappingFoundand does nothing. - Delegates to the existing, unchanged
ITenantAccountingChartTemplateMappingApplyService.PreviewAsync/ApplyAsync— the same callTenantStarterKitService'sAccountingChartMappingsstep already makes. - Filters the returned
Itemsdown to onlyMappingKeyin{"CashOnHand", "BankAccount"}for its ownTenantCashBankAccountingLinkResult— every other mapping key result (Inventory/COGS/etc.) is ignored, not duplicated or re-processed.
Wrapper-level Status values (TenantCashBankAccountingLinkStatus): NotApplicable (tenant not
found/inactive), NotEntitled, NoChartApplied, NoMappingFound, Attempted (delegated — see each
item's own status for the real outcome).
11.3 BackEnd: AccountingChartTemplateMappingApplyService extension
SupportedDestinations gained two entries:
["CashOnHand"] = (AccountingChartTemplateApplyMappingDestinationType.CashAccountCurrency, null),
["BankAccount"] = (AccountingChartTemplateApplyMappingDestinationType.BankAccountCurrency, null),
Per-item resolution (only when the request actually carries one of these two keys, to avoid an unnecessary settings-cache refresh on every ordinary Inventory-only mapping apply):
- Default
CashAccount/BankAccount— resolved viaCashierAppsSettings.CashAccountId/BankAccountId(the exact Guid the Tenant Operational Defaults phase already wires). If null →MissingDependency("apply Tenant Operational Defaults first"). - Default currency — the tenant's single active
Is_DefaultCurrencyrow. If none →MissingDependency. - AccountCode →
Accountresolution — the sameaccountsByCodedictionary every other mapping key already uses (no new duplicate lookup). - Idempotency key —
(CashAccountId, CurrencyId)/(BankAccountId, CurrencyId), matched against non-deletedCashAccountCurrency/BankAccountCurrencyrows fetched trackable up front (same pattern as the existingInventoryAccountCategorytrackable fetch):- No existing row → create it (
DryRunreportsWouldApply). - Existing row, same
AccountId→SkippedAlreadySet. - Existing row, different
AccountId→Conflict— never overwritten, regardless ofForce. Both entity schemas makeAccountIda non-nullableGuid, so "existing row with a missingAccountId" cannot occur — the schema itself rules out that scenario from Section 8 of the original task spec.
- No existing row → create it (
- Writes share the same transaction as the existing
InventoryAccountCategorysave — if either kind of write is dirty, the transaction opens; a save failure rolls back and flips everyApplieditem (regardless of destination type) toFailed, unchanged from the existing rollback behavior.
AccountingChartTemplateApplyMappingResultItem gained three optional fields, populated only for these two
destination types: CurrencyCode, CashAccountName, BankAccountName.
11.4 Integration points
- Tenant Operational Defaults apply (
OnboardingService.ApplyRecommendationAsync) — a new best-efforttry/catchblock, mirroring the existing operational-defaults block, callsITenantCashBankAccountingLinkService.ApplyAsync(session.TenantId)immediately after operational defaults apply. ANoChartApplied/NoMappingFound/NotEntitledresult is expected and non-fatal — no new GL account is ever created during onboarding, and a failure here never blocks the settings-patch apply that follows. TenantStarterKitService— a new step,CashBankAccountingLink, inserted between the existingAccountingChartMappingsandInventoryAccountCategorysteps. It reuses the samechartTemplatealready resolved for theAccountingChartAccounts/AccountingChartMappingssteps in the same run — it does not re-resolve "last successful apply" via the log reader (unnecessary — the template is already known). It shares the same hard dependency onAccountingChartAccountshaving succeeded. Important:TenantOperationalDefaultsis a separate module the Starter Kit does not orchestrate (confirmed by inspection — no reference to it exists inTenantStarterKitService). This new step does not add that integration either; if the tenant has no defaultCashAccount/BankAccountyet when this step runs, every item simply reportsMissingDependency— a soft, per-item outcome, never aBlockedstep.- Root-admin manual controller —
TenantCashBankAccountingLinkController(api/Saas/TenantCashBankAccountingLink/Preview|Apply), gated by the newTenancyPermissions.TenantCashBankAccountingLink.View/.Applypermissions (added toMenuClaims.jsonon the BackEnd side too — permission seeding covers both repos).
11.5 Known issue — transient MissingDependency for a second shared-DB tenant in the same process (FIXED)
Live re-verification (see
Subscription Packages & Entitlements, Chapter 16 §16.7)
found that when two tenants share one physical database (Tenants.UsingSharedDb = 1) and are processed
sequentially in the same long-lived host process, the second tenant processed could transiently see a
stale/incorrect cached CashierAppsSettings value — surfacing as MissingDependency here even though
TenantOperationalDefaults had just created that tenant's CashAccount/BankAccount moments earlier in
the same request. Restarting the host process and processing that tenant first resolved it correctly,
confirming this was a same-process, cross-tenant IAppSettingService caching artifact — not a defect in
this chapter's logic.
Fixed — every AppSettings cache key is now tenant-scoped via a centralized IAppSettingCacheKeyBuilder;
see Subscription Packages & Entitlements, Chapter 17
for the full root cause and fix, live-reverified on this exact tenant pair (171994/793084).
11.5 Entitlement
Both CashOnHand and BankAccount linking are gated by the single existing Features.AccountManagement
entitlement check inside TenantAccountingChartTemplateMappingApplyService (unchanged) — no new feature
key was introduced. A tenant without this entitlement never reaches the point of resolving CashierAppsSettings
or writing any link; the whole apply-mappings call is blocked before the bridge is called, exactly the
same behavior the four pre-existing Inventory-related keys already had.
BankAccount linking specifically requires the tenant to also have a default BankAccount in the first
place — per Chapter 5 §5.3,
Starter/POS tenants never get a BankAccount provisioned (entitled only via Features.AccountManagement),
so BankAccount linking naturally reports MissingDependency for them — no separate entitlement check
was needed for that distinction.
11.6 Live verification (dev, tenant 555001 — the designated safe write-test tenant)
Performed directly against BackEnd's internal apply-mappings endpoint (same endpoint
TenantStarterKitService's AccountingChartMappings step already calls), targeting tenant 555001, which
already had an applied AccountingChartTemplate (RestaurantCafe_Saudi_USStyle_COA_v1) with real
CashOnHand→11101 / BankAccount→11102 mapping rows and matching dbo.Accounts codes from an earlier
phase's testing.
- Before any default
CashAccount/BankAccountexisted —apply-mappingswith both keys returnedMissingDependencyfor each, and confirmed via direct query that zeroCashAccountCurrency/BankAccountCurrencyrows exist. - Created a default
CashAccount+BankAccountfor the tenant via the existing, already-proven Tenant Operational Defaults internal endpoint (api/internal/tenant-operational-defaults/apply) —CreatedCount=2. - Re-ran
apply-mappings— both itemsApplied; confirmed via direct query that exactly oneCashAccountCurrencyrow (pointing atAccount.Code = 11101) and oneBankAccountCurrencyrow (pointing atAccount.Code = 11102) now exist, both correctly attributed (CreatedByresolved to the tenant's real adminApplicationUser). - Ran
apply-mappingsa second time — both itemsSkippedAlreadySet; row counts unchanged (still exactly one of each) — confirms idempotency, no duplicates. - No
InventoryAccountCategory, journal entry, voucher, or opening balance was created at any point.
No cleanup was performed — per this tenant's designated role (safe, persistent write-verification target
across several prior phases), the created CashAccount/BankAccount/link rows are left in place as
expected test-tenant state, matching how earlier phases treated the same tenant.
11.7 Tests
- MultiTenancyApi (
TenantCashBankAccountingLinkServiceShould): entitlement gating (never calls the underlying service when not entitled),NotApplicablefor unknown tenant,NoChartAppliedwhen no successful apply log exists, resolving the most recent successful log while ignoring a more recent failed one,NoMappingFoundwhen the resolved template has neither key, filtering the delegated result down to only the two relevantMappingKeyitems, and thatPreviewAsyncnever calls the underlying service when there's nothing to preview. - BackEnd (
AccountingChartTemplateMappingApplyServiceTests, extended):CashOnHand/BankAccountnow resolve (no longer in the "every foundational key is Unsupported" theory);MissingDependencyfor missingCashAccount/BankAccount/defaultCurrency; link creation with the correctCashAccountId/BankAccountId/CurrencyId/AccountId;SkippedAlreadySet+ zero duplicates on re-apply;Conflictreported (and never overwritten, even withForce=true) when an existing link points at a different account;DryRunnever saves;CurrencyCode/CashAccountName/BankAccountNamepopulated on the result item; and that noInventoryAccountCategoryrow is ever touched by this key pair.
11.8 Package / release
Shumoul.Framework.MultiTenancy.Api bumped 1.0.121 → 1.0.122 (only the Api package — the Core
Shumoul.Framework.MultiTenancy package was not touched). Copied to both C:\MultiTenancy\ and
local-packages/; BackEnd's PackageReference updated to match.
11.9 Remaining gaps
- The wrapper's "resolve last successfully-applied template" reader is new — it is a plain
OrderByDescending(CreatedOn).FirstOrDefault()query, not a cached/indexed lookup. Fine at current scale (one row per apply attempt, per tenant); would need attention if apply-log volume grows significantly per tenant. — closed by the StarterKit Operational Defaults Orchestration phase:TenantStarterKitService's new step still has no technical dependency wiring toTenantOperationalDefaultsTenantOperationalDefaultsis now its own StarterKit step, positioned beforeCashBankAccountingLink, so a single StarterKit Apply provisions the defaultCashAccount/BankAccountand links them in the correct order — no manual pre-run/re-run sequencing is required by the operator anymore. Live-verified (see that chapter) against tenant 555001 through the real, authenticatedapi/Saas/TenantStarterKitshost.
Superseded — the previous entry about live verification only exercising BackEnd's internal endpoint
directly is no longer accurate: the StarterKit Operational Defaults Orchestration phase's live
verification went through the real, authenticated api/Saas/TenantStarterKits/Preview/Apply endpoints
end-to-end (see that chapter's own verification section).
