diff --git a/src/Apps/NA/ShopifyNA/app/Architecture.md b/src/Apps/NA/ShopifyNA/app/Architecture.md new file mode 100644 index 00000000000..75e11654079 --- /dev/null +++ b/src/Apps/NA/ShopifyNA/app/Architecture.md @@ -0,0 +1,455 @@ +# Shopify Tax Matching Agent — Architecture & Design + +## Problem + +When Shopify orders are imported into Business Central, each order carries free-text tax line descriptions (e.g. "NEW YORK STATE TAX at 4%"). BC requires structured Tax Jurisdiction codes and a Tax Area to apply correct tax rules, accounts, and reporting. The standard connector attempts an address-based lookup, but when that fails the Tax Area remains blank and must be filled manually. + +This feature uses an LLM to automate the mapping from free-text Shopify tax descriptions to BC Tax Jurisdictions and Tax Areas. + +## Design Principles + +- **Minimal footprint**: Ships as a separate app. Requires a small set of additions to the standard connector (integration event, Tax Jurisdiction Code field on tax lines, Tax Area/Tax Liable/Tax Exempt fields on order header, MapTaxArea procedure). The Tax Matching Agent app hooks in via the integration event. +- **Sync, invisible**: Runs inline during order import with no user interaction. No AI dialog, chat, or wizard. +- **Fail-safe**: If the LLM call fails or returns bad data, the order proceeds unchanged — same as if the feature were disabled. +- **Admin-controlled**: Every creation action (jurisdictions, areas) requires explicit opt-in per shop. The feature itself requires both a per-shop toggle and Copilot AI Capabilities activation. + +## App Identity + +Tax Matching Agent ships as a **feature inside the `Shopify Connector NA` app** — a North America +connector-localization container (mirrors *Shopify Connector BE*) that can host additional NA-only +features. The feature source lives under `src/Tax Matching Agent/`. Built, tested and published **US +only** for now (add CA/MX when supported). The main Shopify Connector shows a notification prompting +US environments to install this app (see *Localization promotion* below). + +| Property | Value | +|----------|-------| +| App name | Shopify Connector NA | +| App ID | a1b2c3d4-e5f6-47a8-9b0c-1d2e3f4a5b6c | +| Folder | `src/Apps/NA/ShopifyNA` (feature under `app/src/Tax Matching Agent`) | +| Countries | US (add CA/MX when supported) | +| Object ID Range | 30470-30499 | +| Version | 29.0.0.0 | +| Target | OnPrem | +| Dependency | Shopify Connector (`ec255f57-31d0-4ca2-b751-f2fa7c745abb`) | + +## Object Inventory + +| ID | Type | Name | Purpose | +|----|------|------|---------| +| 30470 | Codeunit | Shpfy TMA Register | Capability registration + `OnRegisterCopilotCapability` subscriber | +| 30471 | Codeunit | Shpfy TMA Matcher | Core: gather data, call AOAI, parse response, apply matches | +| 30472 | Codeunit | Shpfy Tax Area Builder | Find existing or create new Tax Area from matched jurisdictions | +| 30473 | Codeunit | Shpfy TMA Events | `OnAfterMapShopifyOrder` subscriber — orchestrates the flow | +| 30474 | Codeunit | Shpfy Tax Match Function | `AOAI Function` interface — tool definition + passthrough Execute | +| 30475 | Codeunit | Shpfy TMA Install | Install trigger → registers capability (per database) + invokes the tag-guarded Shop-defaults backfill (per company) | +| 30478 | Codeunit | Shpfy TMA Upgrade | Upgrade trigger + shared `BackfillShopDefaults` — sets the tax config defaults on existing shops, guarded by an upgrade tag so it runs once per company (called from both install and upgrade) | +| 30470 | TableExtension | Shpfy TMA Shop | 4 config fields on `Shpfy Shop` | +| 30470 | PageExtension | Shpfy TMA Shop Card | "Tax Matching Agent" group on Shop Card | +| 30470 | EnumExtension | Shpfy TMA Cap. | `"Shopify Tax Matching Agent"` value on `Copilot Capability` enum (its space-stripped name `ShopifyTaxMatchingAgent` is the LLM-API COS/taxonomy key) | +| 30470 | PermissionSet | Shpfy TMA Matching | RIMD on tax tables + all codeunits; includes `Shpfy - Edit` | +| 30476 | Codeunit | Shpfy TMA Notify | Owns both review notifications (Sales Order + Shopify Order) and the review-page drills (`RunReviewForSalesHeader`, `OpenReviewForOrder`, single `RunReviewPage`). Notifications are stateless — no table | +| 30477 | Codeunit | Shpfy TMA Activity Log | Wraps `Activity Log Builder` chain for per-line + per-area AI audit entries | +| 30476 | TableExtension | Shpfy TMA Order Header | Per-order markers on `Shpfy Order Header`: `Tax Match Applied`, `Tax Match Reviewed`, `Tax Rate Conflict`, `Tax Match Incomplete`, and `Tax Match Low Confidence` (set when a match is not high confidence — e.g. a provisional-jurisdiction match forced low) | +| 30477 | TableExtension | Shpfy TMA Sales Header | `Shpfy Tax Match Applied` Boolean marker on `Sales Header` | +| 30480 | TableExtension | Shpfy TMA Order Tax Line | `Tax Jurisdiction Code` (Code[10], `TableRelation = "Tax Jurisdiction"`) — moved out of the connector since the connector itself does not read or write it | +| 30481 | TableExtension | Shpfy TMA Tax Jurisdiction | Provenance on `Tax Jurisdiction`: `Shpfy Created by Agent`, `Shpfy Verified`. Marks agent-auto-created jurisdictions as **provisional** until a human verifies them | +| 30470 | TableExtension | Shpfy TMA Shop | 5 config fields on `Shpfy Shop` (incl. `Tax Match Review Mode` — Always / Low Confidence Only / Never) | +| 30478 | PageExtension | Shpfy TMA Order Tax Lines | Adds the Tax Jurisdiction Code column to the standalone Tax Lines list, visible only when the parent order's shop has Tax Matching Agent enabled | +| 30471 | Page (Card) | Shpfy TMA Review | Per-order review summary: resolved Tax Area (with AI confidence indicator), ship-to context, and the tax lines ListPart (each line shows the item it taxes). Hosts the **Approve** action that sets `Tax Match Reviewed` | +| 30479 | Page (ListPart) | Shpfy TMA Order Tax Lines Part | Tax lines list embedded as a subform on the **Tax Match Review page** so the platform AI confidence indicator renders on the Tax Jurisdiction Code cell; `SetTaxLineFilter` scopes it to one order | +| 30479 | PageExtension | Shpfy TMA Order | Adds the review entry action (AI `SparkleFilled` icon) that opens the review page — captioned **Review and Approve Tax Match** while approval is pending, else **Review Tax Match** — and fires the actionable order-page review notification | +| 30476 | PageExtension | Shpfy TMA Sales Order | Read-only badge field, **Review Tax Match** action (opens the review page), `OnAfterGetCurrRecord` notification trigger | + +## Execution Flow + +``` +Shopify Order Import (standard connector) + | + v +OrderMapping.DoMapping() + |-- MapHeaderFields() or MapB2BHeaderFields() + | |-- MapTaxArea() (address-based lookup, respects Tax Exempt) + |-- Map order lines (items, tips, gift cards) + |-- OnAfterMapShopifyOrder event fires + | + v +TMA Events (30473) — Event Subscriber + |-- Guard: Result = true? + |-- Guard: Tax Area Code still blank? + |-- Guard: Tax Exempt = false? + |-- Guard: Shop.Get + Tax Matching Agent Enabled? + |-- Guard: Capability registered + active? + |-- Telemetry: log start + | + v +Tax Matcher (30471) — MatchTaxLines() + |-- Walk the order's tax lines — BOTH product-line tax lines (Parent Id = order line + | "Line Id") AND shipping-charge tax lines (Parent Id = "Shopify Shipping Line Id", + | iterated from Shpfy Order Shipping Charges): + | |-- Tax Jurisdiction Code = '' -> send to the LLM (unmatched) + | |-- Tax Jurisdiction Code set (from a prior run) -> carry into MatchedJurisdictions + | so the Tax Area is built from the order's COMPLETE jurisdiction set on a re-run + | (product lines first to preserve state -> ... -> city ordering; shipping jurisdictions + | usually duplicate product ones and are de-duplicated) + |-- Gather all BC Tax Jurisdictions (code + description) + |-- Build ship-to address context (country, state, city) + |-- Construct user prompt from template + | + v +CallLLMAndApplyMatches() + |-- Load system prompt from .resources + |-- Configure AOAI: GPT-4.1, temp=0, max_tokens=4096 + |-- Add tool: match_tax_jurisdictions (forced) + |-- GenerateChatCompletion() + |-- Parse function call response + | + v +ApplyMatches() + |-- For each match in response: + | |-- Skip if jurisdiction_code empty or low-confidence + no auto-create + | |-- Parse tax_line_id -> ParentId + LineNo + | |-- Validate jurisdiction exists, or create if auto-create enabled + | |-- ApplyAssignedJurisdiction(): write jurisdiction code to Shpfy Order Tax Line + | | (the match is always applied — the jurisdiction is correct) and: + | | |-- Resolve the tax line's Tax Group Code by owner: a product-line tax line uses + | | | the order line item's Tax Group Code; a shipping-charge tax line uses the Shop's + | | | Shipping Charges Account Tax Group Code. + | | |-- Rate-conflict check: if a Tax Detail bracket valid at the order date already + | | | EXISTS for (jurisdiction × that tax group) with a DIFFERENT rate than Shopify's, + | | | set HasRateConflict, log telemetry 0000UMR + a per-line "matched, but rate + | | | differs" entry, and leave the existing (admin-maintained) rate untouched. This + | | | applies equally to product-line and shipping-charge tax lines. + | | |-- Otherwise: seed a Tax Detail for (jurisdiction × that tax group) at Shopify's + | | rate if none exists. Each tax line (product or shipping) seeds its own bracket + | | from its OWN rate — there is no product-line-derived shipping inference. + |-- FixReportToJurisdictions() if >1 jurisdiction matched — points every matched jurisdiction + | whose Report-to is still blank at the state (this covers jurisdictions auto-created this + | run AND pre-existing ones that never had a rollup target, including the state itself, + | which reports to itself), so the Tax Area rolls up correctly; a jurisdiction that already + | has a Report-to (admin-maintained hierarchy) is left untouched + |-- Return matched jurisdiction list + HasRateConflict + | + v +TMA Events (30473): + |-- (A rate conflict no longer blocks matching — the jurisdiction is applied and the Tax + | Area is built as usual; the conflict is recorded on the order to force review.) + | + v +Tax Area Builder (30472) — FindOrCreateTaxArea() + |-- Search all Tax Areas for exact jurisdiction set match + |-- If found: use existing (WasCreated = false) + |-- If not found + Auto Create Tax Areas: create new area (WasCreated = true) + | |-- Code: {NamingPattern}{LowestJurisdiction} (e.g. SHPFY-MTATAX) + | |-- Collision: append -2, -3, ... up to -999 + | |-- Description: "Shopify - " + joined jurisdiction descriptions + | |-- Create Tax Area Lines with calculation order + |-- Set OrderHeader."Tax Area Code" + "Tax Liable" = true + | + v +TMA Events (30473) — HITL writes (after Tax Area resolved) + |-- Set OrderHeader."Tax Match Applied" = true + |-- Set OrderHeader."Tax Rate Conflict" = HasRateConflict + | (a conflict forces review in all review modes; telemetry 0000UMF logged) + |-- Shpfy TMA Activity Log (30477): + | |-- LogPerLineEntries — one Activity Log entry per tax line (matched, or on a rate + | | conflict a "matched, but rate differs" entry explaining the conflict) + | | (anchor = Shpfy Order Tax Line, field "Tax Jurisdiction Code") + | |-- LogTaxAreaEntry — one Activity Log entry on the Order Header + | (anchor = Shpfy Order Header, field "Tax Area Code") + | + v +Order continues through standard import pipeline + | + v +ShpfyProcessOrder.CreateHeaderFromShopifyOrder() + |-- Fires OnBeforeCreateSalesHeader event + | + v + TMA Events (30473) — OnBeforeCreateSalesHeader subscriber [BLOCKING GATE] + |-- If OrderHeader."Tax Match Applied" AND + | NOT OrderHeader."Tax Match Reviewed" AND + | (held by Tax Match Review Mode OR OrderHeader."Tax Rate Conflict" OR "Tax Match Incomplete"): + | |-- Tax Rate Conflict is the stored flag (field 30478), set at match + | | time when a matched line's BC Tax Detail rate differs from Shopify's and + | | refreshed on Approve — the single source of truth for the whole feature. + | |-- Handled := true (connector skips Sales Doc creation) + | |-- A rate-conflict order is held in all review modes; a normal successful + | match is held per the shop's review mode. + | |-- Order stays in pending-review state until the user opens the + | Tax Match Review page (via the Shpfy Order page action + | or the order-page notification) and clicks Approve + | + | (only when Handled = false — i.e. blocking off, or user already approved) + v + ShpfyProcessOrder propagates Tax Area Code + Tax Liable to Sales Header, + fires OnAfterCreateSalesHeader event + | + v + TMA Events (30473) — OnAfterCreateSalesHeader subscriber + |-- If OrderHeader."Tax Match Applied" -> true: + | |-- Set SalesHeader."Shpfy Tax Match Applied" = true + | (badge only — no notification queued; the Sales Order prompt + | is derived live on page open) + | + v + Review surfaces (all open the Tax Match Review page 30471): + - Shpfy Order page (30479): review entry action (Review and Approve + Tax Match when approval is pending, else Review Tax + Match) + an + actionable "Review" notification fired on open (once per order/session) + while the order is matched and not yet reviewed. + - Sales Order page (30476): OnAfterGetCurrRecord sends one notification + (once per session) when the Sales Header is marked and the originating + order is not yet reviewed — Show Tax Match Decisions / Mark as reviewed / + Don't show again. No table: the prompt reads the order's + Tax Match Reviewed flag + the per-user My Notifications toggle. + Approving on the review page (or Mark as reviewed) sets the order's + Tax Match Reviewed flag — the single source of truth that stops + both prompts. +``` + +## Human-in-the-loop + +The matcher runs synchronously during order import without prompting the user, but every agent decision is recorded, customer-configurable to block document creation, and surfaced for human review. The primary review surface is the **Tax Match Review page** (page 30471) — a per-order Card that shows the resolved Tax Area (with the platform AI confidence indicator), the ship-to context the Tax Matching Agent reasoned over, and the tax lines with their matched Tax Jurisdiction Codes. Both entry points below open it. Five pillars: + +1. **Audit trail** — `Shpfy TMA Activity Log` (30477) writes a System Application `Activity Log` entry (Type = `AI`) for each matched tax line and one for the resulting Tax Area. Each entry carries the LLM confidence (`Low`/`Medium`/`High`), the LLM's reasoning text, and a drill-back URL to the Tax Jurisdiction or Tax Area card. The platform automatically renders a confidence indicator next to the field on the originating record (Shpfy Order Tax Line for per-line — shown in the review page's tax lines ListPart; Shpfy Order Header for per-area — shown on the review page's Tax Area Code field). + +2. **Persistent badge** — a `Tax Match Applied` Boolean on `Shpfy Order Header` (field 30476, set by `ShpfyTMAEvents` after `FindOrCreateTaxArea` succeeds) propagates to `Sales Header` (field 30476) via the existing `OnAfterCreateSalesHeader` event in `ShpfyOrderEvents`. The Sales Order page extension shows this as a read-only field (`Importance = Additional`) plus a **Review Tax Match** action that opens the review page. + +3. **Review page** (page 30471) — the single canonical review-and-adjust surface for an order. It summarizes the resolved Tax Area (with AI confidence indicator), the ship-to context, and the tax lines. Each line shows the **item it taxes** (applies-to Item No. + description), **Shopify's rate**, the **Business Central Tax Detail rate** that would apply to that item for the assigned jurisdiction as of the order date, and the matched **Tax Jurisdiction Code** (with AI confidence indicators). The Tax Jurisdiction Code is **editable** so a reviewer can correct or complete a match, and the row is highlighted **green** when Shopify's and BC's rates agree and **red** when they differ. The **Approve** action (Approve icon, shown only while the order is being **held** — per the review mode, or a live rate conflict / incomplete match — and it is not yet reviewed) **rebuilds the Tax Area from the current line jurisdictions** (re-seeding any missing Tax Detail brackets and re-detecting rate conflicts), then sets `Tax Match Reviewed` — the single source of truth that also stops the Sales Order and order-page prompts. When the review mode does not hold the order and there is no rate conflict, the order is never held (its Sales Document is created automatically), so Approve is hidden and the page is purely informational. Approve is blocked while any tax line is still unmatched (blank jurisdiction), so tax is never silently dropped. An **Undo Approval** action (Undo icon) reverses an approval while the order is still held-when-unapproved and no Sales Document has been created yet (`Sales Order No.`/`Sales Invoice No.` still blank) — it clears `Tax Match Reviewed` so the order is held again, and un-verifies the agent-created jurisdictions the order used (returning them to provisional). If the order is being held and the user closes without approving, `OnQueryClosePage` warns them. **Rate-conflict case:** the jurisdiction is applied and a Tax Area is built, but the divergent line shows red and a guidance message on the Overview tab (shown only on a conflict) explains that approving will post at BC's rate. The reviewer can resolve it three ways: accept BC's rate (just Approve), change the jurisdiction, or click **Use Shopify Rate** on the tax lines part — which creates/updates a Tax Detail (effective the order's document date, at Shopify's rate) so BC posts what the customer paid. **Use Shopify Rate** mutates shared BC tax setup (not scoped to this order), so it asks for confirmation first; after it runs the row turns green, and approving then rebuilds the Tax Area and clears the stored rate-conflict flag. The tax lines ListPart is scoped to the order via `SetTaxLineFilter`. (tax lines link to order lines, so the page passes the order's order line ids). The standalone tax lines ListPart is **no longer embedded on the Shopify Order Card** — it lives only on this review page. + +4. **Configurable review mode (default hold everything)** — a per-shop `Tax Match Review Mode` enum (field 30474, default `Always`) and a per-order `Tax Match Reviewed` Boolean (field 30477) gate Sales Document creation. `ShpfyTMAEvents` subscribes to `OnBeforeCreateSalesHeader` and sets `Handled := true` when the order has the marker, is not yet approved, and **(the review mode holds it OR the order has a `Tax Rate Conflict` OR the match is incomplete (`Tax Match Incomplete`))**. The review mode is evaluated by `IsHeldForReviewPreference`: **Always** holds every matched order; **Low Confidence Only** holds only when the order carries a non-high-confidence match (the stored `Tax Match Low Confidence` flag, field 30480, which includes a match to a provisional agent-created jurisdiction forced low); **Never** does not hold for review preference. The `Tax Rate Conflict` Boolean (field 30478) is the **single source of truth** for a rate conflict: it is set at match time when a matched line's BC Tax Detail rate differs from Shopify's, and refreshed whenever the match is re-applied on Approve. The `Tax Match Incomplete` Boolean (field 30479) is set when one or more tax lines could not be resolved to a jurisdiction (the model returned the `UNKNOWN` sentinel for a title that is not a genuine tax description — e.g. an injection/obfuscation attempt): the line is left unmatched, no jurisdiction is created from the untrusted text, and the order is always held so a human assigns the missing jurisdiction (the review page rebuilds the Tax Area from the completed set on Approve, which clears the flag). The gate, the notifications, the order-page action caption, and the review-page guidance + Approve visibility all read these stored flags via the shared `IsHeldForReviewPreference`, so they can never disagree. Because edits to a tax line's jurisdiction on the review page only take effect on Approve — and are **reverted** if the user closes without approving — the stored flag always matches the persisted tax lines. A rate conflict or an incomplete match holds the order in **all** review modes (they are hard safety gates); a normal successful match (no conflict, all lines resolved) is held per the review mode — always in **Always**, only when low-confidence in **Low Confidence Only**, and never in **Never**. The **Shpfy Order page** exposes a **Review and Approve Tax Match** entry action (shown while approval is pending — the order is held and not yet approved; captioned just **Review Tax Match** once approved or when not held) which opens the review page where the match is approved. On the review page, an unmatched tax line (blank Tax Jurisdiction Code) is highlighted **red**, the same visual weight as a rate conflict, so the reviewer is drawn to the line they must complete. If the user closes the review page while approval is still pending, an `OnQueryClosePage` confirm warns them the Sales Document will not be created until it is approved. On the next process run (auto or manual) the order proceeds. Customers set the shop's `Tax Match Review Mode` to choose how much is held. + + **Provisional agent-created jurisdictions.** When the shop has *Auto Create Tax Jurisdictions* enabled and the model matches a tax line to a code that does not yet exist, the matcher creates the jurisdiction and stamps it `Created by Agent = true`, `Verified = false` (tableextension 30481). Such a jurisdiction is **provisional**: on this and every later order (except when the shop's review mode is *Never*, where nothing is held and the downgrade is skipped), a match to a jurisdiction that is `Created by Agent AND NOT Verified` is deterministically forced to **low confidence** in AL (never trusting the model to down-rank its own creation) and the order is flagged `Tax Match Low Confidence` (field 30480) so the review gate holds it for a human — otherwise the agent could "launder" master data it invented into a later high-confidence, auto-released match. Approving any order that uses the jurisdiction sets `Verified = true` (in the review page's Approve action, via `MarkJurisdictionsVerified`), which clears the provisional state so future high-confidence matches ride through. Conversely, undoing an order's approval reverses this (via `UnverifyAgentJurisdictions`): the agent-created jurisdictions the order used are set back to `Verified = false`, returning them to their provisional (forced-low, held) state. This is deliberately unconditional and cheap — only the order's own tax lines are read — so a jurisdiction that another approved order also uses is re-quarantined too and is simply re-verified when a later order using it is approved. The model's free-text `reason` (untrusted, buyer-controlled) is **replaced** with a deterministic explanation for these forced-low matches, so the reviewer sees why the match is held. Because the check is `Created by Agent AND NOT Verified`, pre-existing and user/admin-created jurisdictions (`Created by Agent = false`) are never quarantined and need no migration. + +5. **Active notifications** — two actionable, dismissible BC `Notification`s prompt the user to review, each with its own `MyNotifications` GUID so "Don't show again" is scoped per surface: + - **Shopify Order page** (all review modes) — `ShpfyTMAEvents`/the Order page extension fires (once per order per page session, via `SendOrderReviewNotification`) when the order was agent-matched and not yet reviewed: "Tax Matching Agent set Tax Area %1 on this Shopify order. Review the matched tax jurisdictions." Actions: **Review** (opens the review page) and **Don't show again**. + - **Sales Order page** (auto-released orders) — on open, the page's `OnAfterGetCurrRecord` calls `SendForCurrentSalesHeader`, which fires a notification when the Sales Header is marked `Tax Match Applied` and the originating Shopify order (resolved via `Sales Order No.`) has `Tax Match Reviewed = false` and the user hasn't disabled the prompt. **No table or queue** — the decision is derived live from the order's `Reviewed` flag plus the per-user `My Notifications` toggle, and a per-session dedupe var prevents re-firing on refresh. Actions: **Show Tax Match Decisions** (opens the review page), **Mark as reviewed** (sets the order's `Reviewed` flag), and **Don't show again** (`My Notifications.Disable`). Naturally suppressed once the order is reviewed (approving a held order already sets that flag). + +For safety, `ShpfyTMAEvents.OnAfterMapShopifyOrder` resets both the marker and the reviewed flag to `false` before each matcher run so re-matching (after a user manually clears Tax Area Code on the Shpfy Order Header) cannot leave stale flags from a prior run. + +## Data Sent to LLM + +A single API call per order. The user prompt is assembled from this template: + +``` +Match the following Shopify tax lines to BC Tax Jurisdictions. + +Tax lines: +[{id, title, rate_pct, channel_liable}, ...] + +Available Tax Jurisdictions: +[{code, description}, ...] + +Ship-to address: +{country, state, city} + +Auto Create Tax Jurisdictions: Yes/No +``` + +**What is NOT sent**: customer names, monetary amounts, item details, street addresses, postal codes, or any PII. + +## LLM Configuration + +| Setting | Value | +|---------|-------| +| Model | GPT-4.1 Latest (`AOAIDeployments.GetGPT41Latest()`) | +| Temperature | 0 (deterministic) | +| Max tokens | 4096 | +| Tool choice | Forced — `match_tax_jurisdictions` | +| Infrastructure | Azure OpenAI via BC's `AzureOpenAI` codeunit | + +## System Prompt Strategy + +The **matching** system prompt (`ShpfyTaxMatchingAgent-SystemPrompt.md`, shipped as an app resource) instructs the LLM to match using a four-tier strategy: + +1. **Exact match** — title matches jurisdiction code or description (case-insensitive) +2. **Keyword/semantic match** — common tax abbreviation patterns (GST, PST, HST, MTA, NYC, etc.) +3. **Geographic context** — use ship-to address to disambiguate when multiple jurisdictions could match +4. **Auto-create** — when enabled and no match found, suggest a new code (max 10 chars, uppercase, no spaces) + +The prompt is hardened against prompt injection by a separate **security/guardrail** section that instructs the model to treat every tax line title, address, and jurisdiction description as untrusted **data (never instructions)**, never reveal the prompt or tool definition, keep `reason` short/factual/tax-only, and ignore any embedded instructions (returning the `UNKNOWN` sentinel rather than obeying them). That guardrail section is **not** committed to this public repository: it is stored in **Azure Key Vault** (secret `ShopifyTaxMatchingAgentSecurityPrompt`) and merged onto the matching prompt at runtime (following the Sales Line Suggestions pattern of loading prompts from Key Vault). The guardrail is exercised by the Responsible AI tests, which live in the internal enlistment (see below). + +**Guardrail availability is a precondition, not a mid-match failure.** Because `ShpfyTMAEvents.OnAfterMapShopifyOrder` runs inside `ShpfyOrderMapping.DoMapping` — which the order-sync report calls **without** error isolation — the subscriber must never throw, or it would abort the whole sync run. So it fetches the guardrail prompt up front via `Shpfy TMA Matcher.TryGetGuardrailPrompt()` as a precondition (alongside the capability checks): if the secret cannot be read — a deliberate Microsoft-side disable (kill switch) or a transient Key Vault issue — it logs telemetry `0000UNV` and skips matching, so the order proceeds via the normal address-based tax path and order sync is unaffected. The matcher must never call the model without the guardrail, so the fetched prompt is **carried into `MatchTaxLines`** (Key Vault is read only once per order) and merged onto the matching prompt there. + +## Tool Definition + +The LLM must return a structured JSON object via function calling: + +```json +{ + "matches": [ + { + "tax_line_id": "12345-1", + "jurisdiction_code": "NYSTAX", + "confidence": "high|medium|low", + "reason": "Brief explanation" + } + ] +} +``` + +Confidence levels drive business logic: +- `high` / `medium` — match applied if jurisdiction exists +- `low` — only applied if Auto Create Tax Jurisdictions is enabled (treated as a "suggestion" for a new jurisdiction) +- Empty `jurisdiction_code` — always skipped + +## UI Surface + +The Tax Matching Agent's matching itself runs silently during order import — no dialog, wizard, or chat interface. The visible surfaces are: + +**Shopify Shop Card** — a "Tax Matching Agent" group with the per-shop configuration. Fields that have no effect without a prerequisite are disabled (greyed out) until the prerequisite is set: + +| Field | Type | Default | Enabled when | Purpose | +|-------|------|---------|--------------|---------| +| Tax Matching Agent Enabled | Boolean | false | always | Master toggle per shop | +| Auto Create Tax Jurisdictions | Boolean | false | Tax Matching Agent Enabled | Allow LLM-suggested new jurisdictions to be created | +| Auto Create Tax Areas | Boolean | true | Tax Matching Agent Enabled | Allow system to create Tax Area records | +| Tax Area Naming Pattern | Text[20] | `SHPFY-` | Enabled **and** Auto Create Tax Areas | Prefix for auto-generated Tax Area codes | +| Tax Match Review Mode | Enum (Always / Low Confidence Only / Never) | Always | Tax Matching Agent Enabled | Controls when a matched order is held for approval on the Tax Match Review page. **Always** holds every matched order (default, per RAI guidance); **Low Confidence Only** holds only orders with a non-high-confidence match (incl. a match to a provisional, agent-created jurisdiction); **Never** holds none for review preference. A rate conflict or incomplete match always holds regardless. | + +**Tax Match Review page** (page 30471) — the single canonical review-and-adjust surface. A Card showing the resolved Tax Area (with AI confidence indicator), the ship-to context, and an editable tax lines ListPart where each line shows the item it taxes (applies-to Item No. + description), Shopify's rate, Business Central's Tax Detail rate for the assigned jurisdiction, and its per-line Tax Jurisdiction Code (with AI confidence indicators). The Tax Jurisdiction Code is editable, and a line is highlighted green when the two rates agree, red when they differ. The **Approve** action (Approve icon) shows only while the order is being held (per the shop's review mode, or a live rate conflict / incomplete match) and is not yet reviewed; it rebuilds the Tax Area from the current line jurisdictions and is blocked while any line is unmatched. An **Undo Approval** action (Undo icon) reverses an approval before the Sales Document is created; it also un-verifies the agent-created jurisdictions the order used (returning them to provisional). When the review mode does not hold the order and there is no conflict, the order isn't held, so Approve is hidden and the page is informational. On a rate conflict the divergent line is red and a guidance message on the Overview tab explains that approving posts at BC's rate; a **Use Shopify Rate** action on the tax lines part lets the reviewer instead create/update a Tax Detail (effective the order's document date, at Shopify's rate) so BC posts the Shopify rate — it warns first that this changes shared tax setup beyond this order. The review-drill actions on the Shopify Order and Sales Order pages both use the AI `SparkleFilled` icon. + +**Shopify Order page** — adds a review entry action (AI `SparkleFilled` icon) that opens the review page — captioned **Review and Approve Tax Match** while approval is pending, else **Review Tax Match** — and fires an actionable **Review** notification on open when the order was agent-matched and not yet reviewed. The tax lines are no longer embedded here — they live on the review page. + +**BC Sales Order page** — a secondary HITL surface: + +- Read-only `Tax Match Applied` field (next to Tax Liable, `Importance = Additional`). +- **Review Tax Match** navigation action (visible when the marker is set) that opens the review page for the originating order. +- One-time notification on first open of the Sales Order (shown for auto-released orders — those the review mode did not hold), with actions to open the review page, mark reviewed, or suppress per-user. + +## Data Model + +The feature reads from and writes to the standard Shopify connector tables and BC tax tables: + +### Connector tables (read/write) + +| Table | Field | Usage | +|-------|-------|-------| +| Shpfy Order Header | Tax Area Code (1070) | Written by Tax Area Builder | +| Shpfy Order Header | Tax Liable (1080) | Set to `true` by Tax Area Builder | +| Shpfy Order Header | Tax Exempt (1090) | Imported from Shopify `taxExempt` field; guards skip matching | +| Shpfy Order Tax Line | Tax Jurisdiction Code (30476, via TMA TableExt) | Written by Matcher for each matched line | +| Shpfy Refund Header | Tax Area Code (110) | FlowField → Order Header; shown on Refund page | +| Shpfy Refund Header | Tax Liable (111) | FlowField → Order Header; inherited by credit memo | +| Shpfy Refund Header | Tax Exempt (112) | FlowField → Order Header; shown on Refund page | +| Shpfy Shop | 4 config fields (30470-30473) | Read by Events + Matcher + Builder | + +### BC tax tables + +| Table | Usage | +|-------|-------| +| Tax Jurisdiction | Read for matching; created when `Auto Create Tax Jurisdictions` enabled | +| Tax Area | Read for exact-match search; created when `Auto Create Tax Areas` enabled | +| Tax Area Line | Read/created as part of Tax Area | +| Tax Detail | Seeded per matched tax line for `(jurisdiction × the tax line's Tax Group)` at Shopify's reported rate. The Tax Group is resolved by what the tax line is charged on: a product-line tax line uses the order line item's `Tax Group Code`; a shipping-charge tax line uses the Shop's `Shipping Charges Account` Tax Group Code (`G/L Account.Tax Group Code`). For each seed, look for the latest Tax Detail with `Effective Date <= order date` for the jurisdiction + tax group + tax type. If none exists, insert a new one at the order date with Shopify's rate. If one exists with the same rate, do nothing. **Rate conflict:** if the bracket exists with a *different* rate, the existing (admin-maintained) rate is left untouched, telemetry `0000UMR` is logged, and the order is flagged (`Tax Rate Conflict`) — the jurisdiction is still matched and the Tax Area is built, but the order is held for review so a human accepts BC's rate or corrects the detail (see Human-in-the-loop). This applies uniformly to product-line and shipping-charge tax lines (a shipping rate conflict holds the order too). Empty Tax Group Code is a valid value (an item or shipping account with no group results in a `(Jurisdiction × '')` Tax Detail row). | + +## Integration Points + +The feature hooks into the standard connector via three existing integration events on `Shpfy Order Events` (codeunit 30162): + +```al +[IntegrationEvent(false, false)] +internal procedure OnAfterMapShopifyOrder(var ShopifyOrderHeader: Record "Shpfy Order Header"; Result: Boolean) + +[IntegrationEvent(false, false)] +internal procedure OnBeforeCreateSalesHeader(ShopifyOrderHeader: Record "Shpfy Order Header"; var SalesHeader: Record "Sales Header"; var LastCreatedDocumentId: Guid; var Handled: Boolean) + +[IntegrationEvent(false, false)] +internal procedure OnAfterCreateSalesHeader(OrderHeader: Record "Shpfy Order Header"; var SalesHeader: Record "Sales Header") +``` + +- `OnAfterMapShopifyOrder` fires after `OrderMapping.DoMapping()` completes; the agent subscriber runs the matcher (the connector's `MapTaxArea` runs first via address-based lookup; the agent only activates if that lookup didn't find a Tax Area). +- `OnBeforeCreateSalesHeader` fires at the top of `ShpfyProcessOrder.CreateHeaderFromShopifyOrder()`; the agent subscriber sets `Handled := true` to skip Sales Doc creation when the order is held for review (per the review mode, a rate conflict, or an incomplete match) and hasn't yet been approved. +- `OnAfterCreateSalesHeader` fires after the connector's existing Tax Area Code propagation; the agent subscriber uses it to propagate the marker flag onto the Sales Header (the review prompt itself is derived live on page open, not queued). + +## Capability Registration + +Registration follows the standard BC Copilot capability pattern: + +1. **Install codeunit** (30475) — calls `RegisterCopilotCapability()` on `OnInstallAppPerDatabase` +2. **Register codeunit** (30470) — subscribes to `OnRegisterCopilotCapability` on the Copilot AI Capabilities page, so the capability is also registered when the admin visits that page +3. The capability appears as **"Shopify Tax Jurisdiction Matching with AI"** in the Copilot AI Capabilities page + +### Shop defaults on install/upgrade + +The tax config fields carry their defaults as field `InitValue`s (`Auto Create Tax Areas = true`, `Tax Area Naming Pattern = 'SHPFY-'`, `Tax Match Review Mode = Always`), which only apply to Shop records created *after* the app is installed. Because Shopify shops usually already exist when this app is added, `Shpfy TMA Upgrade` (30478) backfills those three fields onto existing shops from an `Init()`'d record. The backfill is guarded by an **upgrade tag** (`MS-445769-TMAShopDefaults-…`) so it runs **exactly once per company**, and it is invoked from both paths: the Install codeunit's `OnInstallAppPerCompany` (new install into a company that already has shops) and the Upgrade codeunit's `OnUpgradePerCompany` (a previously installed app being updated). The tag is intentionally **not** registered in `OnGetPerCompanyUpgradeTags`, so a fresh install — which has no tag yet — still backfills any pre-existing shops (mirroring how `Shpfy Installer` guards its Cue/retention setup). The other two fields (`Tax Matching Agent Enabled`, `Auto Create Tax Jurisdictions`) intentionally default to `false` and are left untouched. + +## Telemetry + +| Event ID | Level | Location | Trigger | +|----------|-------|----------|---------| +| 0000UMF | Warning | Events | Order held for review due to a rate conflict | +| 0000UMG | Normal (ExtensionPublisher scope) | Events | Tax Match Applied marker set on Order Header | +| 0000UMH | Normal (ExtensionPublisher scope) | Events | Tax lines matched (match successful) | +| 0000UMI | Warning | Events | Sales Document creation blocked pending tax match review | +| 0000UMJ | Normal (ExtensionPublisher scope) | Events | Tax Match Applied marker propagated to Sales Header | +| 0000UMK | Normal (ExtensionPublisher scope) | Events | Match starting for order | +| 0000UML | Uptake: Used | Matcher | MatchTaxLines called | +| 0000UMM | Error (ExtensionPublisher scope) | Matcher | AOAI call failed (HTTP status code in a custom dimension; no echoed error text) | +| 0000UMN | Error (ExtensionPublisher scope) | Matcher | No function call in LLM response | +| 0000UMO | Error (ExtensionPublisher scope) | Matcher | Function execution failed | +| 0000UMP | Normal | Matcher | Low-confidence match skipped | +| 0000UMQ | Warning | Matcher | Jurisdiction not found, auto-create disabled | +| 0000UMR | Warning | Matcher | A matched tax line's Tax Detail rate differs from Shopify's (item or shipping tax group) — jurisdiction still matched, order held for review | +| 0000UMT | Usage | Notify | Sales Order review notification sent | +| 0000UMU | Usage | Notify | User opened the Tax Match Review page from the Sales Order notification (logged only when the review page actually opened) | +| 0000UMV | Usage | Notify | Order-page review notification sent | +| 0000UMW | Usage | Notify | User opened the Tax Match Review page from the order-page notification | +| 0000UMX | Usage | Notify | User marked notification reviewed | +| 0000UMY | Usage | Notify | User chose "Don't show again" | +| 0000UMZ | Uptake: Set up | Register | App installed | +| 0000UN0 | Normal | Activity Log | Per-tax-line entry written (jurisdiction match) | +| 0000UN1 | Normal | Activity Log | Per-tax-area entry written (Tax Area resolved) | +| 0000UN7 | Usage | Notify | User undid an approval (order held for review again) | +| 0000UNP | Usage | Order Tax Lines Part | Reviewer clicked **Use Shopify Rate** — adopted Shopify's rate into a Tax Detail | +| 0000UNR | Warning | Matcher | Tax line unresolved (model returned `UNKNOWN`) — left unmatched for review | +| 0000UNT | Warning | Events | Order held for review due to an unresolved (`UNKNOWN`) tax line | +| 0000UNV | Error | Events | Guardrail/security prompt unavailable from Key Vault — matching skipped, order proceeds via the normal tax path | +| 0000UN8 | Uptake: Discovered | Shop Card | User opened the shop configuration surface where the Tax Matching Agent settings live | +| 0000UN9 | Usage | Notify | Review drill-through fell back to the raw Shopify order because the review page could not be resolved | + +## Test App + +A separate test app — **Shopify Connector NA Test** (`ShopifyNA/test/`, sources under +`test/src/Tax Matching Agent/`, ID range 134713-134720) — carries the public coverage in two layers: + +**AI Test Toolkit (data-driven, real LLM):** +- `Shpfy TMA Match Test` (134717), `Shpfy TMA Tax Area Test` (134718), `Shpfy TMA Guard Test` (134719) read their scenarios via `AITTestContext.GetInput()` and must run **through the AI Test Toolkit** (they need the YAML datasets + suite). Only the Match test issues real LLM calls; Tax Area and Guard exercise post-LLM logic through the same harness. +- Data-driven YAML scenarios iterated by the framework; test output logged via `AITTestContext.SetQueryResponse()` for eval spreadsheets. +- Categories: Jurisdiction Matching (J, H), Jurisdiction Creation (JC), Tax Detail (TD), Shipping Tax (S), Tax Area (TA), Guard (G), End-to-End (F). + +**Plain unit tests (standard test runner, no LLM, no toolkit):** +- `Shpfy TMA HITL Test` (134716) and `Shpfy TMA Rate Conflict Test` (134720) build records directly and drive the codeunit helpers (marker propagation, gate decision, rate-conflict recheck/flip, Undo Approval). They run as ordinary AL tests — the AI Test Toolkit is not required. + +**Responsible AI (RAI) — prompt injection + harms (internal enlistment, not this repo):** +- Because `microsoft/BCApps` is public, the RAI tests — which would otherwise expose the adversarial datasets and the jailbreak-testing approach — live in a **separate internal app in the NAV enlistment**: **Shopify Connector NA AI Tests** (`App/Internal/Apps/ShopifyNAAITest`, ID range 134721-134732), covering deterministic cross-prompt-injection scenarios and dynamic Red Team Scan harms/jailbreak passes. +- It reuses this public test app's `Shpfy TMA Test Library` + `Shpfy TMA Verify` and calls the matcher via `internalsVisibleTo` (granted by both **Shopify Connector NA** and **Shopify Connector NA Test**). +- The tests read the security prompt from Key Vault (the norm — like Sales Line Suggestions), so the eval environment must have the secret provisioned (see *Capability Registration* below); there is no mock. + +See `TestMatrix.md` for the full test scenario inventory and the Automated Test Coverage map. + +## Localization promotion + +The main Shopify Connector nudges eligible environments to install this app, mirroring the Belgian +localization pattern. `Shpfy Shop Mgt.SendNorthAmericaLocalizationNotification()` (called from the +`Shpfy Shops` list `OnOpenPage`) shows a dismissible `Notification` with **Install** +(`ExtensionManagement.InstallMarketplaceExtension`) and **Don't show again** (`MyNotifications`) +actions when: the application family is **US** (CA/MX to be added when supported), the app is not +already installed, and the user hasn't dismissed the prompt. + +## Refund Support + +Refunds inherit all tax context from their parent order — no separate LLM call or tax matching is performed for refunds. + +### Data flow +- **Refund Header** has FlowFields (110-112) that look up Tax Area Code, Tax Liable, and Tax Exempt from the linked `Shpfy Order Header` via `Order Id`. No data duplication. +- **Credit memo creation** (`ShpfyCreateSalesDocRefund`) reads Tax Area Code and Tax Liable from the original order header and validates both on the Sales Credit Memo header. +- **Tax-exempt orders**: If the parent order is tax exempt, the credit memo's Tax Liable stays false (no Tax Area Code to trigger the block). + +### UI +- The **Refund page** shows a "Tax" group with Tax Area Code, Tax Liable, and Tax Exempt (read-only, inherited from order). +- A **"Tax Lines"** navigation action opens the existing `Shpfy Order Tax Lines` page filtered to the parent order's lines, so users can see the tax jurisdiction breakdown without leaving the refund. + +### Why no tax matching for refunds +The Tax Matching Agent event subscriber (`ShpfyTMAEvents`) only subscribes to `OnAfterMapShopifyOrder`. Refunds don't go through order mapping — they reference an already-processed order. If the order's tax was matched by the Tax Matching Agent, that data is already on the order and flows through to the refund via FlowFields. diff --git a/src/Apps/NA/ShopifyNA/app/TestMatrix.md b/src/Apps/NA/ShopifyNA/app/TestMatrix.md new file mode 100644 index 00000000000..1eb793796fc --- /dev/null +++ b/src/Apps/NA/ShopifyNA/app/TestMatrix.md @@ -0,0 +1,359 @@ +# Shopify Tax Matching Agent — Test Matrix + +## Setup Variations + +| Setting | Values | +|---------|--------| +| Tax Matching Agent Enabled | Yes / No | +| Auto Create Tax Jurisdictions | Yes / No | +| Auto Create Tax Areas | Yes / No | +| Tax Area Naming Pattern | `SHPFY-` / Custom / Blank | + +## Pre-conditions + +| ID | Condition | Description | +|----|-----------|-------------| +| P1 | Tax Matching Agent disabled | Shop has Tax Matching Agent Enabled = No | +| P2 | Copilot capability not registered | Capability missing from Copilot AI Capabilities page | +| P3 | Copilot capability inactive | Registered but turned off | +| P4 | Tax Area already assigned | Order already has a Tax Area Code from address-based MapTaxArea | +| P5 | No tax lines on order | Order has no Shopify tax lines at all | +| P6 | All tax lines already matched | All tax lines already have a Tax Jurisdiction Code | + +--- + +## Jurisdiction Matching Scenarios + +| # | Scenario | Existing Jurisdictions | Auto Create Juris. | LLM Returns | Expected Result | +|---|----------|----------------------|--------------------|--------------|----| +| J1 | Exact match — all lines | NYSTAX, NYCTAX, MTATAX exist | N/A | high confidence matches to all 3 | Tax Jurisdiction Code set on all tax lines | +| J2 | Partial match — some lines | NYSTAX exists, NYCTAX does not | No | high for NYSTAX, low for NYCTAX | Only NYSTAX matched; NYCTAX skipped (logged) | +| J3 | Partial match + auto-create | NYSTAX exists, NYCTAX does not | Yes | high for NYSTAX, low for NYCTAX | NYSTAX matched; NYCTAX created and matched | +| J4 | No match — all new | None exist | No | low confidence for all | No matches applied (all skipped) | +| J5 | No match + auto-create | None exist | Yes | low confidence with suggested codes | All jurisdictions created and matched | +| J6 | LLM returns empty jurisdiction_code | Any | Yes | `jurisdiction_code: ""` | Skipped (logged as low confidence) | +| J7 | LLM returns medium confidence | Jurisdiction exists | N/A | medium confidence | Matched (medium is accepted regardless of auto-create) | +| J8 | Duplicate jurisdiction codes | NYSTAX exists | Yes | Same code for multiple tax lines | Jurisdiction used for all lines; added to MatchedJurisdictions once | + +--- + +## Tax Jurisdiction Creation Details (Auto Create = Yes) + +| # | Scenario | Expected Result | +|---|----------|-----------------| +| JC1 | Country/Region | New jurisdiction has Country/Region from order's Ship-to | +| JC2 | Report-to Jurisdiction — multiple | 3 jurisdictions created (state, county, city): all have Report-to = state-level jurisdiction (first in list) | +| JC3 | Report-to — single jurisdiction | Only 1 jurisdiction matched: FixReportToJurisdictions not called (guard: Count > 1) | +| JC4 | Jurisdiction already exists | LLM suggests code that already exists: existing jurisdiction used as-is (not modified, not re-created) | +| JC5 | Description | New jurisdiction description = jurisdiction code (e.g. "NYSTAX") | +| JC6 | Report-to on a pre-existing blank jurisdiction | A matched jurisdiction already exists with a blank Report-to (e.g. from an earlier run): its Report-to is set to the state-level jurisdiction, not left blank (covered by `ReapplySetsReportToOnBlankJurisdictions`); a jurisdiction that already has a non-blank Report-to is preserved (`ReapplyPreservesExistingReportTo`) | + +--- + +## Tax Detail Scenarios (Auto Create Jurisdictions = Yes) + +| # | Scenario | Existing Tax Details | Expected Result | +|---|----------|---------------------|-----------------| +| TD1 | No existing detail | None for this jurisdiction + tax group | Tax Detail created with rate from tax line | +| TD2 | Exact detail exists | Same jurisdiction, tax group, and rate | No duplicate created | +| TD3 | Same jurisdiction, different rate | Valid bracket exists at earlier date with a different rate than Shopify's | **Line is matched** to the (correct) jurisdiction and the **Tax Area is built**; existing detail preserved (not overwritten or duplicated); rate conflict logged (telemetry `0000UMR`) and flagged on the order (`Tax Rate Conflict`); the order is held for review; the line is highlighted red (BC rate vs Shopify rate) on the review page (see RD1) | +| TD4 | Same jurisdiction, different tax group | Detail exists with different Tax Group Code | New Tax Detail created for the new tax group | +| TD5 | Item has no tax group | Order line item has blank Tax Group Code | Tax Detail created with blank Tax Group Code | +| TD6 | Order line has no item | Item No. is blank on order line | Tax Detail created with blank Tax Group Code | +| TD7 | Multiple tax lines across jurisdictions | Two tax lines on different jurisdictions, auto-create on | One Tax Detail per jurisdiction | +| TD8 | Effective date | Order Document Date = 2026-01-15 | Tax Detail has Effective Date = 2026-01-15 | + +--- + +## Rate Divergence Scenarios + +When a matched jurisdiction's existing Tax Detail (for the line's item tax group, valid as of +the order date) has a rate that differs from Shopify's, the agent still matches the (correct) +jurisdiction and builds the Tax Area, but sets the stored `Tax Rate Conflict` flag and +holds the order for human review. That flag is the single source of truth (gate, notifications, +order-page caption, review-page guidance + Approve all read it). The reviewer sees Shopify's and +BC's rates side by side (green/red) on the review page and decides whether to accept BC's rate, +change the jurisdiction, or correct the Tax Detail. Jurisdiction edits on the page only take +effect on Approve and are reverted if the page is closed without approving. + +| # | Scenario | Setup | Expected Result | +|---|----------|-------|-----------------| +| RD1 | Item-group rate conflict | `NYSTAX × FURNITURE` Tax Detail = 10%; order line taxed at 20% for NYSTAX (group FURNITURE) | Line matched to NYSTAX; existing 10% detail untouched; telemetry `0000UMR`; `Tax Match Applied` + `Tax Rate Conflict` set; **Tax Area built**; Activity Log entry on the line noting the rate difference; order held (see RD3/RD4) | +| RD2 | Partial conflict, multi-line | Lines A (NYSTAX, no existing detail) and B (NYCTAX × FURNITURE conflict) | A matched (code written, detail seeded); B matched (code written, existing detail untouched); **Tax Area built from both**; order flagged `Tax Rate Conflict` and held | +| RD3 | Held in Always mode | RD1 + shop Tax Match Review Mode = Always | `OnBeforeCreateSalesHeader` sets `Handled := true`; no Sales Document created | +| RD4 | Held in Never mode | RD1 + shop Tax Match Review Mode = Never | Still held — the gate holds because `Tax Rate Conflict` is set, regardless of the review mode, so a rate difference is never auto-posted without review | +| RD5 | Resolved then approved / re-run | After RD1 the reviewer either (a) accepts BC's 10% and clicks Approve, or (b) corrects the Tax Detail rate to 20% then Approves (or re-runs Find Mappings on the Shopify order) | On Approve the Tax Area is rebuilt from the line jurisdictions, `Tax Rate Conflict` is recomputed (clears when rates now agree), and the order is released. A re-run rebuilds from the order's **full** jurisdiction set (carried in from persisted codes), not just the re-matched line | +| RD8 | Edit discarded on close | On a held order the reviewer changes a line's Tax Jurisdiction Code, then closes the page **without** Approve | A confirmation warns the edit will be discarded; on confirm the line's Tax Jurisdiction Code is reverted to its pre-edit value and `Tax Rate Conflict` is unchanged (still authoritative) | +| RD9 | Undo Approval | On an approved, held order with no Sales Document yet (`Sales Order No.`/`Sales Invoice No.` blank), **Undo Approval** (after a confirm) clears `Tax Match Reviewed` so the order is held again; the action is hidden once a Sales Document exists or the order is not held-when-unapproved | +| RD11 | Undo un-verifies agent jurisdiction | An approved order uses an agent-created, `Verified` jurisdiction; reviewer clicks **Undo Approval** | The jurisdiction is set back to `Verified` = false (returns to provisional/held) — `UndoApprovalUnverifiesAgentJurisdiction` | +| RD12 | Undo re-quarantines a shared jurisdiction | Two approved orders use the same agent-created, `Verified` jurisdiction; reviewer undoes one | The jurisdiction is un-verified (deliberately re-quarantined; a later approval re-verifies it) — `UndoApprovalUnverifiesJurisdictionSharedByAnotherOrder` | +| RD10 | Use Shopify Rate resolves the conflict | On a conflict line the reviewer clicks **Use Shopify Rate** (after a confirm warning it changes shared tax setup beyond this order): a Tax Detail is created/updated for the line's jurisdiction + tax group, effective the order's document date, at Shopify's rate. The row turns green (BC rate now equals Shopify's); on Approve the Tax Area is rebuilt and `Tax Rate Conflict` clears. The action is disabled when the rates already agree or no jurisdiction is assigned. **Verified manually / by TestPage** (page action + Confirm) | + +--- + +## Review Mode & Provenance Scenarios + +The shop's `Tax Match Review Mode` (Always / Low Confidence Only / Never) decides whether a matched +order is held for the review *preference* (evaluated by `IsHeldForReviewPreference`). A rate conflict +or an incomplete match always holds regardless of the mode. An agent-auto-created Tax Jurisdiction is +**provisional** (`Created by Agent` = true, `Verified` = false); in Always and Low Confidence Only +modes a match to a provisional jurisdiction is deterministically forced to low confidence and sets +the order's `Tax Match Low Confidence` flag, so the order is held (in Never mode the downgrade is +skipped, since nothing is held). Approving any order that uses the jurisdiction sets `Verified` = true, +clearing the provisional state (`MarkJurisdictionsVerified`). Undoing an order's approval reverses this +(`UnverifyAgentJurisdictions`): the agent-created jurisdictions the order used are set back to +`Verified` = false (re-quarantined; a later approval re-verifies them). The gate rows RM1–RM7 +are deterministic in-memory unit tests; the provisional rows PV1–PV3 exercise the LLM path and live in +the internal RAI app. + +| # | Scenario | Setup | Expected Result | +|---|----------|-------|-----------------| +| RM1 | Always holds every match | Applied, not reviewed, high confidence, no conflict; mode = Always | Held (`GateHeldWhenAlwaysRegardlessOfConfidence`) | +| RM2 | Low Confidence Only holds low-confidence | Applied, not reviewed, `Tax Match Low Confidence` = true, no conflict; mode = Low Confidence Only | Held (`GateHeldWhenLowConfidenceOnlyAndLowConfidence`) | +| RM3 | Low Confidence Only releases high-confidence | As RM2 but `Tax Match Low Confidence` = false | Not held (`GateNotHeldWhenLowConfidenceOnlyAndHighConfidence`) | +| RM4 | Never releases a low-confidence match | Applied, not reviewed, low confidence, no conflict/incomplete; mode = Never | Not held (`GateNotHeldWhenNeverAndLowConfidence`) | +| RM5 | Never still holds a rate conflict | Mode = Never, `Tax Rate Conflict` = true | Held (`GateHeldWhenNeverButRateConflict`) | +| RM6 | Never still holds an incomplete match | Mode = Never, `Tax Match Incomplete` = true | Held (`GateHeldWhenNeverButIncomplete`) | +| RM7 | Preference tracks the mode | One order; flip the mode across Always / Never / Low Confidence Only | `IsHeldForReviewPreference` returns true / false / flag-driven (`ReviewPreferenceFollowsMode`) | +| PV1 | Provisional jurisdiction forced low | Auto-create on; model matches a not-yet-existing code; jurisdiction created (`Created by Agent` = true, `Verified` = false) | The created jurisdiction is matched, the match is recorded **low** confidence, `Tax Match Low Confidence` set → order held (LLM / XPIA scenario, internal app) | +| PV2 | Later match stays provisional until verified | Second order, same ship-to; jurisdiction now exists but `Verified` = false | Match forced low again; order held (LLM / XPIA scenario, internal app) | +| PV3 | Verify on approve | Reviewer approves an order using the provisional jurisdiction | `Verified` set true on every jurisdiction the order uses; a subsequent high-confidence match then rides through per mode (LLM / AIT scenario) | +| PV4 | Non-agent jurisdiction never quarantined | Pre-existing / admin jurisdiction (`Created by Agent` = false) matched | Never forced low by provenance (the gate short-circuits on `Created by Agent`) | + +--- + +## Shipping Tax Scenarios + +Shipping-charge tax lines (stored by the connector on `Shpfy Order Tax Line` with +`Parent Id = "Shopify Shipping Line Id"`) are treated as **first-class** tax lines: the LLM +matches each to a jurisdiction, and it seeds a Tax Detail for the Shop's `Shipping Charges +Account` Tax Group Code at the **shipping line's own rate** (not derived from product lines). +A shipping-line rate conflict holds the order for review exactly like a product-line one. + +| # | Scenario | Shop Setup | Order Data | Expected Result | +|---|----------|------------|------------|-----------------| +| S1 | Shipping bracket seeded at its own rate | Shipping Charges Account `GLA-SHIP`, group `SHIPGRP`; NYSTAX exists | Item tax line NYSTAX @ 4%, shipping tax line NYSTAX @ **3%** | `(NYSTAX × TAXABLE)` @ 4% and `(NYSTAX × SHIPGRP)` @ **3%** — the shipping bracket comes from the shipping line's own rate | +| S2 | Shipping bracket under new jurisdiction | Same + Auto Create Jurisdictions = Yes | Item + shipping tax lines, no existing jurisdictions | Jurisdiction created; both `(NYSTAX × TAXABLE)` and `(NYSTAX × SHIPGRP)` seeded | +| S3 | Shipping charge without tax lines | Shipping account configured | Shipping charge present but **no** shipping tax lines (untaxed) | Only the item-side detail seeded; `(NYSTAX × SHIPGRP)` count = 0; no error | +| S4 | Shipping account with empty Tax Group Code | `GLA-SHIP` with blank Tax Group Code | Item + shipping tax lines | Item-group detail plus a `(Jurisdiction × '')` detail from the shipping line | +| S5 | Shipping group same as item group | Shipping account's group = `TAXABLE` (same as item), same jurisdiction & rate | Item + shipping tax lines both NYSTAX @ 4% | Single `(NYSTAX × TAXABLE)` row (idempotent seeding, count = 1) | +| S6 | Multiple shipping charges, same jurisdiction | Shipping account group `SHIPGRP` | Two shipping charges both NYSTAX @ 4% | Exactly one `(NYSTAX × SHIPGRP)` row (idempotency); no rate conflict | +| S7 | Shipping rate conflict holds order | Shipping account group `FREIGHT`; existing `(NYSTAX × FREIGHT)` @ 5% | Shipping tax line NYSTAX @ 8% | Shipping line matched; existing 5% untouched; `Tax Rate Conflict` set; order held (covered by `Shpfy TMA Rate Conflict Test`) | + +--- + +## Tax Area Scenarios + +| # | Scenario | Auto Create Areas | Existing Tax Areas | Expected Result | +|---|----------|-------------------|-------------------|-----------------| +| TA1 | Exact area exists | N/A | Area with exactly NYSTAX+NYCTAX+MTATAX | Existing area reused; no new area created | +| TA2 | Superset area exists | N/A | Area with NYSTAX+NYCTAX+MTATAX+EXTRA | Not matched (line count differs); new area or skip | +| TA3 | Subset area exists | N/A | Area with only NYSTAX+NYCTAX | Not matched; new area or skip | +| TA4 | No matching area + auto-create | Yes | None match | New Tax Area created: code = `SHPFY-MTATAX`, description = `Shopify - ...` | +| TA5 | No matching area, no auto-create | No | None match | Tax Area Code remains blank on order | +| TA6 | Area code collision | Yes | `SHPFY-MTATAX` already exists (different jurisdictions) | New area created as `SHPFY-MTATAX-2` | +| TA7 | Country/Region on area | Yes | N/A | New Tax Area has Country/Region from order's Ship-to | +| TA8 | Tax Liable flag | Yes | N/A | Order Header has Tax Liable = Yes after area assigned | +| TA9 | Custom naming pattern | Yes, pattern = `TAX-` | N/A | Area code = `TAX-MTATAX` | +| TA10 | Empty naming pattern | Yes, pattern = blank | N/A | Area code = `MTATAX` | +| TA11 | No matched jurisdictions | N/A | N/A | FindOrCreateTaxArea not called (guard in Events) | + +--- + +## Guard / Early Exit Scenarios + +| # | Scenario | Expected Result | +|---|----------|-----------------| +| G1 | Tax Matching Agent disabled on shop | No LLM call; order unchanged | +| G2 | Copilot capability not registered | No LLM call; order unchanged | +| G3 | Copilot capability inactive | No LLM call; order unchanged | +| G4 | Tax Area already set by MapTaxArea | the agent skipped; existing Tax Area kept | +| G5 | Order is Tax Exempt | the agent skipped; order unchanged | +| G6 | No order lines | MatchTaxLines returns false | +| G7 | All tax lines already have jurisdiction codes | MatchTaxLines returns false (no unmatched lines) | +| G8 | Order import Result = false | Event subscriber exits immediately | + +--- + +## HITL (Human-in-the-loop) Scenarios + +| # | Scenario | Expected Result | +|---|----------|-----------------| +| HITL-1 | Order Header marker set; Sales Header created | `Sales Header."Shpfy Tax Match Applied" = true` (propagated via `OnAfterCreateSalesHeader`) | +| HITL-2 | Order Header marker false; Sales Header created | Sales Header marker stays false (no propagation) | +| HITL-3 | `MarkReviewed` from the Sales Order notification | Sets the originating order's `Tax Match Reviewed = true` (resolved via `Sales Order No.`) | +| HITL-4 | `DisableForUser` from the Sales Order notification | Sets the order's `Tax Match Reviewed = true` and disables the prompt via `My Notifications` | +| HITL-5 | Successful match applied | `Activity Log Entry` count for the Order Header `Tax Area Code` field ≥ 1; per-line entries on each matched `Shpfy Order Tax Line` | +| HITL-6 | LLM returns 'low'/'medium'/'high'/unknown confidence | `Capitalize` helper maps to 'Low'/'Medium'/'High'/'Low' (safe fallback); `Activity Log Builder.SetConfidence` does not error | +| HITL-7 | Review page Approve visibility (held order) | On a held order (the review mode holds it, or a live rate conflict) that isn't yet approved, **Approve** is visible; it sets `Tax Match Reviewed = true` and the order's Sales Document is created on the next process run | +| HITL-8 | Review page Approve hidden (auto-released, no conflict) | When the review mode does not hold the order and there is no rate conflict, the order isn't held (its Sales Document is created automatically), so the page's **Approve** action is hidden and the page is informational | +| HITL-9 | Review page scoping + content | The tax lines ListPart shows exactly the tax lines of the current order (filtered by the order's order line ids via `SetTaxLineFilter`), each with its applies-to Item No./description; AI confidence indicators render on Tax Jurisdiction Code | +| HITL-10 | Sales Order prompt is stateless | With `Sales Header."Shpfy Tax Match Applied"` set, the prompt fires iff the originating order's `Tax Match Reviewed = false` and `My Notifications` is enabled; no `Shpfy TMA Notification` table exists | +| HITL-11 | Order-page review notification | On opening a matched, not-yet-reviewed Shopify order, `SendOrderReviewNotification` fires once per order/session; **Review** opens the review page; **Don't show again** disables it via `MyNotifications` | +| HITL-12 | Page review actions | Shpfy Order page: **Review and Approve Tax Match** shows while the order is held (per the review mode or a live rate conflict) and it isn't yet approved, else **Review Tax Match**; both open the review page (hidden when not agent-matched). BC Sales Order page: **Review Tax Match** opens the review page when the marker is set | +| HITL-13 | Review page close guard | When the order is being held (per the review mode, or a rate conflict) and it is not yet approved, closing the Tax Match Review page raises the `OnQueryClosePage` confirmation; declining keeps the page open. No warning once the order is approved | +| RD6 | Approve rebuilds Tax Area | On a **held**, not-yet-reviewed order the **Approve** action is shown; it re-applies the line jurisdictions (re-seeding brackets, re-detecting conflicts), rebuilds the Tax Area, refreshes `Tax Rate Conflict`, and sets `Tax Match Reviewed`. Approve is blocked (error) while any tax line has a blank Tax Jurisdiction Code, **and** errors without releasing the order if no Tax Area can be resolved for the selected jurisdictions (e.g. edited to a set with no existing area and Auto Create Tax Areas is off) | +| RD7 | Rate comparison + edit | Each tax line shows Shopify's rate next to Business Central's Tax Detail rate; the row is green when they agree, red when they differ. Editing a line's Tax Jurisdiction Code recomputes the BC rate/colour; on a rate conflict, the Overview tab shows a guidance message that approving posts at BC's rate | + +**Shop Card field dependencies (SC scenarios)** + +| # | Scenario | Expected Result | +|---|----------|-----------------| +| SC-1 | Tax Matching Agent Enabled = No | Auto Create Jurisdictions/Areas, Naming Pattern, and Review Mode are disabled (greyed out) | +| SC-2 | Enabled = Yes, Auto Create Tax Areas = No | Tax Area Naming Pattern is disabled; the other three are enabled | +| SC-3 | Enabled = Yes, Auto Create Tax Areas = Yes | Tax Area Naming Pattern is enabled | + +--- + +## Hard Matching Scenarios (LLM Stress Tests) + +These scenarios test the LLM's ability to handle ambiguous, misleading, or complex matching situations with real AOAI calls. All use `autoCreateTaxJurisdictions: false` unless noted. + +| # | Scenario | Challenge | Jurisdictions | Tax Lines | Ship-to | Expected | +|---|----------|-----------|---------------|-----------|---------|----------| +| H1 | Similar codes, different states | CASTAX (California) vs CATAX (Canada) — "CA" is ambiguous | CASTAX, CATAX, COSTAX | "CA STATE TAX" @ 7.25% | US / CA / Los Angeles | CASTAX (geographic context: CA = California) | +| H2 | Abbreviated title, multiple similar | "NYC MTA" with 3 MTA-related jurisdictions | MTATAX, MTANYC, MTANYS | "NYC MTA" @ 0.375% | US / NY / New York | Any of the 3 (soft assertion) | +| H3 | Multi-state distractors | Texas order with 15 distractor jurisdictions from other states | 13 non-TX + TXSTAX, TXCTAX | "TEXAS STATE SALES TAX" @ 6.25%, "HOUSTON CITY TAX" @ 2.0% | US / TX / Houston | TXSTAX, TXCTAX | +| H4 | Truncated Shopify title | "METROPOLITAN COMMUTE" (truncated) must match full description | MTATAX, MCTMTX | "METROPOLITAN COMMUTE" @ 0.375% | US / NY / New York | MCTMTX | +| H5 | Canadian HST/GST/PST | Ontario gets HST (combined), not separate GST or PST | CAHST, CAGST, CAPST, BCPST | "HST" @ 13.0% | CA / ON / Toronto | CAHST | +| H6 | Same rate, different scopes | County tax vs transit tax — both LA, similar names | LACOTR, LACOTX, CASTAX | "LOS ANGELES COUNTY TAX" @ 1.0%, "CALIFORNIA STATE TAX" @ 6.0% | US / CA / Los Angeles | LACOTX, CASTAX | +| H7 | Unusual formatting/casing | "State of New York - Sales & Use Tax", "The City of New York Tax" | NYSTAX, NYCTAX | Unusual wording | US / NY / New York | NYSTAX, NYCTAX | +| H8 | Geographic disambiguation | "NEW YORK SALES TAX" from Albany — state not city level | NYCSAL, NYSSAL, NJSSAL | "NEW YORK SALES TAX" @ 4.0% | US / NY / Albany | NYSSAL (state-level, Albany is not NYC) | +| H9 | 5 tax lines, mixed difficulty | Large order — some trivial, others need semantic reasoning | TXSTAX, TXHTAX, TXHCTX, TXMTD, TXESD | "TEXAS STATE SALES TAX", "CITY OF HOUSTON TAX", "HARRIS CO TAX", "METRO TRANSIT AUTHORITY", "ESD #1" | US / TX / Houston | All 5 matched (soft assertion) | +| H10 | Misleading jurisdiction code | WATAX = Washington, not Waterloo — LLM must read descriptions | WATAX, IASTAX, IACTAX | "IOWA STATE TAX" @ 6.0%, "WATERLOO LOCAL TAX" @ 1.0% | US / IA / Waterloo | IASTAX, IACTAX | +| H11 | Auto-create with distractors | 10+ existing jurisdictions; match what fits, create new for rest | 10 mixed-state jurisdictions including NYSTAX | "NEW YORK STATE TAX" @ 4.0%, "YONKERS SURCHARGE" @ 1.5% | US / NY / Yonkers | NYSTAX matched; Yonkers auto-created (autoCreate=true) | +| H12 | Non-English tax titles | French Canadian abbreviations: TPS = GST, TVQ = QST | QCGST, QCQST, CAHST | "TPS/GST" @ 5.0%, "TVQ/QST" @ 9.975% | CA / QC / Montreal | QCGST, QCQST | + +--- + +## LLM Error / Edge Cases + +| # | Scenario | Expected Result | +|---|----------|-----------------| +| E1 | LLM API call fails | Logged as error; no matches applied; order unchanged | +| E2 | LLM returns no function call | Logged as error; no matches applied | +| E3 | Function call marked as failed | Logged as error; no matches applied | +| E4 | Malformed tax_line_id (not `ParentId-LineNo` format) | Skipped gracefully (Evaluate guard) | +| E5 | Non-numeric tax_line_id parts | Skipped gracefully (Evaluate returns false) | +| E6 | LLM returns jurisdiction_code > 10 chars | Truncated by CopyStr to 10 chars | +| E7 | LLM returns unknown confidence value | Treated as non-low (matched if jurisdiction exists) | +| E8 | Empty matches array | ApplyMatches returns false; no changes | +| E9 | Missing `matches` key in response | ApplyMatches returns false; no changes | +| E10 | Tax line ID points to non-existent record | TaxLine.Get fails; skipped; other matches still applied | + +--- + +## End-to-End Flows + +| # | Flow | Setup | Order Data | Expected End State | +|---|------|-------|------------|-------------------| +| F1 | Full auto-create | Enabled, Auto Create Juris.=Yes, Auto Areas=Yes | 3 tax lines: NY State, NYC City, MTA | 3 new jurisdictions (with Country/Region, Report-to=state), 3 tax details, 1 new tax area `SHPFY-MTATAX`, order has Tax Area Code + Tax Liable=Yes | +| F2 | Match only, no create | Enabled, Auto Create=No for both | 3 tax lines, all 3 jurisdictions + tax area pre-exist | Existing jurisdictions matched, existing tax area found and assigned | +| F3 | Partial match | Enabled, Auto Create Juris.=No, Auto Areas=Yes | 3 tax lines, only NYSTAX exists | Only NYSTAX matched; no tax area created (only 1 of 3 jurisdictions matched, so area wouldn't match expected set) | +| F4 | Re-import same order | Same as F1 | Same order imported again | Tax lines already matched, Tax Area already set; the agent skipped entirely (guard: Tax Area Code <> '') | +| F5 | Address-based match wins | Enabled | Order where MapTaxArea finds a match by address | Tax Area set by MapTaxArea; the agent never runs (guard: Tax Area Code <> '') | +| F6 | Sales document creation | After F1 completes | Create Sales Order from Shopify order | Sales Header has Tax Area Code = `SHPFY-MTATAX`, Tax Liable = Yes | + +--- + +## Responsible AI (RAI) + +The Tax Matching Agent is covered by Responsible AI tests — deterministic cross-prompt-injection +(XPIA) scenarios and dynamic **Red Team Scan** harms/jailbreak passes. Because this repository is +public, those tests and their adversarial datasets are **not** kept here: they live in the internal +NAV enlistment app **Shopify Connector NA AI Tests** (`App/Internal/Apps/ShopifyNAAITest`, ID range +134721-134732), which reuses this test app's library/verify and the matcher via `internalsVisibleTo`. +The mitigation under test is the security/guardrail prompt merged from Azure Key Vault at runtime +(secret `ShopifyTaxMatchingAgentSecurityPrompt`); the tests read it from the vault (no mock), so the +eval environment must have the secret provisioned via the enlistment commands. + +--- + +## Generated Accuracy Dataset (large-scale matching accuracy) + +To measure jurisdiction-matching accuracy at scale (beyond the small curated J/H set), a dedicated +**Weekly** suite `TMA-ACCUR` runs a large, generated corpus through `Shpfy TMA Match Test` (134717). +The small curated J/H scenarios stay in the **Daily** `TMA-UNIT` gate; the large corpus runs Weekly +(mirrors the Payables Agent `PA-ACCUR` split): + +| Dataset | Scenarios | Coverage | +|---------|-----------|----------| +| `TMA-TS-AccUS-1.yaml` | ~144 | US states (first half of the catalog) — synthetic perturbations | +| `TMA-TS-AccUS-2.yaml` | ~139 | US states (second half) — synthetic perturbations | +| `TMA-TS-AccCA.yaml` | ~65 | Canada provinces incl. French (TPS/TVQ) — synthetic perturbations | +| `TMA-TS-RealUS.yaml` | ~45 | Real US localities, real Shopify titles, reworded BC descriptions | +| `TMA-TS-RealCA.yaml` | ~14 | Real CA provinces, real Shopify titles (GST/PST/HST/QST + TPS/TVQ) | +| `TMA-TS-Heldout.yaml` | ~6 | Held-out generalization: unusual official names **not** named in the prompt | +| `TMA-TS-Traps.yaml` | ~7 | Traps: correct answer is no-match despite tempting distractors (false-positive probes) | +| `TMA-TS-Hard.yaml` | ~10 | Adversarial hard mode: homonym places, geo/substring traps, code-vs-desc conflict, many distractors, level discrimination, Code[20] truncation collision | + +**Two dataset families.** *Synthetic* (`TMA-TS-Acc*`) exercises breadth through systematic title +**perturbations** — 24 US states + 7 CA provinces (state/province + county/city/district), via +exact, sales-variant, abbreviation, minimal-abbreviation, punctuation, embedded-rate, reworded, +`Code[20]` truncation, plus structural cases: multi-line orders, distractor jurisdictions, unmatched +(`jurisdictionCode: ""`), auto-create (`hasJurisdictionCode`), and non-English (Quebec French). + +*Real-world* (`TMA-TS-Real*`) reduces the circularity of the synthetic set by using the **actual +Shopify tax-line title conventions** (US `" State Tax"` / `" County Tax"` / +`" City Tax"`; CA bare `"GST"`/`"PST"`/`"HST"`/`"QST"` + French `"TPS"`/`"TVQ"`), **real** +US state/county/city and CA province tax authorities, and BC candidate jurisdiction Descriptions that +are deliberately **reworded** so they are not string-identical to the title (e.g. Shopify +`"Los Angeles City Tax"` vs BC `"City of Los Angeles"`). This forces a genuine semantic match rather +than string equality — a closer proxy for production accuracy. Sources: Shopify Admin API TaxLine +docs/community examples; Tax Foundation *Sales Tax Rates by City, 2024*; CRA GST/HST + provincial +PST/QST. + +**Generation** — both families are produced deterministically (no PII; public US/CA tax facts) so the +corpus can be regenerated while hill-climbing the prompt. Each tax line is labeled with its target +jurisdiction, so accuracy = matched / total. + +**Held-out generalization + false-positive probes.** Because the matcher prompt intentionally carries +a few real official-name aliases (e.g. Retailers' Occupation Tax = state sales tax; RST = PST), a +scenario whose answer is *spelled out in the prompt* proves little. Two datasets guard against that: + +- `TMA-TS-Heldout.yaml` — real jurisdictions with **unusual official names deliberately NOT mentioned + in the prompt** (Hawaii General Excise Tax, New Mexico Gross Receipts Tax, Puerto Rico *IVU* + (Spanish), Colorado RTD district, Nevada Local School Support Tax, Florida Discretionary Sales + Surtax). Passing these measures **generalization** of the general geography/level principle, not + memorization of prompt aliases. +- `TMA-TS-Traps.yaml` — cases where the correct answer is **no match** (or one specific look-alike) + despite a tempting distractor: wrong-state, near-duplicate city (geo disambiguation), missing + county level, similar-name-different-scope, absent city, wrong tax *type* (franchise vs sales), and + empty candidate list. A non-empty wrong match here is a **false positive** — the dangerous failure + mode for a human-in-the-loop matcher (a no-match is safe; a confident wrong match is not). + +**False-positive metric.** Plain pass-rate hides confident wrong matches. `files/tma_fp_metric.py` +(session artifact) reads any `*_Eval_Summary.xlsx` and classifies every jurisdiction assertion as +*correct*, *miss/no-match (safe)*, or *wrong-match / over-match (false positive)*, reported overall +and per family. The target is **zero false positives**; misses are tolerated (held for review). + +**Adversarial hard mode.** Because the model aces the sets above (GPT-4.1 has strong public tax +knowledge), `TMA-TS-Hard.yaml` is deliberately built to find the failure boundary, each case with a +single defensible answer: homonym places disambiguated only by ship-to (Washington/Franklin County +across states; Portland OR vs ME; Columbus OH vs GA); geography traps (Kansas City is in *Missouri*; +"Arkansas" contains "kansas"); a **code-vs-description conflict** (a candidate coded `TEXAS` but +described "Tennessee"); level discrimination against a higher-overlap distractor; a dozen near- +identical Springfields; and a **Code[20] truncation collision** (two titles that both truncate to +`METROPOLITAN TRANSPO` — a data-pipeline limitation probe, expected to be effectively impossible). +This family is *expected* to score below 100%; a miss here is a real hill-climb target (or, for the +truncation case, motivation to store a longer title). + +**Metric** — this corpus is intentionally **not** a 100% pass gate. Accuracy is expected to be +below 100% and improved over time (hill-climb); each scenario asserts the exact expected +jurisdiction, so the AI Test Toolkit reports the pass rate per dataset line. The *real-world* family +is the more honest accuracy signal; the *synthetic* family is the broader regression/robustness net. + +--- +## Automated Test Coverage + +| Test codeunit | ID | Covers | +|---------------|----|--------| +| `Shpfy TMA Match Test` | 134717 | Jurisdiction matching (J*), creation (JC*), Tax Detail incl. rate-conflict TD3/RD1, shipping (ST*) — data-driven via the real LLM + `MatchTaxLines`; plus the large generated US/CA accuracy corpus (`TMA-TS-Acc*.yaml`, run Weekly via the `TMA-ACCUR` suite) | +| `Shpfy TMA Tax Area Test` | 134718 | Tax Area find/create (TA*) — `FindOrCreateTaxArea` | +| `Shpfy TMA Guard Test` | 134719 | Guard / early-exit (GD*, P1–P6) | +| `Shpfy TMA HITL Test` | 134716 | HITL-1…6 — marker propagation, `MarkReviewed`, `DisableForUser`, Activity Log helpers, `Capitalize` | +| `Shpfy TMA Rate Conflict Test` | 134720 | Rate-conflict recheck/flip on approve (RD1/RD5/RD6 core via `ReapplyFromAssignedLines`), including **shipping** tax lines (shipping bracket seeded from the shipping line's own rate; shipping rate conflict holds — S7); the Report-to rollup on re-apply (JC6 — `ReapplySetsReportToOnBlankJurisdictions` sets a blank Report-to on any matched jurisdiction incl. the state; `ReapplyPreservesExistingReportTo` leaves an admin-set one untouched); the creation gate RD3/RD4 + released cases (`IsSalesDocumentCreationHeld`), the **review-mode** gate RM1–RM7 and `IsHeldForReviewPreference` (`GateHeldWhenAlwaysRegardlessOfConfidence`, `GateHeldWhenLowConfidenceOnlyAndLowConfidence`, `GateNotHeldWhenLowConfidenceOnlyAndHighConfidence`, `GateNotHeldWhenNeverAndLowConfidence`, `GateHeldWhenNeverButRateConflict`, `GateHeldWhenNeverButIncomplete`, `ReviewPreferenceFollowsMode`), the business guards P4/tax-exempt/enabled (`ShouldAttemptMatch`), and Undo Approval RD9 (`UndoApproval`) | + +The Responsible AI tests (XPIA + Red Team Scan harms/jailbreak — codeunits 134721-134724) are **not** in this public app; they live in the internal **Shopify Connector NA AI Tests** app (`App/Internal/Apps/ShopifyNAAITest`). See the Responsible AI section above. + +**Verified manually / by TestPage (not unit-automated):** the page-property scenarios — Approve/Undo action visibility, BC-rate column + green/red styling (RD7), edit-revert-on-close (RD8), the **Use Shopify Rate** action (RD10), review-page close guard (HITL-13), page action captions (HITL-12), notification prompts (HITL-10/11), and Shop Card field enable/disable (SC-1…3) — as these are page-lifecycle/UI behaviors best exercised through the client. \ No newline at end of file diff --git a/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/Codeunits/ShpfyTMANotify.Codeunit.al b/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/Codeunits/ShpfyTMANotify.Codeunit.al new file mode 100644 index 00000000000..1b5aa8fcf8f --- /dev/null +++ b/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/Codeunits/ShpfyTMANotify.Codeunit.al @@ -0,0 +1,292 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.Integration.Shopify; + +using Microsoft.Sales.Document; +using System.Environment.Configuration; +using System.Telemetry; + +/// +/// Codeunit Shpfy TMA Notify (ID 30476). +/// Owns the tax review notifications (on the BC Sales Order and the Shopify Order) +/// and the review-page drills. The notifications are stateless: whether to prompt is +/// derived live from the Sales Header marker, the originating Shopify order's +/// Tax Match Reviewed flag, and the per-user My Notifications toggle — there is no +/// dedicated notification table. +/// +codeunit 30476 "Shpfy TMA Notify" +{ + Access = Internal; + InherentPermissions = X; + InherentEntitlements = X; + + var + FeatureNotificationIdLbl: Label '{e9d8c7b6-a5f4-4e32-9d10-cb87a65f43e2}', Locked = true; + OrderNotificationIdLbl: Label '{a7c3f1e2-9b4d-4c8a-8e6f-2d1b0a9c8e7d}', Locked = true; + NotifMsgLbl: Label 'Tax Matching Agent set Tax Area %1 on this Shopify order. Review before posting.', Comment = '%1 = Tax Area Code'; + OrderNotifMsgLbl: Label 'Tax Matching Agent set Tax Area %1 on this Shopify order. Review the matched tax jurisdictions.', Comment = '%1 = Tax Area Code'; + OrderNotifConflictMsgLbl: Label 'Tax Matching Agent set Tax Area %1 on this Shopify order, but one or more tax rates differ from Business Central. Review the highlighted rates before creating the sales document.', Comment = '%1 = Tax Area Code'; + ShowDecisionsActionLbl: Label 'Show Tax Match Decisions'; + OrderReviewActionLbl: Label 'Review'; + MarkReviewedActionLbl: Label 'Mark as reviewed'; + DisableActionLbl: Label 'Don''t show again'; + MyNotificationCaptionLbl: Label 'Shopify Tax Matching Agent review prompt'; + MyNotificationDescriptionLbl: Label 'Shows a one-time prompt on each Sales Order where the Tax Matching Agent populated tax fields from a Shopify order, so you can review the AI-generated decisions before posting.'; + OrderMyNotifCaptionLbl: Label 'Shopify Tax Matching Agent order review prompt'; + OrderMyNotifDescriptionLbl: Label 'Shows a prompt on a Shopify order whose tax was matched by the Tax Matching Agent, so you can review and approve the AI-generated tax match.'; + + /// + /// Fires the review prompt on the Sales Order when the Tax Matching Agent populated its tax fields and + /// the originating Shopify order has not yet been reviewed. Stateless — whether to prompt + /// comes from the order's Tax Match Reviewed flag plus the per-user My + /// Notifications toggle, so no per-user row is stored. + /// + procedure SendForCurrentSalesHeader(SalesHeader: Record "Sales Header") + var + OrderHeader: Record "Shpfy Order Header"; + MyNotifications: Record "My Notifications"; + TMARegister: Codeunit "Shpfy TMA Register"; + FeatureTelemetry: Codeunit "Feature Telemetry"; + Notif: Notification; + begin + if not GuiAllowed() then + exit; + if not FindOrderForReview(SalesHeader, OrderHeader) then + exit; + if OrderHeader."Tax Match Reviewed" then + exit; + + if MyNotifications.WritePermission() then + MyNotifications.InsertDefault(GetFeatureNotificationId(), MyNotificationCaptionLbl, MyNotificationDescriptionLbl, true); + if not MyNotifications.IsEnabled(GetFeatureNotificationId()) then + exit; + + Notif.Id := GetFeatureNotificationId(); + Notif.Message(StrSubstNo(NotifMsgLbl, SalesHeader."Tax Area Code")); + Notif.Scope := NotificationScope::LocalScope; + Notif.SetData('SalesHeaderSystemId', Format(SalesHeader.SystemId)); + Notif.AddAction(ShowDecisionsActionLbl, Codeunit::"Shpfy TMA Notify", 'OpenShopifyOrder'); + Notif.AddAction(MarkReviewedActionLbl, Codeunit::"Shpfy TMA Notify", 'MarkReviewed'); + Notif.AddAction(DisableActionLbl, Codeunit::"Shpfy TMA Notify", 'DisableForUser'); + Notif.Send(); + + FeatureTelemetry.LogUsage('0000UMT', TMARegister.FeatureName(), 'tax review notification sent'); + end; + + procedure OpenShopifyOrder(Notif: Notification) + var + SalesHeader: Record "Sales Header"; + OrderMgt: Codeunit "Shpfy Order Mgt."; + TMARegister: Codeunit "Shpfy TMA Register"; + FeatureTelemetry: Codeunit "Feature Telemetry"; + VariantRec: Variant; + begin + if not TryGetSalesHeader(Notif, SalesHeader) then + exit; + + // Prefer the Tax Match Review page; fall back to the raw Shopify order. + if RunReviewForSalesHeader(SalesHeader) then + FeatureTelemetry.LogUsage('0000UMU', TMARegister.FeatureName(), 'tax review opened') + else begin + VariantRec := SalesHeader; + OrderMgt.ShowShopifyOrder(VariantRec); + FeatureTelemetry.LogUsage('0000UN9', TMARegister.FeatureName(), 'tax review fell back to the Shopify order (review page could not be resolved)'); + end; + end; + + /// + /// Sends the actionable review prompt on the Shopify order itself. The order page calls + /// this once per order per page session. Clicking Review opens the Tax Match + /// Review page. + /// + procedure SendOrderReviewNotification(OrderHeader: Record "Shpfy Order Header") + var + MyNotifications: Record "My Notifications"; + TMARegister: Codeunit "Shpfy TMA Register"; + FeatureTelemetry: Codeunit "Feature Telemetry"; + Notif: Notification; + begin + if not GuiAllowed() then + exit; + + if MyNotifications.WritePermission() then + MyNotifications.InsertDefault(GetOrderNotificationId(), OrderMyNotifCaptionLbl, OrderMyNotifDescriptionLbl, true); + if not MyNotifications.IsEnabled(GetOrderNotificationId()) then + exit; + + Notif.Id := GetOrderNotificationId(); + if OrderHeader."Tax Rate Conflict" then + Notif.Message(StrSubstNo(OrderNotifConflictMsgLbl, OrderHeader."Tax Area Code")) + else + Notif.Message(StrSubstNo(OrderNotifMsgLbl, OrderHeader."Tax Area Code")); + Notif.Scope := NotificationScope::LocalScope; + Notif.SetData('ShpfyOrderSystemId', Format(OrderHeader.SystemId)); + Notif.AddAction(OrderReviewActionLbl, Codeunit::"Shpfy TMA Notify", 'OpenReviewForOrder'); + Notif.AddAction(DisableActionLbl, Codeunit::"Shpfy TMA Notify", 'DisableOrderNotifForUser'); + Notif.Send(); + + FeatureTelemetry.LogUsage('0000UMV', TMARegister.FeatureName(), 'tax order review notification sent'); + end; + + procedure OpenReviewForOrder(Notif: Notification) + var + OrderHeader: Record "Shpfy Order Header"; + TMARegister: Codeunit "Shpfy TMA Register"; + FeatureTelemetry: Codeunit "Feature Telemetry"; + SystemIdText: Text; + OrderSystemId: Guid; + begin + SystemIdText := Notif.GetData('ShpfyOrderSystemId'); + if SystemIdText = '' then + exit; + if not Evaluate(OrderSystemId, SystemIdText) then + exit; + if not OrderHeader.GetBySystemId(OrderSystemId) then + exit; + + RunReviewPage(OrderHeader); + + FeatureTelemetry.LogUsage('0000UMW', TMARegister.FeatureName(), 'tax order review opened'); + end; + + procedure DisableOrderNotifForUser(Notif: Notification) + var + MyNotifications: Record "My Notifications"; + begin + if MyNotifications.WritePermission() then + if not MyNotifications.Disable(GetOrderNotificationId()) then + MyNotifications.InsertDefault(GetOrderNotificationId(), OrderMyNotifCaptionLbl, OrderMyNotifDescriptionLbl, false); + end; + + /// + /// Opens the Tax Match Review page for the Shopify order that produced the given + /// Sales Header (resolved via Shpfy Order Header."Sales Order No."). Returns false when no + /// such order exists, so callers can fall back to another surface. + /// + internal procedure RunReviewForSalesHeader(SalesHeader: Record "Sales Header"): Boolean + var + OrderHeader: Record "Shpfy Order Header"; + begin + if not FindOrderForReview(SalesHeader, OrderHeader) then + exit(false); + + RunReviewPage(OrderHeader); + exit(true); + end; + + /// + /// The single entry point that opens the Tax Match Review page for a Shopify + /// order. All review surfaces (order-page action, order-page notification, Sales Order + /// action, Sales Order notification) resolve their Shpfy Order Header and route through + /// here, so the page is opened one consistent way. + /// + internal procedure RunReviewPage(var OrderHeader: Record "Shpfy Order Header") + begin + OrderHeader.SetRecFilter(); + Page.Run(Page::"Shpfy TMA Review", OrderHeader); + end; + + procedure MarkReviewed(Notif: Notification) + var + SalesHeader: Record "Sales Header"; + OrderHeader: Record "Shpfy Order Header"; + TMARegister: Codeunit "Shpfy TMA Register"; + FeatureTelemetry: Codeunit "Feature Telemetry"; + begin + if not TryGetSalesHeader(Notif, SalesHeader) then + exit; + if not FindOrderForReview(SalesHeader, OrderHeader) then + exit; + if OrderHeader."Tax Match Reviewed" then + exit; + + OrderHeader."Tax Match Reviewed" := true; + OrderHeader.Modify(); + + FeatureTelemetry.LogUsage('0000UMX', TMARegister.FeatureName(), 'tax review notification marked reviewed'); + end; + + /// + /// Reverses an approval: clears the order's Tax Match Reviewed flag so the order is + /// held for review again (it is created only once re-approved), and un-verifies the + /// agent-created Tax Jurisdictions the order used so they return to their provisional state. + /// Exposed as internal so the review page's Undo Approval action and tests both drive the + /// same state change. + /// + internal procedure UndoApproval(var OrderHeader: Record "Shpfy Order Header") + var + TMAMatcher: Codeunit "Shpfy TMA Matcher"; + TMARegister: Codeunit "Shpfy TMA Register"; + FeatureTelemetry: Codeunit "Feature Telemetry"; + begin + if not OrderHeader."Tax Match Reviewed" then + exit; + + OrderHeader."Tax Match Reviewed" := false; + OrderHeader.Modify(); + + TMAMatcher.UnverifyAgentJurisdictions(OrderHeader); + + FeatureTelemetry.LogUsage('0000UN7', TMARegister.FeatureName(), 'tax match approval undone'); + end; + + procedure DisableForUser(Notif: Notification) + var + MyNotifications: Record "My Notifications"; + TMARegister: Codeunit "Shpfy TMA Register"; + FeatureTelemetry: Codeunit "Feature Telemetry"; + begin + if MyNotifications.WritePermission() then + if not MyNotifications.Disable(GetFeatureNotificationId()) then + MyNotifications.InsertDefault(GetFeatureNotificationId(), MyNotificationCaptionLbl, MyNotificationDescriptionLbl, false); + MarkReviewed(Notif); + + FeatureTelemetry.LogUsage('0000UMY', TMARegister.FeatureName(), 'tax review notification disabled per user'); + end; + + /// + /// Resolves the originating Shopify order for a Sales Header via + /// Shpfy Order Header."Sales Order No.". Returns false when the Sales Header has no + /// number or no linked Shopify order. + /// + local procedure FindOrderForReview(SalesHeader: Record "Sales Header"; var OrderHeader: Record "Shpfy Order Header"): Boolean + begin + if SalesHeader."No." = '' then + exit(false); + OrderHeader.SetCurrentKey("Sales Order No."); + OrderHeader.SetRange("Sales Order No.", SalesHeader."No."); + exit(OrderHeader.FindFirst()); + end; + + local procedure TryGetSalesHeader(Notif: Notification; var SalesHeader: Record "Sales Header"): Boolean + var + SystemIdText: Text; + SystemId: Guid; + begin + SystemIdText := Notif.GetData('SalesHeaderSystemId'); + if SystemIdText = '' then + exit(false); + if not Evaluate(SystemId, SystemIdText) then + exit(false); + exit(SalesHeader.GetBySystemId(SystemId)); + end; + + local procedure GetFeatureNotificationId(): Guid + var + FeatureNotificationId: Guid; + begin + Evaluate(FeatureNotificationId, FeatureNotificationIdLbl); + exit(FeatureNotificationId); + end; + + local procedure GetOrderNotificationId(): Guid + var + OrderNotificationId: Guid; + begin + Evaluate(OrderNotificationId, OrderNotificationIdLbl); + exit(OrderNotificationId); + end; +} diff --git a/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/Page Extensions/ShpfyTMAOrder.PageExt.al b/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/Page Extensions/ShpfyTMAOrder.PageExt.al new file mode 100644 index 00000000000..31025a449bf --- /dev/null +++ b/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/Page Extensions/ShpfyTMAOrder.PageExt.al @@ -0,0 +1,96 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.Integration.Shopify; + +/// +/// PageExtension Shpfy TMA Order (ID 30479) extends Shpfy Order (page 30113). +/// Surfaces the tax match on the Shopify order: an actionable notification and a +/// review action (review icon) that opens the Tax Match Review page (page 30471), +/// where the resolved Tax Area and per-line Tax Jurisdiction Codes are shown with AI +/// confidence indicators and the match is approved. The action is captioned "Review and +/// Approve Tax Match" while approval is still pending (the order is held — the shop +/// requires review or there is a rate conflict — and it is not yet approved) and "Review +/// Tax Match" otherwise. A held order's Sales Document is not created until the match +/// is approved on the review page. +/// +pageextension 30479 "Shpfy TMA Order" extends "Shpfy Order" +{ + actions + { + addafter(CancelOrder) + { + action(ShpfyReviewAndApproveTaxMatch) + { + ApplicationArea = All; + Caption = 'Review and Approve Tax Match'; + Image = SparkleFilled; + ToolTip = 'Opens the tax match review for this order. Because this order is held for review, the Sales Document is not created until you approve the match on the review page.'; + Visible = ShowReviewEntry and not Rec."Tax Match Reviewed" and (ReviewRequired or Rec."Tax Rate Conflict" or Rec."Tax Match Incomplete"); + + trigger OnAction() + begin + OpenReviewPage(); + end; + } + action(ShpfyReviewTaxMatch) + { + ApplicationArea = All; + Caption = 'Review Tax Match'; + Image = SparkleFilled; + ToolTip = 'Opens the tax match review for this order, where you can see the resolved Tax Area and the per-line Tax Jurisdiction Codes that the Tax Matching Agent matched.'; + Visible = ShowReviewEntry and (Rec."Tax Match Reviewed" or not (ReviewRequired or Rec."Tax Rate Conflict" or Rec."Tax Match Incomplete")); + + trigger OnAction() + begin + OpenReviewPage(); + end; + } + } + addfirst(Category_Process) + { + actionref(ShpfyReviewAndApproveTaxMatch_Promoted; ShpfyReviewAndApproveTaxMatch) { } + actionref(ShpfyReviewTaxMatch_Promoted; ShpfyReviewTaxMatch) { } + } + } + + trigger OnAfterGetCurrRecord() + var + Shop: Record "Shpfy Shop"; + TMANotify: Codeunit "Shpfy TMA Notify"; + TMAEvents: Codeunit "Shpfy TMA Events"; + begin + if Shop.Get(Rec."Shop Code") then begin + TaxMatchingAgentEnabled := Shop."Tax Matching Agent Enabled"; + ReviewRequired := TMAEvents.IsHeldForReviewPreference(Rec, Shop); + end else begin + TaxMatchingAgentEnabled := false; + ReviewRequired := false; + end; + + ShowReviewEntry := TaxMatchingAgentEnabled and Rec."Tax Match Applied"; + + if ShowReviewEntry and (not Rec."Tax Match Reviewed") and (NotifiedOrderId <> Rec."Shopify Order Id") then begin + TMANotify.SendOrderReviewNotification(Rec); + NotifiedOrderId := Rec."Shopify Order Id"; + end; + end; + + var + TaxMatchingAgentEnabled: Boolean; + ReviewRequired: Boolean; + ShowReviewEntry: Boolean; + NotifiedOrderId: BigInteger; + + local procedure OpenReviewPage() + var + OrderHeader: Record "Shpfy Order Header"; + TMANotify: Codeunit "Shpfy TMA Notify"; + begin + OrderHeader := Rec; + TMANotify.RunReviewPage(OrderHeader); + CurrPage.Update(false); + end; +} diff --git a/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/Page Extensions/ShpfyTMAOrderTaxLines.PageExt.al b/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/Page Extensions/ShpfyTMAOrderTaxLines.PageExt.al new file mode 100644 index 00000000000..d5169138803 --- /dev/null +++ b/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/Page Extensions/ShpfyTMAOrderTaxLines.PageExt.al @@ -0,0 +1,48 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.Integration.Shopify; + +/// +/// PageExtension Shpfy TMA Order Tax Lines (ID 30478) extends Shpfy Order Tax Lines (page 30168). +/// Adds the Tax Jurisdiction Code column to the standalone tax lines list — visible only when +/// the order's shop has Tax Matching Agent enabled. The column lives in the Tax Matching Agent app so +/// the standard connector page is uncluttered for tenants that do not use the feature. +/// +pageextension 30478 "Shpfy TMA Order Tax Lines" extends "Shpfy Order Tax Lines" +{ + layout + { + addafter("Channel Liable") + { + field("Tax Jurisdiction Code"; Rec."Tax Jurisdiction Code") + { + ApplicationArea = All; + ToolTip = 'Specifies the Business Central Tax Jurisdiction matched to this Shopify tax line.'; + Visible = TaxMatchingAgentEnabled; + } + } + } + + var + TaxMatchingAgentEnabled: Boolean; + + trigger OnAfterGetRecord() + var + OrderLine: Record "Shpfy Order Line"; + OrderHeader: Record "Shpfy Order Header"; + Shop: Record "Shpfy Shop"; + begin + TaxMatchingAgentEnabled := false; + OrderLine.SetRange("Line Id", Rec."Parent Id"); + if not OrderLine.FindFirst() then + exit; + if not OrderHeader.Get(OrderLine."Shopify Order Id") then + exit; + if not Shop.Get(OrderHeader."Shop Code") then + exit; + TaxMatchingAgentEnabled := Shop."Tax Matching Agent Enabled"; + end; +} diff --git a/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/Page Extensions/ShpfyTMASalesOrder.PageExt.al b/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/Page Extensions/ShpfyTMASalesOrder.PageExt.al new file mode 100644 index 00000000000..af7009783b3 --- /dev/null +++ b/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/Page Extensions/ShpfyTMASalesOrder.PageExt.al @@ -0,0 +1,74 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.Integration.Shopify; + +using Microsoft.Sales.Document; + +/// +/// PageExtension Shpfy TMA Sales Order (ID 30476) extends Sales Order (page 42). +/// Surfaces the Tax Match Applied marker as a read-only badge on the Sales Order +/// and adds a Review Tax Match action that opens the Tax Match Review page +/// for the originating Shopify order, where the resolved Tax Area and per-line Tax +/// Jurisdiction Codes are shown with AI confidence indicators. +/// +pageextension 30476 "Shpfy TMA Sales Order" extends "Sales Order" +{ + layout + { + addafter("Tax Liable") + { + field(ShpfyTaxMatchApplied; Rec."Shpfy Tax Match Applied") + { + ApplicationArea = All; + Caption = 'Shopify Tax Match Applied'; + Editable = false; + Importance = Additional; + ToolTip = 'Specifies that the Tax Matching Agent populated the Tax Area Code on the originating Shopify order. Use the Review Shopify Tax Match action to review the AI-generated decisions.'; + } + } + } + actions + { + addlast(navigation) + { + action(ShpfyReviewTaxMatch) + { + ApplicationArea = All; + Caption = 'Review Shopify Tax Match'; + Image = SparkleFilled; + ToolTip = 'Opens the tax match review for the originating Shopify order, where you can see the resolved Tax Area and per-line Tax Jurisdiction Codes together with the AI confidence and explanation for each agent-matched field.'; + Visible = Rec."Shpfy Tax Match Applied"; + + trigger OnAction() + var + TMANotify: Codeunit "Shpfy TMA Notify"; + OrderMgt: Codeunit "Shpfy Order Mgt."; + VariantRec: Variant; + begin + if not TMANotify.RunReviewForSalesHeader(Rec) then begin + VariantRec := Rec; + OrderMgt.ShowShopifyOrder(VariantRec); + end; + end; + } + } + } + + var + NotifiedSystemId: Guid; + + trigger OnAfterGetCurrRecord() + var + TMANotify: Codeunit "Shpfy TMA Notify"; + begin + if not Rec."Shpfy Tax Match Applied" then + exit; + if NotifiedSystemId = Rec.SystemId then + exit; + NotifiedSystemId := Rec.SystemId; + TMANotify.SendForCurrentSalesHeader(Rec); + end; +} diff --git a/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/Page Extensions/ShpfyTMAShopCard.PageExt.al b/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/Page Extensions/ShpfyTMAShopCard.PageExt.al new file mode 100644 index 00000000000..f16e645b157 --- /dev/null +++ b/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/Page Extensions/ShpfyTMAShopCard.PageExt.al @@ -0,0 +1,60 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.Integration.Shopify; + +using System.Telemetry; + +/// +/// PageExtension Shpfy TMA Shop Card (ID 30470) extends Shpfy Shop Card. +/// Adds a Tax Matching Agent tab to the Shop Card. Dependent settings are disabled until +/// their prerequisite is set, so a field that has no effect cannot be edited. +/// +pageextension 30470 "Shpfy TMA Shop Card" extends "Shpfy Shop Card" +{ + layout + { + addlast(content) + { + group(TaxMatchingAgent) + { + Caption = 'Tax Matching Agent'; + + field("Tax Matching Agent Enabled"; Rec."Tax Matching Agent Enabled") + { + ApplicationArea = All; + } + field("Auto Create Tax Jurisdictions"; Rec."Auto Create Tax Jurisdictions") + { + ApplicationArea = All; + Enabled = Rec."Tax Matching Agent Enabled"; + } + field("Auto Create Tax Areas"; Rec."Auto Create Tax Areas") + { + ApplicationArea = All; + Enabled = Rec."Tax Matching Agent Enabled"; + } + field("Tax Area Naming Pattern"; Rec."Tax Area Naming Pattern") + { + ApplicationArea = All; + Enabled = Rec."Tax Matching Agent Enabled" and Rec."Auto Create Tax Areas"; + } + field("Tax Match Review Mode"; Rec."Tax Match Review Mode") + { + ApplicationArea = All; + Enabled = Rec."Tax Matching Agent Enabled"; + } + } + } + } + + trigger OnOpenPage() + var + TMARegister: Codeunit "Shpfy TMA Register"; + FeatureTelemetry: Codeunit "Feature Telemetry"; + begin + FeatureTelemetry.LogUptake('0000UN8', TMARegister.FeatureName(), Enum::"Feature Uptake Status"::Discovered); + end; +} diff --git a/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/Pages/ShpfyTMAOrderTaxLinesPart.Page.al b/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/Pages/ShpfyTMAOrderTaxLinesPart.Page.al new file mode 100644 index 00000000000..21f0a89f378 --- /dev/null +++ b/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/Pages/ShpfyTMAOrderTaxLinesPart.Page.al @@ -0,0 +1,255 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.Integration.Shopify; + +using System.Telemetry; + +/// +/// Page Shpfy TMA Order Tax Lines Part (ID 30479). +/// ListPart variant of the Shopify Order Tax Lines surface, embedded as a subform on the +/// Tax Match Review page (page 30471). The platform renders AI confidence indicators +/// on Activity Log-anchored fields when they appear on a Card or in a ListPart embedded +/// in a Card — so embedding this part is what makes per-tax-line agent indicators +/// visible. The Tax Jurisdiction Code is editable so a reviewer can correct or complete a +/// match, and each line shows Business Central's Tax Detail rate next to Shopify's rate with +/// the row highlighted green when they agree and red when they differ. The host page calls +/// SetTaxLineFilter to scope the part to a single order's tax lines (tax lines link to order +/// lines via Parent Id, so the host passes the order's order line ids). +/// +page 30479 "Shpfy TMA Order Tax Lines Part" +{ + Caption = 'Tax Lines'; + PageType = ListPart; + SourceTable = "Shpfy Order Tax Line"; + DeleteAllowed = false; + Editable = true; + InsertAllowed = false; + ModifyAllowed = true; + + layout + { + area(content) + { + repeater(Group) + { + field(AppliesToItemNo; AppliesToItemNo) + { + ApplicationArea = All; + Caption = 'Applies-to Item No.'; + Editable = false; + ToolTip = 'Specifies the item number of the order line this tax line is charged on.'; + } + field(AppliesToItem; AppliesToItemDescription) + { + ApplicationArea = All; + Caption = 'Applies-to Item'; + Editable = false; + ToolTip = 'Specifies the description of the order line this tax line is charged on, so you can see which item each tax line corresponds to.'; + } + field(Title; Rec.Title) + { + ApplicationArea = All; + Editable = false; + ToolTip = 'Specifies the title of the tax line as imported from Shopify.'; + } + field(Rate; Rec.Rate) + { + ApplicationArea = All; + Editable = false; + ToolTip = 'Specifies the rate of the tax line.'; + } + field(Amount; Rec.Amount) + { + ApplicationArea = All; + Editable = false; + ToolTip = 'Specifies the amount of the tax line.'; + } + field("Presentment Amount"; Rec."Presentment Amount") + { + ApplicationArea = All; + Editable = false; + ToolTip = 'Specifies the amount of the tax line in presentment currency.'; + Visible = PresentmentCurrencyVisible; + } + field("Rate %"; Rec."Rate %") + { + ApplicationArea = All; + Caption = 'Shopify Rate %'; + Editable = false; + StyleExpr = RateStyleExpr; + ToolTip = 'Specifies the rate percentage Shopify charged on this tax line.'; + } + field(BCRate; BCRatePct) + { + ApplicationArea = All; + Caption = 'BC Rate %'; + AutoFormatType = 0; + BlankZero = true; + Editable = false; + StyleExpr = RateStyleExpr; + ToolTip = 'Specifies the Business Central Tax Detail rate that applies to this line''s item for the assigned Tax Jurisdiction as of the order date. When it differs from Shopify''s rate the line is highlighted red; approving the order posts at this Business Central rate. It is blank when no Tax Jurisdiction is assigned or no Tax Detail exists yet.'; + } + field("Channel Liable"; Rec."Channel Liable") + { + ApplicationArea = All; + Editable = false; + ToolTip = 'Specifies if the channel that submitted the tax line is liable for remitting.'; + } + field("Tax Jurisdiction Code"; Rec."Tax Jurisdiction Code") + { + ApplicationArea = All; + StyleExpr = RateStyleExpr; + ToolTip = 'Specifies the Business Central Tax Jurisdiction matched to this Shopify tax line. When it is blank the line is unmatched and highlighted red — assign a Tax Jurisdiction to complete the match. You can change it to correct or complete the match; the Tax Area is rebuilt from these codes when you approve.'; + + trigger OnValidate() + begin + CurrPage.Update(true); + end; + } + } + } + } + + actions + { + area(Processing) + { + action(UseShopifyRate) + { + ApplicationArea = All; + Caption = 'Use Shopify Rate'; + Image = Apply; + Enabled = UseShopifyRateEnabled; + ToolTip = 'Creates or updates a Business Central Tax Detail for this line''s Tax Jurisdiction and tax group, effective the order''s document date, using the rate Shopify charged. This changes your shared Business Central tax setup - it is not limited to this order and affects every document that posts this Tax Jurisdiction and tax group on or after that date, overwriting any existing rate on that date.'; + + trigger OnAction() + begin + UseShopifyRateForCurrentLine(); + end; + } + } + } + + trigger OnOpenPage() + begin + // Start empty; the host page scopes the part to a single order via SetTaxLineFilter. + Rec.SetRange("Parent Id", 0); + end; + + trigger OnAfterGetRecord() + begin + ResolveLineContext(); + ResolveBcRate(); + // Offer the rate fix only when a jurisdiction is assigned and BC's rate is not already the + // same as Shopify's (i.e. a conflict, or no BC bracket yet). Nothing to do when they agree. + UseShopifyRateEnabled := (Rec."Tax Jurisdiction Code" <> '') and (RateStyleExpr <> 'Favorable'); + end; + + var + PresentmentCurrencyVisible: Boolean; + AppliesToItemNo: Code[20]; + AppliesToItemDescription: Text[100]; + BCRatePct: Decimal; + RateStyleExpr: Text; + UseShopifyRateEnabled: Boolean; + ShippingAppliesToLbl: Label 'Shipping charge: %1', Comment = '%1 = shipping method title'; + NoJurisdictionErr: Label 'Assign a Tax Jurisdiction to this line before using Shopify''s rate.'; + UseShopifyRateQst: Label 'This changes your shared Business Central tax setup for Tax Jurisdiction %1, not just this order: it sets the tax rate to Shopify''s %2 %, effective the order''s document date, and affects every document that posts this Tax Jurisdiction and tax group on or after that date. Do you want to continue?', Comment = '%1 = Tax Jurisdiction Code, %2 = Shopify rate percentage'; + UseShopifyRateDoneMsg: Label 'Business Central will now post %1 %% for Tax Jurisdiction %2 as of the order''s document date. Approve the order to rebuild the Tax Area and clear the rate conflict.', Comment = '%1 = Shopify rate percentage, %2 = Tax Jurisdiction Code'; + SeedFailedErr: Label 'The Business Central tax rate could not be updated for this tax line.'; + + /// + /// Scopes the part to the tax lines of a single order. The host passes a filter + /// expression of the order's order line ids (e.g. '1001|1002'), since tax lines link + /// to order lines through Parent Id. An empty expression leaves the part empty. + /// + internal procedure SetTaxLineFilter(ParentIdFilter: Text) + begin + if ParentIdFilter = '' then + Rec.SetRange("Parent Id", 0) + else + Rec.SetFilter("Parent Id", ParentIdFilter); + CurrPage.Update(false); + end; + + local procedure ResolveLineContext() + var + OrderHeader: Record "Shpfy Order Header"; + OrderLine: Record "Shpfy Order Line"; + ShippingCharge: Record "Shpfy Order Shipping Charges"; + begin + Clear(AppliesToItemNo); + Clear(AppliesToItemDescription); + PresentmentCurrencyVisible := false; + + // A tax line is charged either on a product order line (Parent Id = "Line Id") or on a + // shipping charge (Parent Id = "Shopify Shipping Line Id"). Show the item for the former + // and the shipping title for the latter. + OrderLine.SetRange("Line Id", Rec."Parent Id"); + if OrderLine.FindFirst() then begin + AppliesToItemNo := OrderLine."Item No."; + AppliesToItemDescription := OrderLine.Description; + if OrderHeader.Get(OrderLine."Shopify Order Id") then + PresentmentCurrencyVisible := OrderHeader.IsPresentmentCurrencyOrder(); + exit; + end; + + if ShippingCharge.Get(Rec."Parent Id") then begin + AppliesToItemDescription := CopyStr(StrSubstNo(ShippingAppliesToLbl, ShippingCharge.Title), 1, MaxStrLen(AppliesToItemDescription)); + if OrderHeader.Get(ShippingCharge."Shopify Order Id") then + PresentmentCurrencyVisible := OrderHeader.IsPresentmentCurrencyOrder(); + end; + end; + + local procedure ResolveBcRate() + var + TMAMatcher: Codeunit "Shpfy TMA Matcher"; + begin + Clear(BCRatePct); + + // An unmatched tax line (no Tax Jurisdiction assigned) is highlighted red so the reviewer is + // drawn to the line they must complete — mirroring the rate-conflict highlight. The order is + // held for review (Tax Match Incomplete) until every line has a Tax Jurisdiction. + if Rec."Tax Jurisdiction Code" = '' then begin + RateStyleExpr := 'Unfavorable'; + exit; + end; + + if not TMAMatcher.TryGetEffectiveItemRate(Rec, BCRatePct) then begin + // Jurisdiction assigned but no Tax Detail bracket yet — nothing to compare. + RateStyleExpr := 'Standard'; + exit; + end; + + if BCRatePct = Rec."Rate %" then + RateStyleExpr := 'Favorable' + else + RateStyleExpr := 'Unfavorable'; + end; + + local procedure UseShopifyRateForCurrentLine() + var + TMAMatcher: Codeunit "Shpfy TMA Matcher"; + TMARegister: Codeunit "Shpfy TMA Register"; + FeatureTelemetry: Codeunit "Feature Telemetry"; + begin + if Rec."Tax Jurisdiction Code" = '' then + Error(NoJurisdictionErr); + + if not Confirm(UseShopifyRateQst, false, Rec."Tax Jurisdiction Code", Rec."Rate %") then + exit; + + if not TMAMatcher.SeedTaxDetailFromShopifyRate(Rec) then + Error(SeedFailedErr); + + FeatureTelemetry.LogUsage('0000UNP', TMARegister.FeatureName(), 'Reviewer adopted Shopify rate into Tax Detail'); + + ResolveBcRate(); + UseShopifyRateEnabled := (Rec."Tax Jurisdiction Code" <> '') and (RateStyleExpr <> 'Favorable'); + CurrPage.Update(false); + Message(UseShopifyRateDoneMsg, Rec."Rate %", Rec."Tax Jurisdiction Code"); + end; +} diff --git a/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/Pages/ShpfyTMAReview.Page.al b/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/Pages/ShpfyTMAReview.Page.al new file mode 100644 index 00000000000..81970e966c8 --- /dev/null +++ b/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/Pages/ShpfyTMAReview.Page.al @@ -0,0 +1,412 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.Integration.Shopify; + +using Microsoft.Finance.SalesTax; + +/// +/// Page Shpfy TMA Review (ID 30471). +/// Card view that lets a human review — and adjust — what Tax Matching Agent did for a +/// single Shopify order: the resolved Tax Area (with the platform AI confidence indicator), +/// the ship-to context the Tax Matching Agent reasoned over, and each tax line with the item it taxes and +/// its matched Tax Jurisdiction Code. The Tax Jurisdiction on each line is editable, and each +/// line shows Business Central's Tax Detail rate next to Shopify's rate (a difference is +/// highlighted). The Approve action rebuilds the Tax Area from the (possibly edited) line +/// jurisdictions and releases the order from review-blocked state. +/// +page 30471 "Shpfy TMA Review" +{ + PageType = Card; + ApplicationArea = All; + UsageCategory = None; + SourceTable = "Shpfy Order Header"; + Caption = 'Tax Match Review'; + Editable = true; + InsertAllowed = false; + DeleteAllowed = false; + ModifyAllowed = true; + DataCaptionExpression = DataCaption(); + LinksAllowed = false; + + layout + { + area(content) + { + group(General) + { + Caption = 'Overview'; + + field("Shopify Order No."; Rec."Shopify Order No.") + { + Editable = false; + ToolTip = 'Specifies the Shopify order that the Tax Matching Agent matched tax for.'; + } + field("Tax Area Code"; Rec."Tax Area Code") + { + Editable = false; + ToolTip = 'Specifies the Tax Area that the Tax Matching Agent resolved for this order from the matched tax jurisdictions. The AI confidence indicator shows how confident the Tax Matching Agent was about the resolved Tax Area.'; + } + field("Tax Liable"; Rec."Tax Liable") + { + Editable = false; + ToolTip = 'Specifies whether the order is liable for tax.'; + } + field("Tax Match Reviewed"; Rec."Tax Match Reviewed") + { + Caption = 'Reviewed'; + Editable = false; + ToolTip = 'Specifies whether the tax match has been reviewed. When the shop requires review, a Sales Document is not created until this is set via the Approve action.'; + } + field(RateConflictGuidance; RateConflictGuidanceTxt) + { + ShowCaption = false; + Editable = false; + MultiLine = true; + Visible = RateConflict; + StyleExpr = RateConflictStyleTxt; + ToolTip = 'Explains that one or more matched tax rates differ from Business Central and what approving the order will do.'; + } + } + part(TaxLines; "Shpfy TMA Order Tax Lines Part") + { + ApplicationArea = All; + Caption = 'Tax Lines'; + UpdatePropagation = Both; + } + group(ShipTo) + { + Caption = 'Ship-to (geographic context)'; + + field("Ship-to Country/Region Code"; Rec."Ship-to Country/Region Code") + { + Editable = false; + ToolTip = 'Specifies the ship-to country/region the Tax Matching Agent used as geographic context when matching tax jurisdictions.'; + } + field("Ship-to County"; Rec."Ship-to County") + { + Editable = false; + ToolTip = 'Specifies the ship-to state/county the Tax Matching Agent used as geographic context when matching tax jurisdictions.'; + } + field("Ship-to City"; Rec."Ship-to City") + { + Editable = false; + ToolTip = 'Specifies the ship-to city the Tax Matching Agent used as geographic context when matching tax jurisdictions.'; + } + } + } + } + + actions + { + area(processing) + { + action(Approve) + { + ApplicationArea = All; + Caption = 'Approve'; + Image = Approve; + Visible = Rec."Tax Match Applied" and not Rec."Tax Match Reviewed" and (ReviewRequired or Rec."Tax Rate Conflict" or Rec."Tax Match Incomplete"); + ToolTip = 'Confirms the tax match for this order and approves it. The Tax Area is (re)built from the Tax Jurisdiction Codes on the tax lines below — including any you changed — and the order is released so a Sales Document can be created. Review the per-line Business Central rates against Shopify''s (differences are highlighted) before approving.'; + + trigger OnAction() + begin + ApproveReview(); + end; + } + action(UndoApproval) + { + ApplicationArea = All; + Caption = 'Undo Approval'; + Image = Undo; + // Only meaningful while the order is approved, still held-when-unapproved, and no + // Sales Document has been created yet — undoing cannot un-create an existing document. + Visible = Rec."Tax Match Applied" and Rec."Tax Match Reviewed" and (Rec."Sales Order No." = '') and (Rec."Sales Invoice No." = '') and (ReviewRequired or Rec."Tax Rate Conflict" or Rec."Tax Match Incomplete"); + ToolTip = 'Reverses the approval of this order''s tax match. The order is held for review again and no Sales Document is created until it is approved once more. Available only before the Sales Document has been created.'; + + trigger OnAction() + begin + UndoApprovalReview(); + end; + } + } + area(Promoted) + { + group(Category_Process) + { + Caption = 'Process'; + + actionref(Approve_Promoted; Approve) { } + actionref(UndoApproval_Promoted; UndoApproval) { } + } + } + } + + trigger OnOpenPage() + begin + ResolveShopSettings(); + RateConflict := Rec."Tax Rate Conflict"; + RateConflictGuidanceTxt := RateConflictGuidanceMsg; + RateConflictStyleTxt := 'Unfavorable'; + SnapshotTaxLines(); + end; + + trigger OnAfterGetCurrRecord() + begin + ResolveShopSettings(); + CurrPage.TaxLines.Page.SetTaxLineFilter(BuildTaxLineFilter()); + end; + + trigger OnQueryClosePage(CloseAction: Action): Boolean + begin + ResolveShopSettings(); + // Tax-jurisdiction edits made on this page only take effect on Approve. If the user closes + // without approving, discard those edits so the persisted tax lines stay consistent with + // the stored Tax Rate Conflict flag (the single source of truth). + if HasPendingEdits() then begin + if not Confirm(DiscardChangesQst, false) then + exit(false); + RevertTaxLineEdits(); + exit(true); + end; + // No edits: warn if the order is still held for review and not yet approved. + if (not Rec."Tax Match Reviewed") and (ReviewRequired or Rec."Tax Rate Conflict" or Rec."Tax Match Incomplete") then + exit(Confirm(CloseWithoutApproveQst, false)); + exit(true); + end; + + var + ReviewRequired: Boolean; + SnapshotJurisdictions: Dictionary of [Text, Code[10]]; + RateConflictGuidanceTxt: Text; + RateConflictStyleTxt: Text; + RateConflict: Boolean; + CloseWithoutApproveQst: Label 'The tax match for this order has not been approved. The Sales Document will not be created until it is approved. Close without approving?'; + DiscardChangesQst: Label 'You have changed one or more Tax Jurisdictions but have not approved the match. If you close now, your changes will be discarded. Close without approving?'; + UndoApprovalQst: Label 'Undo the approval for this order? It will be held for review again and no Sales Document will be created until it is approved once more.'; + RateConflictGuidanceMsg: Label 'One or more tax lines have a rate that differs from Business Central''s Tax Detail (highlighted in red). Approving accepts Business Central''s rates for this order. To use a different rate, correct the Tax Detail, or change the Tax Jurisdiction on the line, before approving.'; + UnmatchedLinesErr: Label 'One or more tax lines do not have a Tax Jurisdiction Code. Assign a Tax Jurisdiction to every tax line before approving, so the order''s tax is fully resolved.'; + NoTaxAreaErr: Label 'A Tax Area could not be resolved for the selected Tax Jurisdictions. Turn on Auto Create Tax Areas on the Shopify Shop Card, or create a Tax Area that covers these jurisdictions, then approve again. The order stays held for review until then.'; + LineKeyTok: Label '%1|%2', Locked = true, Comment = '%1 = Parent Id, %2 = Line No.'; + DataCaptionTok: Label 'Tax Match Review - Order %1', Comment = '%1 = Shopify Order No.'; + + local procedure ResolveShopSettings() + var + Shop: Record "Shpfy Shop"; + TMAEvents: Codeunit "Shpfy TMA Events"; + begin + if Shop.Get(Rec."Shop Code") then + ReviewRequired := TMAEvents.IsHeldForReviewPreference(Rec, Shop) + else + ReviewRequired := false; + end; + + /// + /// Snapshots the current Tax Jurisdiction Code of every tax line on the order, so edits made + /// on this page can be discarded if the user closes without approving. Refreshed after Approve + /// so the approved state becomes the new baseline. + /// + local procedure SnapshotTaxLines() + var + OrderTaxLine: Record "Shpfy Order Tax Line"; + FilterText: Text; + begin + Clear(SnapshotJurisdictions); + FilterText := BuildTaxLineFilter(); + if FilterText = '' then + exit; + OrderTaxLine.SetFilter("Parent Id", FilterText); + OrderTaxLine.SetLoadFields("Line No.", "Tax Jurisdiction Code"); + if OrderTaxLine.FindSet() then + repeat + SnapshotJurisdictions.Set(LineKey(OrderTaxLine), OrderTaxLine."Tax Jurisdiction Code"); + until OrderTaxLine.Next() = 0; + end; + + local procedure HasPendingEdits(): Boolean + var + OrderTaxLine: Record "Shpfy Order Tax Line"; + LineKeyText: Text; + FilterText: Text; + begin + FilterText := BuildTaxLineFilter(); + if FilterText = '' then + exit(false); + OrderTaxLine.SetFilter("Parent Id", FilterText); + OrderTaxLine.SetLoadFields("Line No.", "Tax Jurisdiction Code"); + if OrderTaxLine.FindSet() then + repeat + LineKeyText := LineKey(OrderTaxLine); + if SnapshotJurisdictions.ContainsKey(LineKeyText) then + if SnapshotJurisdictions.Get(LineKeyText) <> OrderTaxLine."Tax Jurisdiction Code" then + exit(true); + until OrderTaxLine.Next() = 0; + exit(false); + end; + + local procedure RevertTaxLineEdits() + var + OrderTaxLine: Record "Shpfy Order Tax Line"; + LineKeyText: Text; + FilterText: Text; + begin + FilterText := BuildTaxLineFilter(); + if FilterText = '' then + exit; + OrderTaxLine.SetFilter("Parent Id", FilterText); + OrderTaxLine.SetLoadFields("Line No.", "Tax Jurisdiction Code"); + if OrderTaxLine.FindSet() then + repeat + LineKeyText := LineKey(OrderTaxLine); + if SnapshotJurisdictions.ContainsKey(LineKeyText) then + if OrderTaxLine."Tax Jurisdiction Code" <> SnapshotJurisdictions.Get(LineKeyText) then begin + OrderTaxLine."Tax Jurisdiction Code" := SnapshotJurisdictions.Get(LineKeyText); + OrderTaxLine.Modify(); + end; + until OrderTaxLine.Next() = 0; + end; + + local procedure LineKey(OrderTaxLine: Record "Shpfy Order Tax Line"): Text + begin + exit(StrSubstNo(LineKeyTok, OrderTaxLine."Parent Id", OrderTaxLine."Line No.")); + end; + + /// + /// Builds a Parent Id filter covering every tax line on the order — both product-line tax + /// lines (Parent Id = order line "Line Id") and shipping-charge tax lines (Parent Id = + /// "Shopify Shipping Line Id") — so the tax-lines part, the snapshot/revert, and the + /// unmatched/edit checks all see the full set. + /// + local procedure BuildTaxLineFilter(): Text + var + OrderLine: Record "Shpfy Order Line"; + ShippingCharge: Record "Shpfy Order Shipping Charges"; + FilterBuilder: TextBuilder; + begin + OrderLine.SetRange("Shopify Order Id", Rec."Shopify Order Id"); + OrderLine.SetLoadFields("Line Id"); + if OrderLine.FindSet() then + repeat + AppendParentId(FilterBuilder, OrderLine."Line Id"); + until OrderLine.Next() = 0; + ShippingCharge.SetRange("Shopify Order Id", Rec."Shopify Order Id"); + ShippingCharge.SetLoadFields("Shopify Shipping Line Id"); + if ShippingCharge.FindSet() then + repeat + AppendParentId(FilterBuilder, ShippingCharge."Shopify Shipping Line Id"); + until ShippingCharge.Next() = 0; + exit(FilterBuilder.ToText()); + end; + + local procedure AppendParentId(var FilterBuilder: TextBuilder; ParentId: BigInteger) + begin + if FilterBuilder.Length() > 0 then + FilterBuilder.Append('|'); + FilterBuilder.Append(Format(ParentId)); + end; + + local procedure ApproveReview() + var + Shop: Record "Shpfy Shop"; + OrderHeader: Record "Shpfy Order Header"; + TMAMatcher: Codeunit "Shpfy TMA Matcher"; + TaxAreaBuilder: Codeunit "Shpfy Tax Area Builder"; + CTActivityLog: Codeunit "Shpfy TMA Activity Log"; + MatchedJurisdictions: List of [Code[10]]; + MatchLog: JsonArray; + ResolvedTaxAreaCode: Code[20]; + TaxAreaWasCreated: Boolean; + HasRateConflict: Boolean; + begin + if HasUnmatchedTaxLine() then + Error(UnmatchedLinesErr); + + OrderHeader := Rec; + + // Rebuild the Tax Area from the jurisdictions currently on the tax lines (the human may + // have changed or completed them), re-seeding brackets and re-detecting rate conflicts. + // If a Tax Area cannot be resolved for the selected jurisdictions (e.g. the reviewer + // changed them to a set with no existing Tax Area and Auto Create Tax Areas is off), do + // NOT release the order — otherwise it would be created against the pre-edit Tax Area and + // post the wrong tax. Surface an actionable error instead and keep the order held. + if not Shop.Get(OrderHeader."Shop Code") then + Error(NoTaxAreaErr); + if not TMAMatcher.ReapplyFromAssignedLines(OrderHeader, Shop, MatchedJurisdictions, MatchLog, HasRateConflict) then + Error(NoTaxAreaErr); + if not TaxAreaBuilder.FindOrCreateTaxArea(OrderHeader, Shop, MatchedJurisdictions, ResolvedTaxAreaCode, TaxAreaWasCreated) then + Error(NoTaxAreaErr); + + OrderHeader."Tax Rate Conflict" := HasRateConflict; + // Approval is blocked above unless every tax line has a jurisdiction (HasUnmatchedTaxLine), + // so once we get here the match is complete. + OrderHeader."Tax Match Incomplete" := false; + CTActivityLog.LogTaxAreaEntry(OrderHeader, ResolvedTaxAreaCode, TaxAreaWasCreated, MatchedJurisdictions); + + OrderHeader."Tax Match Reviewed" := true; + OrderHeader.Modify(); + + // A human has approved this order, so mark every Tax Jurisdiction it uses as verified. + // This clears the provisional state of any agent-created jurisdiction so later matches to + // it are no longer forced to low confidence and held for review. + MarkJurisdictionsVerified(MatchedJurisdictions); + + Rec := OrderHeader; + // Refresh the guidance flag from the rechecked state so the red message clears when the + // conflict was resolved. The approved state is now the baseline — refresh the snapshot so + // closing the page does not try to revert what was just approved. + RateConflict := OrderHeader."Tax Rate Conflict"; + SnapshotTaxLines(); + CurrPage.Update(false); + end; + + local procedure MarkJurisdictionsVerified(MatchedJurisdictions: List of [Code[10]]) + var + TaxJurisdiction: Record "Tax Jurisdiction"; + JurisdictionCode: Code[10]; + begin + // For an agent-created jurisdiction this clears its provisional state so future + // high-confidence matches to it are no longer forced to low confidence and held. + // Non-agent and already-verified jurisdictions are skipped (no-op). + foreach JurisdictionCode in MatchedJurisdictions do + if TaxJurisdiction.Get(JurisdictionCode) then + if TaxJurisdiction."Shpfy Created by Agent" and not TaxJurisdiction."Shpfy Verified" then begin + TaxJurisdiction."Shpfy Verified" := true; + TaxJurisdiction.Modify(); + end; + end; + + local procedure UndoApprovalReview() + var + OrderHeader: Record "Shpfy Order Header"; + TMANotify: Codeunit "Shpfy TMA Notify"; + begin + if not Confirm(UndoApprovalQst, false) then + exit; + + OrderHeader := Rec; + TMANotify.UndoApproval(OrderHeader); + + Rec := OrderHeader; + CurrPage.Update(false); + end; + + local procedure HasUnmatchedTaxLine(): Boolean + var + OrderTaxLine: Record "Shpfy Order Tax Line"; + FilterText: Text; + begin + FilterText := BuildTaxLineFilter(); + if FilterText = '' then + exit(false); + OrderTaxLine.SetFilter("Parent Id", FilterText); + OrderTaxLine.SetRange("Tax Jurisdiction Code", ''); + exit(not OrderTaxLine.IsEmpty()); + end; + + local procedure DataCaption(): Text + begin + exit(StrSubstNo(DataCaptionTok, Rec."Shopify Order No.")); + end; +} diff --git a/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/PermissionSets/ShpfyTMA.PermissionSet.al b/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/PermissionSets/ShpfyTMA.PermissionSet.al index 6139317c346..149b076df96 100644 --- a/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/PermissionSets/ShpfyTMA.PermissionSet.al +++ b/src/Apps/NA/ShopifyNA/app/src/Tax Matching Agent/PermissionSets/ShpfyTMA.PermissionSet.al @@ -19,6 +19,8 @@ permissionset 30470 "Shpfy TMA" tabledata "Tax Area Line" = rimd, tabledata "Tax Detail" = rimd, tabledata "Tax Jurisdiction" = rimd, + page "Shpfy TMA Review" = X, + page "Shpfy TMA Order Tax Lines Part" = X, codeunit "Shpfy TMA Register" = X, codeunit "Shpfy TMA Matcher" = X, codeunit "Shpfy Tax Area Builder" = X, @@ -26,5 +28,6 @@ permissionset 30470 "Shpfy TMA" codeunit "Shpfy Tax Match Function" = X, codeunit "Shpfy TMA Install" = X, codeunit "Shpfy TMA Upgrade" = X, - codeunit "Shpfy TMA Activity Log" = X; + codeunit "Shpfy TMA Activity Log" = X, + codeunit "Shpfy TMA Notify" = X; } diff --git a/src/Apps/NA/ShopifyNA/test/src/Tax Matching Agent/ShpfyTMAHITLTest.Codeunit.al b/src/Apps/NA/ShopifyNA/test/src/Tax Matching Agent/ShpfyTMAHITLTest.Codeunit.al new file mode 100644 index 00000000000..243aa306e82 --- /dev/null +++ b/src/Apps/NA/ShopifyNA/test/src/Tax Matching Agent/ShpfyTMAHITLTest.Codeunit.al @@ -0,0 +1,217 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.Integration.Shopify; + +using Microsoft.Finance.SalesTax; +using Microsoft.Sales.Document; +using System.TestLibraries.Utilities; + +/// +/// Codeunit Shpfy TMA HITL Test (ID 134716). +/// Plain (non-AITest) unit tests for the Human-in-the-Loop layer of the Shopify Tax Matching +/// Agent: marker propagation from Shopify order to Sales Header, notification queue +/// behavior, Activity Log helper invocations, and the Capitalize confidence mapping. +/// No LLM call — these tests drive the helpers directly. +/// +codeunit 134716 "Shpfy TMA HITL Test" +{ + Subtype = Test; + TestPermissions = Disabled; + Access = Internal; + + // HITL-1 + [Test] + procedure MarkerPropagatesToSalesHeader() + var + OrderHeader: Record "Shpfy Order Header"; + SalesHeader: Record "Sales Header"; + TMAEvents: Codeunit "Shpfy TMA Events"; + begin + Cleanup(); + CreateOrderHeader(OrderHeader, true); + CreateSalesHeader(SalesHeader, 'HITL-1'); + + TMAEvents.HandleSalesHeaderCreated(OrderHeader, SalesHeader); + + LibraryAssert.IsTrue(SalesHeader."Shpfy Tax Match Applied", 'Sales Header marker should be set when Order Header marker is true.'); + end; + + // HITL-2 + [Test] + procedure MarkerFalseDoesNotPropagate() + var + OrderHeader: Record "Shpfy Order Header"; + SalesHeader: Record "Sales Header"; + TMAEvents: Codeunit "Shpfy TMA Events"; + begin + Cleanup(); + CreateOrderHeader(OrderHeader, false); + CreateSalesHeader(SalesHeader, 'HITL-2'); + + TMAEvents.HandleSalesHeaderCreated(OrderHeader, SalesHeader); + + LibraryAssert.IsFalse(SalesHeader."Shpfy Tax Match Applied", 'Sales Header marker must remain false when Order Header marker is false.'); + end; + + // HITL-3 + [Test] + procedure MarkReviewedSetsOrderReviewed() + var + OrderHeader: Record "Shpfy Order Header"; + SalesHeader: Record "Sales Header"; + TMANotify: Codeunit "Shpfy TMA Notify"; + Notif: Notification; + begin + Cleanup(); + CreateSalesHeader(SalesHeader, 'HITL-3'); + CreateOrderHeader(OrderHeader, true); + OrderHeader."Sales Order No." := SalesHeader."No."; + OrderHeader.Modify(); + + Notif.SetData('SalesHeaderSystemId', Format(SalesHeader.SystemId)); + TMANotify.MarkReviewed(Notif); + + OrderHeader.Get(OrderHeader."Shopify Order Id"); + LibraryAssert.IsTrue(OrderHeader."Tax Match Reviewed", + 'MarkReviewed should set the originating order''s Tax Match Reviewed flag (resolved via Sales Order No.).'); + end; + + // HITL-4 + [Test] + procedure DisableForUserMarksOrderReviewed() + var + OrderHeader: Record "Shpfy Order Header"; + SalesHeader: Record "Sales Header"; + TMANotify: Codeunit "Shpfy TMA Notify"; + Notif: Notification; + begin + Cleanup(); + CreateSalesHeader(SalesHeader, 'HITL-4'); + CreateOrderHeader(OrderHeader, true); + OrderHeader."Sales Order No." := SalesHeader."No."; + OrderHeader.Modify(); + + Notif.SetData('SalesHeaderSystemId', Format(SalesHeader.SystemId)); + TMANotify.DisableForUser(Notif); + + OrderHeader.Get(OrderHeader."Shopify Order Id"); + LibraryAssert.IsTrue(OrderHeader."Tax Match Reviewed", + 'DisableForUser should also mark the order reviewed so the prompt stops firing.'); + end; + + // HITL-5 + [Test] + procedure ActivityLogHelpersRunWithoutErrors() + var + OrderHeader: Record "Shpfy Order Header"; + OrderLine: Record "Shpfy Order Line"; + OrderTaxLine: Record "Shpfy Order Tax Line"; + TaxJurisdiction: Record "Tax Jurisdiction"; + TaxArea: Record "Tax Area"; + CTActivityLog: Codeunit "Shpfy TMA Activity Log"; + MatchLog: JsonArray; + MatchEntry: JsonObject; + Jurisdictions: List of [Code[10]]; + begin + Cleanup(); + CreateOrderHeader(OrderHeader, true); + + OrderLine.Init(); + OrderLine."Shopify Order Id" := OrderHeader."Shopify Order Id"; + OrderLine."Line Id" := 9000001; + OrderLine.Insert(); + + if not TaxJurisdiction.Get('NYSTAX') then begin + TaxJurisdiction.Init(); + TaxJurisdiction.Code := 'NYSTAX'; + TaxJurisdiction.Description := 'New York State Tax'; + TaxJurisdiction.Insert(true); + end; + + OrderTaxLine.Init(); + OrderTaxLine."Parent Id" := OrderLine."Line Id"; + OrderTaxLine."Line No." := 1; + OrderTaxLine.Title := 'NEW YORK STATE TAX'; + OrderTaxLine."Rate %" := 4; + OrderTaxLine."Tax Jurisdiction Code" := 'NYSTAX'; + OrderTaxLine.Insert(); + + if not TaxArea.Get('SHPFY-NYTAX') then begin + TaxArea.Init(); + TaxArea.Code := 'SHPFY-NYTAX'; + TaxArea.Description := 'Shopify - NY'; + TaxArea.Insert(true); + end; + + MatchEntry.Add('parentId', OrderLine."Line Id"); + MatchEntry.Add('lineNo', 1); + MatchEntry.Add('jurisdictionCode', 'NYSTAX'); + MatchEntry.Add('confidence', 'High'); + MatchEntry.Add('reason', 'Title matches jurisdiction description.'); + MatchLog.Add(MatchEntry); + + Jurisdictions.Add('NYSTAX'); + + CTActivityLog.LogPerLineEntries(OrderHeader, MatchLog); + CTActivityLog.LogTaxAreaEntry(OrderHeader, 'SHPFY-NYTAX', false, Jurisdictions); + CTActivityLog.LogTaxAreaEntry(OrderHeader, 'SHPFY-NYTAX', true, Jurisdictions); + end; + + // HITL-6 + [Test] + procedure CapitalizeMapsConfidenceCorrectly() + var + TMAMatcher: Codeunit "Shpfy TMA Matcher"; + begin + LibraryAssert.AreEqual('Low', TMAMatcher.Capitalize('low'), 'low'); + LibraryAssert.AreEqual('Low', TMAMatcher.Capitalize('LOW'), 'LOW'); + LibraryAssert.AreEqual('Medium', TMAMatcher.Capitalize('medium'), 'medium'); + LibraryAssert.AreEqual('Medium', TMAMatcher.Capitalize('Medium'), 'Medium'); + LibraryAssert.AreEqual('High', TMAMatcher.Capitalize('HIGH'), 'HIGH'); + LibraryAssert.AreEqual('Low', TMAMatcher.Capitalize(''), 'empty -> Low (safe fallback)'); + LibraryAssert.AreEqual('Low', TMAMatcher.Capitalize('uncertain'), 'unknown -> Low (safe fallback)'); + end; + + local procedure CreateOrderHeader(var OrderHeader: Record "Shpfy Order Header"; MarkerApplied: Boolean) + begin + NextOrderId += 1; + OrderHeader.Init(); + OrderHeader."Shopify Order Id" := 950000000 + NextOrderId; + OrderHeader."Tax Match Applied" := MarkerApplied; + OrderHeader.Insert(); + end; + + local procedure CreateSalesHeader(var SalesHeader: Record "Sales Header"; SuffixTok: Text) + begin + NextSalesHeaderSequence += 1; + SalesHeader.Init(); + SalesHeader."Document Type" := SalesHeader."Document Type"::Order; + SalesHeader."No." := CopyStr('CTHITL-' + SuffixTok + '-' + Format(NextSalesHeaderSequence), 1, MaxStrLen(SalesHeader."No.")); + SalesHeader.Insert(); + end; + + local procedure Cleanup() + var + OrderHeader: Record "Shpfy Order Header"; + OrderLine: Record "Shpfy Order Line"; + OrderTaxLine: Record "Shpfy Order Tax Line"; + SalesHeader: Record "Sales Header"; + StartOrderId: BigInteger; + begin + OrderTaxLine.DeleteAll(); + OrderLine.DeleteAll(); + StartOrderId := 950000000; + OrderHeader.SetFilter("Shopify Order Id", '>=%1', StartOrderId); + OrderHeader.DeleteAll(); + SalesHeader.SetFilter("No.", 'CTHITL-*'); + SalesHeader.DeleteAll(); + end; + + var + LibraryAssert: Codeunit "Library Assert"; + NextOrderId: BigInteger; + NextSalesHeaderSequence: Integer; +} diff --git a/src/Apps/NA/ShopifyNA/test/src/Tax Matching Agent/ShpfyTMARateConflictTest.Codeunit.al b/src/Apps/NA/ShopifyNA/test/src/Tax Matching Agent/ShpfyTMARateConflictTest.Codeunit.al new file mode 100644 index 00000000000..d0bba9f6d51 --- /dev/null +++ b/src/Apps/NA/ShopifyNA/test/src/Tax Matching Agent/ShpfyTMARateConflictTest.Codeunit.al @@ -0,0 +1,950 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.Integration.Shopify; + +using Microsoft.Finance.GeneralLedger.Account; +using Microsoft.Finance.SalesTax; +using Microsoft.Inventory.Item; +using System.TestLibraries.Utilities; + +/// +/// Codeunit Shpfy TMA Rate Conflict Test (ID 134720). +/// Plain (non-AITest) unit tests for the rate-conflict lifecycle of the Shopify Tax Matching +/// Agent: the on-approve recheck/flip of the Tax Rate Conflict flag +/// (ReapplyFromAssignedLines), the Sales Document creation gate decision +/// (IsSalesDocumentCreationHeld), and Undo Approval. No LLM call — these tests drive the +/// helpers directly against records built in the test database. +/// +codeunit 134720 "Shpfy TMA Rate Conflict Test" +{ + Subtype = Test; + TestPermissions = Disabled; + Access = Internal; + + var + LibraryAssert: Codeunit "Library Assert"; + NextOrderId: BigInteger; + + // RD1 / RD6 — a matched jurisdiction whose BC Tax Detail rate differs from Shopify's is + // detected as a rate conflict; the jurisdiction stays assigned and the existing detail is + // left untouched. + [Test] + procedure ReapplyDetectsRateConflict() + var + OrderHeader: Record "Shpfy Order Header"; + OrderTaxLine: Record "Shpfy Order Tax Line"; + Shop: Record "Shpfy Shop"; + TMAMatcher: Codeunit "Shpfy TMA Matcher"; + MatchedJurisdictions: List of [Code[10]]; + MatchLog: JsonArray; + HasRateConflict: Boolean; + begin + Cleanup(); + Shop := CreateShop(); + // BC has NYSTAX x TAXABLE at 10%, but Shopify charged 20% on the line. + CreateConflictScenario(OrderHeader, Shop, 20, 10); + + TMAMatcher.ReapplyFromAssignedLines(OrderHeader, Shop, MatchedJurisdictions, MatchLog, HasRateConflict); + + LibraryAssert.IsTrue(HasRateConflict, 'A differing BC Tax Detail rate should be flagged as a rate conflict.'); + + OrderTaxLine.Get(GetLineId(OrderHeader), 1); + LibraryAssert.AreEqual('NYSTAX', OrderTaxLine."Tax Jurisdiction Code", 'The jurisdiction must stay assigned on a rate conflict.'); + + LibraryAssert.AreEqual(10, GetEffectiveBcRate('NYSTAX', 'TAXABLE'), 'The existing Tax Detail rate must be left untouched.'); + end; + + // RD5 — after the user corrects the Tax Detail rate to match Shopify, re-applying clears the + // rate conflict (this is what Approve does before releasing the order). + [Test] + procedure ReapplyClearsConflictWhenRateFixed() + var + OrderHeader: Record "Shpfy Order Header"; + TaxDetail: Record "Tax Detail"; + Shop: Record "Shpfy Shop"; + TMAMatcher: Codeunit "Shpfy TMA Matcher"; + MatchedJurisdictions: List of [Code[10]]; + MatchLog: JsonArray; + HasRateConflict: Boolean; + begin + Cleanup(); + Shop := CreateShop(); + CreateConflictScenario(OrderHeader, Shop, 20, 10); + + // User fixes the conflicting Tax Detail rate to match Shopify. + TaxDetail.SetRange("Tax Jurisdiction Code", 'NYSTAX'); + TaxDetail.SetRange("Tax Group Code", 'TAXABLE'); + TaxDetail.FindFirst(); + TaxDetail."Tax Below Maximum" := 20; + TaxDetail.Modify(); + + TMAMatcher.ReapplyFromAssignedLines(OrderHeader, Shop, MatchedJurisdictions, MatchLog, HasRateConflict); + + LibraryAssert.IsFalse(HasRateConflict, 'Correcting the Tax Detail rate should clear the rate conflict on re-apply.'); + end; + + // TD1 — no existing bracket: re-apply seeds one at Shopify's rate and reports no conflict. + [Test] + procedure ReapplyNoConflictWhenNoBracket() + var + OrderHeader: Record "Shpfy Order Header"; + TaxDetail: Record "Tax Detail"; + Shop: Record "Shpfy Shop"; + TMAMatcher: Codeunit "Shpfy TMA Matcher"; + MatchedJurisdictions: List of [Code[10]]; + MatchLog: JsonArray; + HasRateConflict: Boolean; + begin + Cleanup(); + Shop := CreateShop(); + // No existing Tax Detail (pass 0 to skip creating one). + CreateConflictScenario(OrderHeader, Shop, 20, 0); + + TMAMatcher.ReapplyFromAssignedLines(OrderHeader, Shop, MatchedJurisdictions, MatchLog, HasRateConflict); + + LibraryAssert.IsFalse(HasRateConflict, 'No existing bracket means no conflict.'); + TaxDetail.SetRange("Tax Jurisdiction Code", 'NYSTAX'); + TaxDetail.SetRange("Tax Group Code", 'TAXABLE'); + LibraryAssert.IsFalse(TaxDetail.IsEmpty(), 'A Tax Detail should have been seeded at Shopify''s rate.'); + end; + + // JC2 regression — every matched jurisdiction with a blank Report-to (auto-created or + // pre-existing) gets Report-to = the first (state) jurisdiction, including the state itself. + [Test] + procedure ReapplySetsReportToOnBlankJurisdictions() + var + OrderHeader: Record "Shpfy Order Header"; + TaxJurisdiction: Record "Tax Jurisdiction"; + Shop: Record "Shpfy Shop"; + TMAMatcher: Codeunit "Shpfy TMA Matcher"; + MatchedJurisdictions: List of [Code[10]]; + MatchLog: JsonArray; + HasRateConflict: Boolean; + begin + Cleanup(); + Shop := CreateShop(); + // Two product lines with pre-existing jurisdictions that both have a BLANK Report-to + // (mirrors a DB where jurisdictions already exist from earlier runs). + CreateTwoLineJurisdictionScenario(OrderHeader, Shop); + + TMAMatcher.ReapplyFromAssignedLines(OrderHeader, Shop, MatchedJurisdictions, MatchLog, HasRateConflict); + + TaxJurisdiction.Get('NYSTAX'); + LibraryAssert.AreEqual('NYSTAX', TaxJurisdiction."Report-to Jurisdiction", 'The state jurisdiction must report to itself, not stay blank.'); + TaxJurisdiction.Get('NYCTAX'); + LibraryAssert.AreEqual('NYSTAX', TaxJurisdiction."Report-to Jurisdiction", 'The city jurisdiction must report to the state.'); + end; + + // RD10 — Use Shopify Rate: seeds a Tax Detail at the order's document date with Shopify's rate + // so BC posts what the customer paid; the earlier (conflicting) admin bracket is left untouched. + [Test] + procedure UseShopifyRateSeedsBracketAtDocumentDate() + var + OrderHeader: Record "Shpfy Order Header"; + OrderTaxLine: Record "Shpfy Order Tax Line"; + TaxDetail: Record "Tax Detail"; + Shop: Record "Shpfy Shop"; + TMAMatcher: Codeunit "Shpfy TMA Matcher"; + BCRate: Decimal; + begin + Cleanup(); + Shop := CreateShop(); + // BC has NYSTAX x TAXABLE at 10% effective 2026-01-01; Shopify charged 20%; doc date 2026-01-15. + CreateConflictScenario(OrderHeader, Shop, 20, 10); + OrderTaxLine.Get(GetLineId(OrderHeader), 1); + + LibraryAssert.IsTrue(TMAMatcher.SeedTaxDetailFromShopifyRate(OrderTaxLine), 'Seeding Shopify''s rate should succeed for a matched line.'); + + // A new bracket exists at the document date with Shopify's rate. + LibraryAssert.IsTrue(TaxDetail.Get('NYSTAX', 'TAXABLE', TaxDetail."Tax Type"::"Sales and Use Tax", OrderHeader."Document Date"), 'A Tax Detail should exist at the order''s document date.'); + LibraryAssert.AreEqual(20, TaxDetail."Tax Below Maximum", 'The document-date bracket should carry Shopify''s rate.'); + + // The earlier admin bracket is untouched. + LibraryAssert.IsTrue(TaxDetail.Get('NYSTAX', 'TAXABLE', TaxDetail."Tax Type"::"Sales and Use Tax", 20260101D), 'The earlier bracket must remain.'); + LibraryAssert.AreEqual(10, TaxDetail."Tax Below Maximum", 'The earlier bracket rate must be left untouched.'); + + // The effective rate as of the document date is now Shopify's — conflict resolved. + LibraryAssert.IsTrue(TMAMatcher.TryGetEffectiveItemRate(OrderTaxLine, BCRate), 'An effective bracket should now exist.'); + LibraryAssert.AreEqual(20, BCRate, 'BC should now post Shopify''s rate as of the document date.'); + end; + + // RD10 — Use Shopify Rate when a bracket already exists on the document date: it is updated in + // place (no duplicate insert), adopting Shopify's rate. + [Test] + procedure UseShopifyRateUpdatesSameDateBracket() + var + OrderHeader: Record "Shpfy Order Header"; + OrderTaxLine: Record "Shpfy Order Tax Line"; + TaxDetail: Record "Tax Detail"; + Shop: Record "Shpfy Shop"; + TMAMatcher: Codeunit "Shpfy TMA Matcher"; + begin + Cleanup(); + Shop := CreateShop(); + // No earlier bracket; instead an existing bracket sits on the document date itself at 10%. + CreateConflictScenario(OrderHeader, Shop, 20, 0); + CreateTaxDetail('NYSTAX', 'TAXABLE', 10, 20260115D); + OrderTaxLine.Get(GetLineId(OrderHeader), 1); + + LibraryAssert.IsTrue(TMAMatcher.SeedTaxDetailFromShopifyRate(OrderTaxLine), 'Seeding Shopify''s rate should succeed.'); + + TaxDetail.SetRange("Tax Jurisdiction Code", 'NYSTAX'); + TaxDetail.SetRange("Tax Group Code", 'TAXABLE'); + LibraryAssert.AreEqual(1, TaxDetail.Count(), 'The same-date bracket should be updated in place, not duplicated.'); + TaxDetail.FindFirst(); + LibraryAssert.AreEqual(20, TaxDetail."Tax Below Maximum", 'The same-date bracket should now carry Shopify''s rate.'); + end; + + // An existing admin-maintained Report-to must never be overwritten. + [Test] + procedure ReapplyPreservesExistingReportTo() + var + OrderHeader: Record "Shpfy Order Header"; + TaxJurisdiction: Record "Tax Jurisdiction"; + Shop: Record "Shpfy Shop"; + TMAMatcher: Codeunit "Shpfy TMA Matcher"; + MatchedJurisdictions: List of [Code[10]]; + MatchLog: JsonArray; + HasRateConflict: Boolean; + begin + Cleanup(); + Shop := CreateShop(); + CreateTwoLineJurisdictionScenario(OrderHeader, Shop); + // Admin has NYCTAX reporting to a custom jurisdiction, not the state. + EnsureJurisdiction('CUSTOMRPT'); + TaxJurisdiction.Get('NYCTAX'); + TaxJurisdiction."Report-to Jurisdiction" := 'CUSTOMRPT'; + TaxJurisdiction.Modify(); + + TMAMatcher.ReapplyFromAssignedLines(OrderHeader, Shop, MatchedJurisdictions, MatchLog, HasRateConflict); + + TaxJurisdiction.Get('NYCTAX'); + LibraryAssert.AreEqual('CUSTOMRPT', TaxJurisdiction."Report-to Jurisdiction", 'An existing Report-to must be preserved, not overwritten.'); + end; + + // Shipping tax line is first-class: its own rate seeds the shipping-group Tax Detail. + [Test] + procedure ReapplySeedsShippingBracketFromShippingTaxLine() + var + OrderHeader: Record "Shpfy Order Header"; + TaxDetail: Record "Tax Detail"; + Shop: Record "Shpfy Shop"; + TMAMatcher: Codeunit "Shpfy TMA Matcher"; + MatchedJurisdictions: List of [Code[10]]; + MatchLog: JsonArray; + HasRateConflict: Boolean; + begin + Cleanup(); + Shop := CreateShop(); + // Shipping charge with its own tax line at 8%, no existing shipping-group bracket. + CreateShippingScenario(OrderHeader, Shop, 8, 0); + + TMAMatcher.ReapplyFromAssignedLines(OrderHeader, Shop, MatchedJurisdictions, MatchLog, HasRateConflict); + + LibraryAssert.IsFalse(HasRateConflict, 'No existing shipping bracket means no conflict.'); + TaxDetail.SetRange("Tax Jurisdiction Code", 'NYSTAX'); + TaxDetail.SetRange("Tax Group Code", 'FREIGHT'); + TaxDetail.SetRange("Tax Below Maximum", 8); + LibraryAssert.IsFalse(TaxDetail.IsEmpty(), 'A shipping-group Tax Detail should be seeded at the shipping tax line''s own rate.'); + end; + + // Shipping rate conflict now holds like product lines (BC shipping rate != Shopify's). + [Test] + procedure ReapplyDetectsShippingRateConflict() + var + OrderHeader: Record "Shpfy Order Header"; + Shop: Record "Shpfy Shop"; + TMAMatcher: Codeunit "Shpfy TMA Matcher"; + MatchedJurisdictions: List of [Code[10]]; + MatchLog: JsonArray; + HasRateConflict: Boolean; + begin + Cleanup(); + Shop := CreateShop(); + // BC has NYSTAX x FREIGHT at 5%, but the shipping tax line charged 8%. + CreateShippingScenario(OrderHeader, Shop, 8, 5); + + TMAMatcher.ReapplyFromAssignedLines(OrderHeader, Shop, MatchedJurisdictions, MatchLog, HasRateConflict); + + LibraryAssert.IsTrue(HasRateConflict, 'A shipping tax line whose rate differs from BC must flag a rate conflict.'); + LibraryAssert.AreEqual(5, GetEffectiveBcRate('NYSTAX', 'FREIGHT'), 'The existing shipping Tax Detail rate must be left untouched.'); + end; + + // RD3 — order held for creation when the shop requires review. + [Test] + procedure GateHeldWhenReviewRequired() + var + OrderHeader: Record "Shpfy Order Header"; + Shop: Record "Shpfy Shop"; + TMAEvents: Codeunit "Shpfy TMA Events"; + begin + BuildOrderAndShop(OrderHeader, Shop, true, false, true, false); + LibraryAssert.IsTrue(TMAEvents.IsSalesDocumentCreationHeld(OrderHeader, Shop), + 'A matched, not-yet-reviewed order must be held when the shop requires review.'); + end; + + // RD4 — order held for creation on a rate conflict even when review is not required. + [Test] + procedure GateHeldWhenRateConflictAndReviewNotRequired() + var + OrderHeader: Record "Shpfy Order Header"; + Shop: Record "Shpfy Shop"; + TMAEvents: Codeunit "Shpfy TMA Events"; + begin + BuildOrderAndShop(OrderHeader, Shop, true, false, false, true); + LibraryAssert.IsTrue(TMAEvents.IsSalesDocumentCreationHeld(OrderHeader, Shop), + 'A rate conflict must hold the order regardless of the review-required toggle.'); + end; + + // Non-blocking, no conflict — the order is released (created automatically). + [Test] + procedure GateNotHeldWhenNonBlockingNoConflict() + var + OrderHeader: Record "Shpfy Order Header"; + Shop: Record "Shpfy Shop"; + TMAEvents: Codeunit "Shpfy TMA Events"; + begin + BuildOrderAndShop(OrderHeader, Shop, true, false, false, false); + LibraryAssert.IsFalse(TMAEvents.IsSalesDocumentCreationHeld(OrderHeader, Shop), + 'With no review requirement and no conflict, the order must not be held.'); + end; + + // Already approved — never held. + [Test] + procedure GateNotHeldWhenReviewed() + var + OrderHeader: Record "Shpfy Order Header"; + Shop: Record "Shpfy Shop"; + TMAEvents: Codeunit "Shpfy TMA Events"; + begin + BuildOrderAndShop(OrderHeader, Shop, true, true, true, true); + LibraryAssert.IsFalse(TMAEvents.IsSalesDocumentCreationHeld(OrderHeader, Shop), + 'An approved order must never be held.'); + end; + + // Not agent-matched — never held. + [Test] + procedure GateNotHeldWhenNotApplied() + var + OrderHeader: Record "Shpfy Order Header"; + Shop: Record "Shpfy Shop"; + TMAEvents: Codeunit "Shpfy TMA Events"; + begin + BuildOrderAndShop(OrderHeader, Shop, false, false, true, true); + LibraryAssert.IsFalse(TMAEvents.IsSalesDocumentCreationHeld(OrderHeader, Shop), + 'An order the Tax Matching Agent did not match must not be held.'); + end; + + // Low Confidence Only — held when the order carries a non-high-confidence match. + [Test] + procedure GateHeldWhenLowConfidenceOnlyAndLowConfidence() + var + OrderHeader: Record "Shpfy Order Header"; + Shop: Record "Shpfy Shop"; + TMAEvents: Codeunit "Shpfy TMA Events"; + begin + BuildOrderAndShopMode(OrderHeader, Shop, Enum::"Shpfy Tax Match Review Mode"::"Low Confidence Only", true, false, false); + LibraryAssert.IsTrue(TMAEvents.IsSalesDocumentCreationHeld(OrderHeader, Shop), + 'In Low Confidence Only mode, a low-confidence match must hold the order.'); + end; + + // Low Confidence Only — a fully high-confidence order (no conflict) is released. + [Test] + procedure GateNotHeldWhenLowConfidenceOnlyAndHighConfidence() + var + OrderHeader: Record "Shpfy Order Header"; + Shop: Record "Shpfy Shop"; + TMAEvents: Codeunit "Shpfy TMA Events"; + begin + BuildOrderAndShopMode(OrderHeader, Shop, Enum::"Shpfy Tax Match Review Mode"::"Low Confidence Only", false, false, false); + LibraryAssert.IsFalse(TMAEvents.IsSalesDocumentCreationHeld(OrderHeader, Shop), + 'In Low Confidence Only mode, a fully high-confidence match with no conflict must not be held.'); + end; + + // Never — a low-confidence (e.g. provisional-jurisdiction) match is released. + [Test] + procedure GateNotHeldWhenNeverAndLowConfidence() + var + OrderHeader: Record "Shpfy Order Header"; + Shop: Record "Shpfy Shop"; + TMAEvents: Codeunit "Shpfy TMA Events"; + begin + BuildOrderAndShopMode(OrderHeader, Shop, Enum::"Shpfy Tax Match Review Mode"::Never, true, false, false); + LibraryAssert.IsFalse(TMAEvents.IsSalesDocumentCreationHeld(OrderHeader, Shop), + 'In Never mode, a low-confidence match with no hard-gate flag must not be held.'); + end; + + // Never — a rate conflict still holds (hard safety gate). + [Test] + procedure GateHeldWhenNeverButRateConflict() + var + OrderHeader: Record "Shpfy Order Header"; + Shop: Record "Shpfy Shop"; + TMAEvents: Codeunit "Shpfy TMA Events"; + begin + BuildOrderAndShopMode(OrderHeader, Shop, Enum::"Shpfy Tax Match Review Mode"::Never, false, true, false); + LibraryAssert.IsTrue(TMAEvents.IsSalesDocumentCreationHeld(OrderHeader, Shop), + 'A rate conflict must hold the order even in Never mode.'); + end; + + // Never — an incomplete match still holds (hard safety gate). + [Test] + procedure GateHeldWhenNeverButIncomplete() + var + OrderHeader: Record "Shpfy Order Header"; + Shop: Record "Shpfy Shop"; + TMAEvents: Codeunit "Shpfy TMA Events"; + begin + BuildOrderAndShopMode(OrderHeader, Shop, Enum::"Shpfy Tax Match Review Mode"::Never, false, false, true); + LibraryAssert.IsTrue(TMAEvents.IsSalesDocumentCreationHeld(OrderHeader, Shop), + 'An incomplete match must hold the order even in Never mode.'); + end; + + // Always — every matched order is held, even a high-confidence one. + [Test] + procedure GateHeldWhenAlwaysRegardlessOfConfidence() + var + OrderHeader: Record "Shpfy Order Header"; + Shop: Record "Shpfy Shop"; + TMAEvents: Codeunit "Shpfy TMA Events"; + begin + BuildOrderAndShopMode(OrderHeader, Shop, Enum::"Shpfy Tax Match Review Mode"::Always, false, false, false); + LibraryAssert.IsTrue(TMAEvents.IsSalesDocumentCreationHeld(OrderHeader, Shop), + 'In Always mode, every matched, not-yet-reviewed order must be held.'); + end; + + // IsHeldForReviewPreference reflects the mode independently of the hard gates. + [Test] + procedure ReviewPreferenceFollowsMode() + var + OrderHeader: Record "Shpfy Order Header"; + Shop: Record "Shpfy Shop"; + TMAEvents: Codeunit "Shpfy TMA Events"; + begin + BuildOrderAndShopMode(OrderHeader, Shop, Enum::"Shpfy Tax Match Review Mode"::Always, false, false, false); + LibraryAssert.IsTrue(TMAEvents.IsHeldForReviewPreference(OrderHeader, Shop), 'Always must hold for review preference.'); + + Shop."Tax Match Review Mode" := Shop."Tax Match Review Mode"::Never; + LibraryAssert.IsFalse(TMAEvents.IsHeldForReviewPreference(OrderHeader, Shop), 'Never must not hold for review preference.'); + + Shop."Tax Match Review Mode" := Shop."Tax Match Review Mode"::"Low Confidence Only"; + OrderHeader."Tax Match Low Confidence" := false; + LibraryAssert.IsFalse(TMAEvents.IsHeldForReviewPreference(OrderHeader, Shop), 'Low Confidence Only must not hold a high-confidence order.'); + OrderHeader."Tax Match Low Confidence" := true; + LibraryAssert.IsTrue(TMAEvents.IsHeldForReviewPreference(OrderHeader, Shop), 'Low Confidence Only must hold a low-confidence order.'); + end; + + // Guard — matching runs only when enabled, no Tax Area yet, and not tax exempt. + [Test] + procedure ShouldAttemptMatchWhenEligible() + var + OrderHeader: Record "Shpfy Order Header"; + Shop: Record "Shpfy Shop"; + TMAEvents: Codeunit "Shpfy TMA Events"; + begin + BuildGuardRecords(OrderHeader, Shop, true, '', false); + LibraryAssert.IsTrue(TMAEvents.ShouldAttemptMatch(OrderHeader, Shop), + 'Matching should run for an enabled shop, no existing Tax Area, not tax exempt.'); + end; + + [Test] + procedure ShouldNotAttemptMatchWhenDisabled() + var + OrderHeader: Record "Shpfy Order Header"; + Shop: Record "Shpfy Shop"; + TMAEvents: Codeunit "Shpfy TMA Events"; + begin + BuildGuardRecords(OrderHeader, Shop, false, '', false); + LibraryAssert.IsFalse(TMAEvents.ShouldAttemptMatch(OrderHeader, Shop), + 'Matching must not run when the shop has Tax Matching Agent disabled.'); + end; + + // P4 idempotency — a Tax Area already resolved (e.g. by address-based MapTaxArea or re-import). + [Test] + procedure ShouldNotAttemptMatchWhenTaxAreaAlreadySet() + var + OrderHeader: Record "Shpfy Order Header"; + Shop: Record "Shpfy Shop"; + TMAEvents: Codeunit "Shpfy TMA Events"; + begin + BuildGuardRecords(OrderHeader, Shop, true, 'EXISTING', false); + LibraryAssert.IsFalse(TMAEvents.ShouldAttemptMatch(OrderHeader, Shop), + 'Matching must not run when the order already has a Tax Area Code.'); + end; + + [Test] + procedure ShouldNotAttemptMatchWhenTaxExempt() + var + OrderHeader: Record "Shpfy Order Header"; + Shop: Record "Shpfy Shop"; + TMAEvents: Codeunit "Shpfy TMA Events"; + begin + BuildGuardRecords(OrderHeader, Shop, true, '', true); + LibraryAssert.IsFalse(TMAEvents.ShouldAttemptMatch(OrderHeader, Shop), + 'Matching must not run for a tax-exempt order.'); + end; + + // RD9 — Undo Approval clears the reviewed flag, so a held order is held again. + [Test] + procedure UndoApprovalReholdsOrder() + var + OrderHeader: Record "Shpfy Order Header"; + Shop: Record "Shpfy Shop"; + TMANotify: Codeunit "Shpfy TMA Notify"; + TMAEvents: Codeunit "Shpfy TMA Events"; + begin + Cleanup(); + Shop := CreateShop(); + Shop."Tax Match Review Mode" := Shop."Tax Match Review Mode"::Always; + Shop.Modify(); + + OrderHeader.Init(); + OrderHeader."Shopify Order Id" := NextId(); + OrderHeader."Shop Code" := Shop.Code; + OrderHeader."Tax Match Applied" := true; + OrderHeader."Tax Match Reviewed" := true; + OrderHeader.Insert(); + + TMANotify.UndoApproval(OrderHeader); + + LibraryAssert.IsFalse(OrderHeader."Tax Match Reviewed", 'Undo Approval must clear the reviewed flag.'); + LibraryAssert.IsTrue(TMAEvents.IsSalesDocumentCreationHeld(OrderHeader, Shop), + 'After Undo Approval a review-required order must be held again.'); + end; + + // Undo Approval on a not-yet-approved order is a harmless no-op. + [Test] + procedure UndoApprovalNoOpWhenNotReviewed() + var + OrderHeader: Record "Shpfy Order Header"; + Shop: Record "Shpfy Shop"; + TMANotify: Codeunit "Shpfy TMA Notify"; + begin + Cleanup(); + Shop := CreateShop(); + + OrderHeader.Init(); + OrderHeader."Shopify Order Id" := NextId(); + OrderHeader."Shop Code" := Shop.Code; + OrderHeader."Tax Match Applied" := true; + OrderHeader."Tax Match Reviewed" := false; + OrderHeader.Insert(); + + TMANotify.UndoApproval(OrderHeader); + + LibraryAssert.IsFalse(OrderHeader."Tax Match Reviewed", 'Undo Approval on a not-reviewed order stays not-reviewed.'); + end; + + // Undo Approval un-verifies an agent-created jurisdiction the order used, returning it to its + // provisional (low-confidence, held) state. + [Test] + procedure UndoApprovalUnverifiesAgentJurisdiction() + var + OrderHeader: Record "Shpfy Order Header"; + TaxJurisdiction: Record "Tax Jurisdiction"; + Shop: Record "Shpfy Shop"; + TMANotify: Codeunit "Shpfy TMA Notify"; + begin + Cleanup(); + Shop := CreateShop(); + EnsureAgentJurisdiction('AGENTJUR', true); + CreateReviewedOrderForJurisdiction(Shop, 'AGENTJUR', OrderHeader); + + TMANotify.UndoApproval(OrderHeader); + + TaxJurisdiction.Get('AGENTJUR'); + LibraryAssert.IsFalse(TaxJurisdiction."Shpfy Verified", + 'Undo Approval must un-verify an agent-created jurisdiction no other approved order uses.'); + end; + + // Undo Approval also un-verifies a jurisdiction shared with another approved order: the + // jurisdiction is intentionally re-quarantined (a later approval re-verifies it). This keeps + // undo cheap — only the order's own lines are read. + [Test] + procedure UndoApprovalUnverifiesJurisdictionSharedByAnotherOrder() + var + OrderA: Record "Shpfy Order Header"; + OrderB: Record "Shpfy Order Header"; + TaxJurisdiction: Record "Tax Jurisdiction"; + Shop: Record "Shpfy Shop"; + TMANotify: Codeunit "Shpfy TMA Notify"; + begin + Cleanup(); + Shop := CreateShop(); + EnsureAgentJurisdiction('AGENTJUR', true); + CreateReviewedOrderForJurisdiction(Shop, 'AGENTJUR', OrderA); + CreateReviewedOrderForJurisdiction(Shop, 'AGENTJUR', OrderB); + + TMANotify.UndoApproval(OrderA); + + TaxJurisdiction.Get('AGENTJUR'); + LibraryAssert.IsFalse(TaxJurisdiction."Shpfy Verified", + 'Undo Approval un-verifies the agent-created jurisdiction the undone order used (re-quarantined until re-approved).'); + end; + + // Install/upgrade backfill: a shop that pre-dates the config fields (zero-values) gets the + // documented defaults applied — Tax Area auto-creation on, blocking review on, SHPFY- prefix. + [Test] + procedure BackfillAppliesShopDefaults() + var + Shop: Record "Shpfy Shop"; + TMAUpgrade: Codeunit "Shpfy TMA Upgrade"; + begin + Cleanup(); + Shop := CreateShop(); + // Simulate a shop created before the fields existed: platform zero-values. + Shop."Auto Create Tax Areas" := false; + Shop."Tax Area Naming Pattern" := ''; + Shop."Tax Match Review Mode" := Shop."Tax Match Review Mode"::Never; + Shop.Modify(); + + TMAUpgrade.ApplyShopDefaults(Shop); + + LibraryAssert.IsTrue(Shop."Auto Create Tax Areas", 'Auto Create Tax Areas should be defaulted to true.'); + LibraryAssert.AreEqual('SHPFY-', Shop."Tax Area Naming Pattern", 'Tax Area Naming Pattern should be defaulted to SHPFY-.'); + LibraryAssert.AreEqual(Shop."Tax Match Review Mode"::Always, Shop."Tax Match Review Mode", 'Tax Match Review Mode should be defaulted to Always.'); + end; + + // The backfill must not flip the two intentionally-false defaults on. + [Test] + procedure BackfillLeavesOptInFieldsUntouched() + var + Shop: Record "Shpfy Shop"; + TMAUpgrade: Codeunit "Shpfy TMA Upgrade"; + begin + Cleanup(); + Shop := CreateShop(); + Shop."Tax Matching Agent Enabled" := false; + Shop."Auto Create Tax Jurisdictions" := false; + Shop.Modify(); + + TMAUpgrade.ApplyShopDefaults(Shop); + + LibraryAssert.IsFalse(Shop."Tax Matching Agent Enabled", 'Tax Matching Agent Enabled must stay off (opt-in).'); + LibraryAssert.IsFalse(Shop."Auto Create Tax Jurisdictions", 'Auto Create Tax Jurisdictions must stay off (opt-in).'); + end; + + local procedure BuildOrderAndShop(var OrderHeader: Record "Shpfy Order Header"; var Shop: Record "Shpfy Shop"; Applied: Boolean; Reviewed: Boolean; ReviewRequired: Boolean; RateConflict: Boolean) + begin + // In-memory records are enough — IsSalesDocumentCreationHeld only reads these fields. + Clear(Shop); + Shop.Code := 'TMATEST'; + if ReviewRequired then + Shop."Tax Match Review Mode" := Shop."Tax Match Review Mode"::Always + else + Shop."Tax Match Review Mode" := Shop."Tax Match Review Mode"::Never; + + Clear(OrderHeader); + OrderHeader."Shopify Order Id" := NextId(); + OrderHeader."Tax Match Applied" := Applied; + OrderHeader."Tax Match Reviewed" := Reviewed; + OrderHeader."Tax Rate Conflict" := RateConflict; + end; + + local procedure BuildOrderAndShopMode(var OrderHeader: Record "Shpfy Order Header"; var Shop: Record "Shpfy Shop"; ReviewMode: Enum "Shpfy Tax Match Review Mode"; LowConfidence: Boolean; RateConflict: Boolean; Incomplete: Boolean) + begin + // In-memory records are enough — IsSalesDocumentCreationHeld / IsHeldForReviewPreference + // only read these fields. The order is always agent-matched and not yet reviewed here. + Clear(Shop); + Shop.Code := 'TMATEST'; + Shop."Tax Match Review Mode" := ReviewMode; + + Clear(OrderHeader); + OrderHeader."Shopify Order Id" := NextId(); + OrderHeader."Tax Match Applied" := true; + OrderHeader."Tax Match Reviewed" := false; + OrderHeader."Tax Match Low Confidence" := LowConfidence; + OrderHeader."Tax Rate Conflict" := RateConflict; + OrderHeader."Tax Match Incomplete" := Incomplete; + end; + + local procedure BuildGuardRecords(var OrderHeader: Record "Shpfy Order Header"; var Shop: Record "Shpfy Shop"; Enabled: Boolean; ExistingTaxAreaCode: Code[20]; TaxExempt: Boolean) + begin + // In-memory records — ShouldAttemptMatch only reads these fields. + Clear(Shop); + Shop.Code := 'TMATEST'; + Shop."Tax Matching Agent Enabled" := Enabled; + + Clear(OrderHeader); + OrderHeader."Shopify Order Id" := NextId(); + OrderHeader."Tax Area Code" := ExistingTaxAreaCode; + OrderHeader."Tax Exempt" := TaxExempt; + end; + + local procedure CreateConflictScenario(var OrderHeader: Record "Shpfy Order Header"; Shop: Record "Shpfy Shop"; ShopifyRate: Decimal; ExistingBcRate: Decimal) + var + OrderLine: Record "Shpfy Order Line"; + OrderTaxLine: Record "Shpfy Order Tax Line"; + TaxJurisdiction: Record "Tax Jurisdiction"; + LineId: BigInteger; + begin + EnsureItem('ITEM001', 'TAXABLE'); + EnsureJurisdiction('NYSTAX'); + + OrderHeader.Init(); + OrderHeader."Shopify Order Id" := NextId(); + OrderHeader."Shop Code" := Shop.Code; + OrderHeader."Document Date" := 20260115D; + OrderHeader.Insert(); + + LineId := OrderHeader."Shopify Order Id" + 1; + OrderLine.Init(); + OrderLine."Shopify Order Id" := OrderHeader."Shopify Order Id"; + OrderLine."Line Id" := LineId; + OrderLine."Item No." := 'ITEM001'; + OrderLine.Insert(); + + OrderTaxLine.Init(); + OrderTaxLine."Parent Id" := LineId; + OrderTaxLine."Line No." := 1; + OrderTaxLine.Title := 'NEW YORK STATE TAX'; + OrderTaxLine."Rate %" := ShopifyRate; + OrderTaxLine."Tax Jurisdiction Code" := 'NYSTAX'; + OrderTaxLine.Insert(); + + if ExistingBcRate <> 0 then + CreateTaxDetail('NYSTAX', 'TAXABLE', ExistingBcRate, 20260101D); + + if TaxJurisdiction.Get('NYSTAX') then; + end; + + local procedure CreateTwoLineJurisdictionScenario(var OrderHeader: Record "Shpfy Order Header"; Shop: Record "Shpfy Shop") + var + OrderLine: Record "Shpfy Order Line"; + LineId: BigInteger; + begin + // Both jurisdictions pre-exist with a blank Report-to (as after earlier runs). + EnsureJurisdiction('NYSTAX'); + EnsureJurisdiction('NYCTAX'); + EnsureItem('ITEM001', 'TAXABLE'); + + OrderHeader.Init(); + OrderHeader."Shopify Order Id" := NextId(); + OrderHeader."Shop Code" := Shop.Code; + OrderHeader."Document Date" := 20260115D; + OrderHeader.Insert(); + + LineId := OrderHeader."Shopify Order Id" + 1; + OrderLine.Init(); + OrderLine."Shopify Order Id" := OrderHeader."Shopify Order Id"; + OrderLine."Line Id" := LineId; + OrderLine."Item No." := 'ITEM001'; + OrderLine.Insert(); + + InsertMatchedTaxLine(LineId, 1, 'NEW YORK STATE TAX', 4, 'NYSTAX'); + InsertMatchedTaxLine(LineId, 2, 'NEW YORK CITY TAX', 4.5, 'NYCTAX'); + end; + + local procedure InsertMatchedTaxLine(ParentId: BigInteger; LineNo: Integer; LineTitle: Text; RatePct: Decimal; JurisdictionCode: Code[10]) + var + OrderTaxLine: Record "Shpfy Order Tax Line"; + begin + OrderTaxLine.Init(); + OrderTaxLine."Parent Id" := ParentId; + OrderTaxLine."Line No." := LineNo; + OrderTaxLine.Title := CopyStr(LineTitle, 1, MaxStrLen(OrderTaxLine.Title)); + OrderTaxLine."Rate %" := RatePct; + OrderTaxLine."Tax Jurisdiction Code" := JurisdictionCode; + OrderTaxLine.Insert(); + end; + + local procedure CreateShippingScenario(var OrderHeader: Record "Shpfy Order Header"; var Shop: Record "Shpfy Shop"; ShopifyRate: Decimal; ExistingBcRate: Decimal) + var + ShippingCharge: Record "Shpfy Order Shipping Charges"; + OrderTaxLine: Record "Shpfy Order Tax Line"; + ShippingLineId: BigInteger; + begin + EnsureJurisdiction('NYSTAX'); + EnsureShippingAccount(Shop, 'SHIPACC', 'FREIGHT'); + + OrderHeader.Init(); + OrderHeader."Shopify Order Id" := NextId(); + OrderHeader."Shop Code" := Shop.Code; + OrderHeader."Document Date" := 20260115D; + OrderHeader.Insert(); + + ShippingLineId := OrderHeader."Shopify Order Id" + 5000; + ShippingCharge.Init(); + ShippingCharge."Shopify Shipping Line Id" := ShippingLineId; + ShippingCharge."Shopify Order Id" := OrderHeader."Shopify Order Id"; + ShippingCharge.Title := 'Standard Shipping'; + ShippingCharge.Insert(); + + OrderTaxLine.Init(); + OrderTaxLine."Parent Id" := ShippingLineId; + OrderTaxLine."Line No." := 1; + OrderTaxLine.Title := 'NEW YORK STATE TAX'; + OrderTaxLine."Rate %" := ShopifyRate; + OrderTaxLine."Tax Jurisdiction Code" := 'NYSTAX'; + OrderTaxLine.Insert(); + + if ExistingBcRate <> 0 then + CreateTaxDetail('NYSTAX', 'FREIGHT', ExistingBcRate, 20260101D); + end; + + local procedure EnsureShippingAccount(var Shop: Record "Shpfy Shop"; AccountNo: Code[20]; TaxGroupCode: Code[20]) + var + GLAccount: Record "G/L Account"; + TaxGroup: Record "Tax Group"; + begin + if not TaxGroup.Get(TaxGroupCode) then begin + TaxGroup.Init(); + TaxGroup.Code := TaxGroupCode; + TaxGroup.Description := TaxGroupCode; + TaxGroup.Insert(true); + end; + if not GLAccount.Get(AccountNo) then begin + GLAccount.Init(); + GLAccount."No." := AccountNo; + GLAccount.Name := AccountNo; + GLAccount."Tax Group Code" := TaxGroupCode; + GLAccount.Insert(false); + end; + Shop."Shipping Charges Account" := AccountNo; + Shop.Modify(); + end; + + local procedure CreateTaxDetail(JurisdictionCode: Code[10]; TaxGroupCode: Code[20]; Rate: Decimal; EffectiveDate: Date) + var + TaxDetail: Record "Tax Detail"; + begin + TaxDetail.Init(); + TaxDetail."Tax Jurisdiction Code" := JurisdictionCode; + TaxDetail."Tax Group Code" := TaxGroupCode; + TaxDetail."Tax Type" := TaxDetail."Tax Type"::"Sales and Use Tax"; + TaxDetail."Effective Date" := EffectiveDate; + TaxDetail."Tax Below Maximum" := Rate; + TaxDetail.Insert(true); + end; + + local procedure EnsureItem(ItemNo: Code[20]; TaxGroupCode: Code[20]) + var + Item: Record Item; + TaxGroup: Record "Tax Group"; + begin + if (TaxGroupCode <> '') and not TaxGroup.Get(TaxGroupCode) then begin + TaxGroup.Init(); + TaxGroup.Code := TaxGroupCode; + TaxGroup.Description := TaxGroupCode; + TaxGroup.Insert(true); + end; + if Item.Get(ItemNo) then + exit; + Item.Init(); + Item."No." := ItemNo; + Item.Description := ItemNo; + Item."Tax Group Code" := TaxGroupCode; + Item.Insert(true); + end; + + local procedure EnsureJurisdiction(JurisdictionCode: Code[10]) + var + TaxJurisdiction: Record "Tax Jurisdiction"; + begin + if TaxJurisdiction.Get(JurisdictionCode) then + exit; + TaxJurisdiction.Init(); + TaxJurisdiction.Code := JurisdictionCode; + TaxJurisdiction.Description := JurisdictionCode; + TaxJurisdiction.Insert(true); + end; + + local procedure EnsureAgentJurisdiction(JurisdictionCode: Code[10]; IsVerified: Boolean) + var + TaxJurisdiction: Record "Tax Jurisdiction"; + begin + EnsureJurisdiction(JurisdictionCode); + TaxJurisdiction.Get(JurisdictionCode); + TaxJurisdiction."Shpfy Created by Agent" := true; + TaxJurisdiction."Shpfy Verified" := IsVerified; + TaxJurisdiction.Modify(); + end; + + local procedure CreateReviewedOrderForJurisdiction(Shop: Record "Shpfy Shop"; JurisdictionCode: Code[10]; var OrderHeader: Record "Shpfy Order Header") + var + OrderLine: Record "Shpfy Order Line"; + LineId: BigInteger; + begin + OrderHeader.Init(); + OrderHeader."Shopify Order Id" := NextId(); + OrderHeader."Shop Code" := Shop.Code; + OrderHeader."Document Date" := 20260115D; + OrderHeader."Tax Match Applied" := true; + OrderHeader."Tax Match Reviewed" := true; + OrderHeader.Insert(); + + LineId := OrderHeader."Shopify Order Id" + 1; + OrderLine.Init(); + OrderLine."Shopify Order Id" := OrderHeader."Shopify Order Id"; + OrderLine."Line Id" := LineId; + OrderLine.Insert(); + + InsertMatchedTaxLine(LineId, 1, 'AGENT TAX', 5, JurisdictionCode); + end; + + local procedure CreateShop(): Record "Shpfy Shop" + var + Shop: Record "Shpfy Shop"; + begin + if Shop.Get('TMATEST') then + exit(Shop); + Shop.Init(); + Shop.Code := 'TMATEST'; + Shop."Shopify URL" := 'https://tma-test.myshopify.com'; + Shop."Tax Matching Agent Enabled" := true; + Shop.Insert(); + exit(Shop); + end; + + local procedure GetLineId(OrderHeader: Record "Shpfy Order Header"): BigInteger + begin + exit(OrderHeader."Shopify Order Id" + 1); + end; + + local procedure GetEffectiveBcRate(JurisdictionCode: Code[10]; TaxGroupCode: Code[20]): Decimal + var + TaxDetail: Record "Tax Detail"; + begin + TaxDetail.SetRange("Tax Jurisdiction Code", JurisdictionCode); + TaxDetail.SetRange("Tax Group Code", TaxGroupCode); + TaxDetail.FindFirst(); + exit(TaxDetail."Tax Below Maximum"); + end; + + local procedure NextId(): BigInteger + begin + NextOrderId += 1; + exit(960000000 + NextOrderId); + end; + + local procedure Cleanup() + var + OrderHeader: Record "Shpfy Order Header"; + OrderLine: Record "Shpfy Order Line"; + OrderTaxLine: Record "Shpfy Order Tax Line"; + ShippingCharge: Record "Shpfy Order Shipping Charges"; + Shop: Record "Shpfy Shop"; + TaxJurisdiction: Record "Tax Jurisdiction"; + TaxDetail: Record "Tax Detail"; + TaxGroup: Record "Tax Group"; + Item: Record Item; + GLAccount: Record "G/L Account"; + begin + OrderTaxLine.DeleteAll(); + OrderLine.DeleteAll(); + ShippingCharge.DeleteAll(); + OrderHeader.SetFilter("Shopify Order Id", '>=%1', 960000000); + OrderHeader.DeleteAll(); + Shop.SetRange(Code, 'TMATEST'); + Shop.DeleteAll(); + TaxDetail.DeleteAll(); + TaxJurisdiction.DeleteAll(); + TaxGroup.DeleteAll(); + Item.DeleteAll(); + if GLAccount.Get('SHIPACC') then + GLAccount.Delete(); + end; +} diff --git a/src/Apps/W1/Shopify/ShopifyCopilot.code-workspace b/src/Apps/W1/Shopify/ShopifyCopilot.code-workspace new file mode 100644 index 00000000000..c227c75652d --- /dev/null +++ b/src/Apps/W1/Shopify/ShopifyCopilot.code-workspace @@ -0,0 +1,14 @@ +{ + "folders": [ + { + "path": "App" + }, + { + "path": "../../NA/ShopifyNA/app" + }, + { + "path": "../../NA/ShopifyNA/test" + } + ], + "settings": {} +}