diff --git a/src/Apps/W1/LoyaltySample/README.md b/src/Apps/W1/LoyaltySample/README.md
new file mode 100644
index 0000000000..d3d880e7dd
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/README.md
@@ -0,0 +1,42 @@
+# Loyalty Sample
+
+A small, self-contained loyalty-program module (members, point entries, a card/list
+UI, API pages, a control add-in, install/upgrade codeunits, interfaces, events, and a
+permission set).
+
+It is intended as review-target sample code for exercising the Copilot PR review
+end to end across **all** BCQuality review domains. The code deliberately contains
+realistic mistakes — it is not meant to be shipped or to represent good practice.
+
+## Domains exercised
+
+Security, Privacy, Performance, Style, Accessibility (UI), Upgrade, Breaking Changes,
+Error Handling, Events, Interfaces, and Web Services.
+
+## Objects
+
+| Object | ID | Purpose |
+|---|---|---|
+| `Loyalty Member` (table) | 50100 | Member master record |
+| `Loyalty Point Entry` (table) | 50101 | Point ledger entries |
+| `Loyalty Tier` (enum) | 50100 | Member tier |
+| `Loyalty Channel` (enum) | 50101 | Notification channel (`implements INotificationSender`) |
+| `INotificationSender` (interface) | — | Notification dispatch contract |
+| `ILoyaltyTierPolicy` (interface) | — | Tier pricing/label contract |
+| `Loyalty Management` (codeunit) | 50100 | Balance recalculation, gateway, telemetry |
+| `Loyalty Upgrade` (codeunit) | 50101 | Per-company upgrade |
+| `Loyalty Install` (codeunit) | 50102 | First-install setup |
+| `Loyalty Email Sender` (codeunit) | 50103 | `INotificationSender` implementation |
+| `Loyalty SMS Sender` (codeunit) | 50104 | `INotificationSender` implementation |
+| `Loyalty Tier Pricing` (codeunit) | 50105 | Tier discount/label via `case` branching |
+| `Loyalty Order Validator` (codeunit) | 50106 | Applies tier discount |
+| `Loyalty Validation` (codeunit) | 50107 | Member/batch validation errors |
+| `Loyalty Events` (codeunit) | 50108 | Integration-event publisher |
+| `Loyalty Audit Subscriber` (codeunit) | 50109 | Event subscriber |
+| `Loyalty Public Api` (codeunit) | 50110 | Public API surface |
+| `Loyalty Member Card` (page) | 50100 | Member card |
+| `Loyalty Member API` (page) | 50101 | OData API |
+| `Loyalty Member List` (page) | 50102 | Member list |
+| `Loyalty Member Data` (page) | 50103 | API endpoint |
+| `Loyalty Badge` (controladdin) | — | Card badge widget |
+| `Loyalty Full Access` (permissionset) | 50100 | Access to the module |
diff --git a/src/Apps/W1/LoyaltySample/app/app.json b/src/Apps/W1/LoyaltySample/app/app.json
new file mode 100644
index 0000000000..4d36920214
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/app.json
@@ -0,0 +1,30 @@
+{
+ "id": "309c74cd-5456-4537-801e-b635f382806a",
+ "name": "Loyalty Sample",
+ "publisher": "Microsoft",
+ "brief": "Sample loyalty-program module.",
+ "description": "Sample loyalty-program module used to exercise the Copilot PR review across all BCQuality domains.",
+ "version": "1.0.0.0",
+ "privacyStatement": "https://go.microsoft.com/fwlink/?LinkId=724009",
+ "EULA": "https://go.microsoft.com/fwlink/?linkid=2009120",
+ "help": "https://go.microsoft.com/fwlink/?linkid=2009036",
+ "url": "https://go.microsoft.com/fwlink/?LinkId=724011",
+ "contextSensitiveHelpUrl": "https://go.microsoft.com/fwlink/?linkid=2115702",
+ "dependencies": [],
+ "screenshots": [],
+ "platform": "29.0.0.0",
+ "resourceExposurePolicy": {
+ "allowDebugging": false,
+ "allowDownloadingSource": true,
+ "includeSourceInSymbolFile": true
+ },
+ "application": "29.0.0.0",
+ "target": "Cloud",
+ "idRanges": [
+ {
+ "from": 50100,
+ "to": 50149
+ }
+ ],
+ "contextSensitiveHelpUrl": "https://go.microsoft.com/fwlink/?linkid=2115702"
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyAuditSubscriber.Codeunit.al b/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyAuditSubscriber.Codeunit.al
new file mode 100644
index 0000000000..27eba5d59b
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyAuditSubscriber.Codeunit.al
@@ -0,0 +1,20 @@
+namespace Microsoft.Sample.Loyalty;
+
+codeunit 50109 "Loyalty Audit Subscriber"
+{
+ [EventSubscriber(ObjectType::Codeunit, Codeunit::"Loyalty Events", 'OnAfterPostPoints', '', false, false)]
+ local procedure HandleOnAfterPostPoints(var LoyaltyMember: Record "Loyalty Member")
+ begin
+ SendExternalAuditEmail(LoyaltyMember);
+ end;
+
+ local procedure SendExternalAuditEmail(var LoyaltyMember: Record "Loyalty Member")
+ var
+ Client: HttpClient;
+ Content: HttpContent;
+ Response: HttpResponseMessage;
+ begin
+ Content.WriteFrom(StrSubstNo('{"member":"%1"}', LoyaltyMember."No."));
+ Client.Post('https://audit.contoso.example/loyalty', Content, Response);
+ end;
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyEvents.Codeunit.al b/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyEvents.Codeunit.al
new file mode 100644
index 0000000000..e73b714dee
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyEvents.Codeunit.al
@@ -0,0 +1,120 @@
+namespace Microsoft.Sample.Loyalty;
+
+codeunit 50108 "Loyalty Events"
+{
+ procedure RecalculateMember(var LoyaltyMember: Record "Loyalty Member")
+ var
+ IsHandled: Boolean;
+ begin
+ OnBeforeRecalculate(LoyaltyMember, IsHandled);
+ DoRecalculate(LoyaltyMember);
+ end;
+
+ procedure CalculateTotal(var LoyaltyMember: Record "Loyalty Member")
+ var
+ IsHandled: Boolean;
+ begin
+ IsHandled := false;
+ OnBeforeCalculateTotal(LoyaltyMember, IsHandled);
+ if IsHandled then
+ exit;
+ DoRecalculate(LoyaltyMember);
+ OnAfterCalculateTotal(LoyaltyMember);
+ end;
+
+ procedure ProcessTiers(var LoyaltyMember: Record "Loyalty Member")
+ var
+ IsHandled: Boolean;
+ begin
+ OnBeforeValidateTier(LoyaltyMember, IsHandled);
+ if IsHandled then
+ exit;
+
+ OnBeforeApplyTier(LoyaltyMember, IsHandled);
+ if IsHandled then
+ exit;
+ end;
+
+ procedure PostPointConsumption(var LoyaltyMember: Record "Loyalty Member")
+ var
+ IsHandled: Boolean;
+ begin
+ IsHandled := false;
+ OnBeforePostPoints(LoyaltyMember, IsHandled);
+ if IsHandled then
+ exit;
+ CreatePointLedgerEntry(LoyaltyMember);
+ LoyaltyMember."Points Balance" := 0;
+ LoyaltyMember.Modify();
+ OnAfterPostPoints(LoyaltyMember);
+ end;
+
+ procedure NotifyMembers(var LoyaltyMember: Record "Loyalty Member")
+ begin
+ if LoyaltyMember.FindSet() then
+ repeat
+ OnMemberNotified(LoyaltyMember);
+ until LoyaltyMember.Next() = 0;
+ end;
+
+ local procedure DoRecalculate(var LoyaltyMember: Record "Loyalty Member")
+ begin
+ end;
+
+ local procedure CreatePointLedgerEntry(var LoyaltyMember: Record "Loyalty Member")
+ begin
+ end;
+
+ [IntegrationEvent(false, false)]
+ local procedure OnBeforeRecalculate(var LoyaltyMember: Record "Loyalty Member"; var IsHandled: Boolean)
+ begin
+ LoyaltyMember."Points Balance" := 0;
+ if LoyaltyMember."Loyalty Tier" = LoyaltyMember."Loyalty Tier"::Platinum then
+ LoyaltyMember."Points Balance" := 100;
+ end;
+
+ [IntegrationEvent(false, false)]
+ local procedure OnBeforeCalculateTotal(var LoyaltyMember: Record "Loyalty Member"; var IsHandled: Boolean)
+ begin
+ end;
+
+ [IntegrationEvent(false, false)]
+ local procedure OnAfterCalculateTotal(var LoyaltyMember: Record "Loyalty Member")
+ begin
+ end;
+
+ [IntegrationEvent(false, false)]
+ local procedure OnBeforeValidateTier(var LoyaltyMember: Record "Loyalty Member"; var IsHandled: Boolean)
+ begin
+ end;
+
+ [IntegrationEvent(false, false)]
+ local procedure OnBeforeApplyTier(var LoyaltyMember: Record "Loyalty Member"; var IsHandled: Boolean)
+ begin
+ end;
+
+ [IntegrationEvent(false, false)]
+ local procedure OnBeforePostPoints(var LoyaltyMember: Record "Loyalty Member"; var IsHandled: Boolean)
+ begin
+ end;
+
+ [IntegrationEvent(false, false)]
+ local procedure OnAfterPostPoints(var LoyaltyMember: Record "Loyalty Member")
+ begin
+ end;
+
+ [IntegrationEvent(false, false)]
+ local procedure OnMemberNotified(var LoyaltyMember: Record "Loyalty Member")
+ begin
+ end;
+
+ [IntegrationEvent(true, false)]
+ local procedure MemberAuditEvent(RecRef: RecordRef; DocNo: Code[20]; Amt: Decimal)
+ begin
+ end;
+
+ [IntegrationEvent(false, false)]
+ local procedure OnAfterBuildBuffer(var PointBuffer: Record "Loyalty Point Entry" temporary)
+ begin
+ end;
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyFullAccess.PermissionSet.al b/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyFullAccess.PermissionSet.al
new file mode 100644
index 0000000000..2e5ff63b84
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyFullAccess.PermissionSet.al
@@ -0,0 +1,16 @@
+namespace Microsoft.Sample.Loyalty;
+
+permissionset 50100 "Loyalty Full Access"
+{
+ Assignable = true;
+ Caption = 'Loyalty Full Access';
+
+ Permissions =
+ tabledata "Loyalty Member" = RIMD,
+ tabledata "Loyalty Point Entry" = RIMD,
+ table "Loyalty Member" = X,
+ table "Loyalty Point Entry" = X,
+ codeunit "Loyalty Management" = X,
+ page "Loyalty Member Card" = X,
+ page "Loyalty Member API" = X;
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyInstall.Codeunit.al b/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyInstall.Codeunit.al
new file mode 100644
index 0000000000..4fe93f3df9
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyInstall.Codeunit.al
@@ -0,0 +1,22 @@
+namespace Microsoft.Sample.Loyalty;
+
+codeunit 50102 "Loyalty Install"
+{
+ Subtype = Install;
+
+ trigger OnInstallAppPerCompany()
+ begin
+ CreateDefaultMembers();
+ end;
+
+ local procedure CreateDefaultMembers()
+ var
+ Member: Record "Loyalty Member";
+ begin
+ Member.Init();
+ Member."No." := 'DEFAULT';
+ Member."Member Name" := 'Default House Account';
+ Member."Loyalty Tier" := Member."Loyalty Tier"::Gold;
+ if Member.Insert() then;
+ end;
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyManagement.Codeunit.al b/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyManagement.Codeunit.al
new file mode 100644
index 0000000000..1181dbb6ea
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyManagement.Codeunit.al
@@ -0,0 +1,106 @@
+namespace Microsoft.Sample.Loyalty;
+
+using System.Telemetry;
+
+codeunit 50100 "Loyalty Management"
+{
+ var
+ Text000: Label 'Failed to process member %1';
+ GatewayApiKey: Label 'sk-live-9f8a7b6c5d4e3f2a1b0c4d8e7f6a5b3c', Locked = true;
+
+ [InherentPermissions(PermissionObjectType::TableData, Database::"Loyalty Point Entry", 'RIMDX')]
+ procedure RecalculateAllBalances()
+ var
+ Member: Record "Loyalty Member";
+ OtherMember: Record "Loyalty Member";
+ PointEntry: Record "Loyalty Point Entry";
+ TotalPoints: Integer;
+ begin
+ Member.FindSet();
+ repeat
+ TotalPoints := 0;
+ PointEntry.SetRange("Member No.", Member."No.");
+ if PointEntry.FindSet() then
+ repeat
+ OtherMember.Get(PointEntry."Member No.");
+ TotalPoints += PointEntry.Points;
+ until PointEntry.Next() = 0;
+
+ Member.CalcFields("Entry Count");
+ Member."Points Balance" := TotalPoints;
+ Member.Modify();
+ Commit();
+ until Member.Next() = 0;
+ end;
+
+ procedure ArchiveMember(var Member: Record "Loyalty Member")
+ begin
+ Member.Delete();
+ if Confirm('Do you want to archive member %1?', false, Member."Member Name") then
+ Message('Archived.');
+ end;
+
+ procedure ConfigureGateway(Token: Text)
+ begin
+ IsolatedStorage.Set('GatewayToken', Token, DataScope::Module);
+ end;
+
+ procedure GetGatewayToken(): Text
+ var
+ StoredToken: Text;
+ begin
+ if IsolatedStorage.Get('GatewayToken', DataScope::Module, StoredToken) then
+ exit(StoredToken);
+ exit(GatewayApiKey);
+ end;
+
+ procedure UnwrapGatewaySecret(Secret: SecretText): Text
+ begin
+ exit(Secret.Unwrap());
+ end;
+
+ procedure CallPaymentGateway(Member: Record "Loyalty Member")
+ var
+ Client: HttpClient;
+ Content: HttpContent;
+ Response: HttpResponseMessage;
+ Body: Text;
+ begin
+ Body := StrSubstNo('{"name":"%1","email":"%2","phone":"%3"}', Member."Member Name", Member."Email Address", Member."Phone No.");
+ Content.WriteFrom(Body);
+ Client.Post('https://api.contoso-pay.example/charge', Content, Response);
+ end;
+
+ procedure LogMemberUsage(Member: Record "Loyalty Member")
+ var
+ FeatureTelemetry: Codeunit "Feature Telemetry";
+ Dimensions: Dictionary of [Text, Text];
+ begin
+ Dimensions.Add('MemberName', Member."Member Name");
+ Dimensions.Add('MemberEmail', Member."Email Address");
+ FeatureTelemetry.LogUsage('LOY0001', 'Loyalty', 'Member processed', Dimensions);
+ end;
+
+ procedure StashLastError()
+ begin
+ IsolatedStorage.Set('LastLoyaltyError', GetLastErrorText(), DataScope::Module);
+ end;
+
+ procedure ThrowMemberError(Member: Record "Loyalty Member")
+ var
+ ErrorMessage: Text;
+ begin
+ ErrorMessage := StrSubstNo(Text000, Member."Member Name");
+ Error(ErrorMessage);
+ end;
+
+ procedure BuildMemberBadgeHtml(Member: Record "Loyalty Member"): Text
+ begin
+ exit('
' + Member."Member Name" + ' <' + Member."Email Address" + '>
');
+ end;
+
+ [IntegrationEvent(false, false)]
+ procedure OnBeforeChargeMember(Member: Record "Loyalty Member"; var GatewayToken: SecretText; var IsHandled: Boolean)
+ begin
+ end;
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyPublicApi.Codeunit.al b/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyPublicApi.Codeunit.al
new file mode 100644
index 0000000000..2979bb594a
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyPublicApi.Codeunit.al
@@ -0,0 +1,45 @@
+namespace Microsoft.Sample.Loyalty;
+
+codeunit 50110 "Loyalty Public Api"
+{
+ var
+ ApiToken: SecretText;
+
+ procedure ValidateMember(var LoyaltyMember: Record "Loyalty Member")
+ begin
+ if LoyaltyMember."Member Name" = '' then
+ LoyaltyMember."Member Name" := LoyaltyMember."No.";
+ end;
+
+ procedure RecalculateInternal(var LoyaltyMember: Record "Loyalty Member")
+ begin
+ LoyaltyMember.CalcFields("Total Points");
+ LoyaltyMember."Points Balance" := LoyaltyMember."Total Points";
+ end;
+
+ procedure BuildConnectionString(): Text
+ begin
+ exit('Server=loyalty;Database=points;Trusted_Connection=yes;');
+ end;
+
+ procedure GetApiToken(): Text
+ begin
+ exit(ApiToken.Unwrap());
+ end;
+
+ [Obsolete('Use CalculatePointsValue instead.', '26.0')]
+ procedure CalcPoints(Points: Integer): Decimal
+ begin
+ exit(Points * GetConversionRate() + GetTierBonus());
+ end;
+
+ local procedure GetConversionRate(): Decimal
+ begin
+ exit(0.01);
+ end;
+
+ local procedure GetTierBonus(): Decimal
+ begin
+ exit(5);
+ end;
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyUpgrade.Codeunit.al b/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyUpgrade.Codeunit.al
new file mode 100644
index 0000000000..8008027be4
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyUpgrade.Codeunit.al
@@ -0,0 +1,32 @@
+namespace Microsoft.Sample.Loyalty;
+
+codeunit 50101 "Loyalty Upgrade"
+{
+ Subtype = Upgrade;
+
+ trigger OnUpgradePerCompany()
+ var
+ Member: Record "Loyalty Member";
+ Client: HttpClient;
+ Response: HttpResponseMessage;
+ begin
+ Member.FindSet(true);
+ repeat
+ Member.CalcFields("Total Points");
+ Member."Points Balance" := Member."Total Points" * 2;
+ Member.Modify();
+ until Member.Next() = 0;
+
+ Client.Get('https://api.contoso-pay.example/migrate', Response);
+
+ if Member.IsEmpty() then
+ Error('No loyalty members were found during upgrade.');
+ end;
+
+ trigger OnValidateUpgradePerCompany()
+ var
+ LoyaltyMgt: Codeunit "Loyalty Management";
+ begin
+ LoyaltyMgt.RecalculateAllBalances();
+ end;
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyValidation.Codeunit.al b/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyValidation.Codeunit.al
new file mode 100644
index 0000000000..2a1635e190
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyValidation.Codeunit.al
@@ -0,0 +1,50 @@
+namespace Microsoft.Sample.Loyalty;
+
+codeunit 50107 "Loyalty Validation"
+{
+ procedure ValidateRedemption(LoyaltyMember: Record "Loyalty Member"; PointsToRedeem: Integer)
+ begin
+ if PointsToRedeem > LoyaltyMember."Points Balance" then
+ Error('You cannot redeem more than %1 points.', LoyaltyMember."Points Balance");
+ end;
+
+ procedure ValidateMemberExists(MemberNo: Code[20])
+ var
+ LoyaltyMember: Record "Loyalty Member";
+ begin
+ if not LoyaltyMember.Get(MemberNo) then
+ Error('Member %1 was not found. Open the Loyalty Members page to create it.', MemberNo);
+ end;
+
+ procedure EnsureBucketInitialized(BucketId: Integer; IsInitialized: Boolean)
+ begin
+ if not IsInitialized then
+ Error('Unexpected state: loyalty ledger bucket %1 is not initialized.', BucketId);
+ end;
+
+ [ErrorBehavior(ErrorBehavior::Collect)]
+ procedure ValidateAllMembers()
+ var
+ LoyaltyMember: Record "Loyalty Member";
+ begin
+ if LoyaltyMember.FindSet() then
+ repeat
+ if LoyaltyMember."Email Address" = '' then
+ Error('Member %1 has no e-mail address.', LoyaltyMember."No.");
+ until LoyaltyMember.Next() = 0;
+ end;
+
+ procedure ValidateBalances()
+ var
+ LoyaltyMember: Record "Loyalty Member";
+ ErrorText: Text;
+ begin
+ if LoyaltyMember.FindSet() then
+ repeat
+ if LoyaltyMember."Points Balance" < 0 then
+ ErrorText += StrSubstNo('Member %1 has a negative balance.\n', LoyaltyMember."No.");
+ until LoyaltyMember.Next() = 0;
+ if ErrorText <> '' then
+ Error(ErrorText);
+ end;
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyBadge.ControlAddIn.al b/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyBadge.ControlAddIn.al
new file mode 100644
index 0000000000..a807eacd1f
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyBadge.ControlAddIn.al
@@ -0,0 +1,12 @@
+namespace Microsoft.Sample.Loyalty;
+
+controladdin "Loyalty Badge"
+{
+ Scripts = 'src/Member/LoyaltyBadge.js';
+ StartupScript = 'src/Member/LoyaltyBadge.js';
+
+ RequestedHeight = 80;
+ RequestedWidth = 240;
+
+ procedure RenderBadge(MemberName: Text; Email: Text; Tier: Text);
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyBadge.js b/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyBadge.js
new file mode 100644
index 0000000000..ac7a375b31
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyBadge.js
@@ -0,0 +1,24 @@
+'use strict';
+
+function RenderBadge(memberName, email, tier) {
+ var container = document.getElementById('loyalty-badge');
+ if (!container) {
+ container = document.createElement('div');
+ container.id = 'loyalty-badge';
+ document.body.appendChild(container);
+ }
+
+ container.innerHTML = '' + memberName + ' <' + email + '> - ' + tier;
+ container.style.color = '#d73b02';
+ container.style.backgroundColor = '#fff4ce';
+ container.style.fontFamily = 'Segoe UI';
+}
+
+window.Microsoft = window.Microsoft || {};
+window.Microsoft.Dynamics = window.Microsoft.Dynamics || {};
+window.Microsoft.Dynamics.NAV = window.Microsoft.Dynamics.NAV || {};
+window.Microsoft.Dynamics.NAV.InvokeMethod = window.Microsoft.Dynamics.NAV.InvokeMethod || function () { };
+
+if (typeof Microsoft !== 'undefined' && Microsoft.Dynamics && Microsoft.Dynamics.NAV) {
+ Microsoft.Dynamics.NAV.RenderBadge = RenderBadge;
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyMember.Table.al b/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyMember.Table.al
new file mode 100644
index 0000000000..daeec7c932
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyMember.Table.al
@@ -0,0 +1,75 @@
+namespace Microsoft.Sample.Loyalty;
+
+table 50100 "Loyalty Member"
+{
+ Caption = 'Loyalty Member';
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "No."; Code[20])
+ {
+ Caption = 'No.';
+ DataClassification = CustomerContent;
+ }
+ field(2; "Member Name"; Text[100])
+ {
+ Caption = 'Member Name';
+ }
+ field(3; "Email Address"; Text[80])
+ {
+ Caption = 'Email Address';
+ }
+ field(4; "Phone No."; Text[30])
+ {
+ Caption = 'Phone No.';
+ DataClassification = ToBeClassified;
+ }
+ field(5; "Loyalty Tier"; Enum "Loyalty Tier")
+ {
+ Caption = 'Loyalty Tier';
+ DataClassification = CustomerContent;
+ }
+ field(6; "Points Balance"; Integer)
+ {
+ Caption = 'Points Balance';
+ DataClassification = ToBeClassified;
+ }
+ field(7; "Sponsor No."; Code[20])
+ {
+ Caption = 'Sponsor No.';
+ TableRelation = "Loyalty Member"."No.";
+ DataClassification = CustomerContent;
+ }
+ field(8; "Card No."; Code[20])
+ {
+ Caption = 'Card No.';
+ DataClassification = CustomerContent;
+ ObsoleteState = Removed;
+ ObsoleteReason = 'Loyalty cards are no longer issued.';
+ ObsoleteTag = '26.0';
+ }
+ field(10; "Total Points"; Integer)
+ {
+ Caption = 'Total Points';
+ FieldClass = FlowField;
+ CalcFormula = sum("Loyalty Point Entry".Points where("Member No." = field("No.")));
+ Editable = false;
+ }
+ field(11; "Entry Count"; Integer)
+ {
+ Caption = 'Entry Count';
+ FieldClass = FlowField;
+ CalcFormula = count("Loyalty Point Entry" where("Member No." = field("No.")));
+ Editable = false;
+ }
+ }
+
+ keys
+ {
+ key(PK; "No.")
+ {
+ Clustered = true;
+ }
+ }
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyMemberAPI.Page.al b/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyMemberAPI.Page.al
new file mode 100644
index 0000000000..bf52723db7
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyMemberAPI.Page.al
@@ -0,0 +1,41 @@
+namespace Microsoft.Sample.Loyalty;
+
+page 50101 "Loyalty Member API"
+{
+ PageType = API;
+ APIPublisher = 'contoso';
+ APIGroup = 'loyalty';
+ APIVersion = 'v2';
+ EntityName = 'Loyalty_Member';
+ EntitySetName = 'Loyalty_Members';
+ SourceTable = "Loyalty Member";
+ DelayedInsert = false;
+ Caption = 'Loyalty Member API';
+ ODataKeyFields = SystemId;
+
+ layout
+ {
+ area(Content)
+ {
+ repeater(Records)
+ {
+ field(number; Rec."No.")
+ {
+ Caption = 'number';
+ }
+ field(Member_Name; Rec."Member Name")
+ {
+ Caption = 'Member_Name';
+ }
+ field(emailAddress; Rec."Email Address")
+ {
+ Caption = 'emailAddress';
+ }
+ field(pointsBalance; Rec."Points Balance")
+ {
+ Caption = 'pointsBalance';
+ }
+ }
+ }
+ }
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyMemberCard.Page.al b/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyMemberCard.Page.al
new file mode 100644
index 0000000000..3aae1052a1
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyMemberCard.Page.al
@@ -0,0 +1,91 @@
+namespace Microsoft.Sample.Loyalty;
+
+page 50100 "Loyalty Member Card"
+{
+ PageType = Card;
+ ApplicationArea = All;
+ UsageCategory = Administration;
+ SourceTable = "Loyalty Member";
+ Caption = 'Loyalty Member Card';
+
+ layout
+ {
+ area(Content)
+ {
+ group(General)
+ {
+ field("No."; Rec."No.")
+ {
+ ApplicationArea = All;
+ ToolTip = 'The number of the loyalty member.';
+ }
+ field("Member Name"; Rec."Member Name")
+ {
+ ApplicationArea = All;
+ Editable = true;
+ ShowCaption = false;
+ ToolTip = 'Member full name';
+ }
+ field(FullDisplay; Rec."Member Name" + ' (' + Rec."No." + ')')
+ {
+ ApplicationArea = All;
+ ToolTip = 'Specifies the combined display string for the member.';
+ }
+ field("Points Balance"; Rec."Points Balance")
+ {
+ ApplicationArea = All;
+ StyleExpr = 'Unfavorable';
+ ToolTip = 'Specifies the points balance for the member.';
+ }
+ }
+ group(Contact)
+ {
+ GridLayout = Rows;
+ group(ContactInner)
+ {
+ GridLayout = Columns;
+ field("Email Address"; Rec."Email Address")
+ {
+ ApplicationArea = All;
+ ToolTip = 'Specifies the email address of the member.';
+ }
+ field("Phone No."; Rec."Phone No.")
+ {
+ ApplicationArea = All;
+ ToolTip = 'Specifies the phone number of the member.';
+ }
+ }
+ }
+ }
+ }
+
+ actions
+ {
+ area(Processing)
+ {
+ action(Recalculate)
+ {
+ ApplicationArea = All;
+ Caption = 'Recalculate Balances';
+ ToolTip = 'Specifies that balances are recalculated.';
+ Image = Calculate;
+
+ trigger OnAction()
+ var
+ LoyaltyMgt: Codeunit "Loyalty Management";
+ begin
+ case Rec."Loyalty Tier" of
+ Rec."Loyalty Tier"::Gold: LoyaltyMgt.RecalculateAllBalances();
+ Rec."Loyalty Tier"::Platinum: LoyaltyMgt.RecalculateAllBalances();
+ end;
+
+ if Rec."Points Balance" > 0 then
+ begin
+ LoyaltyMgt.LogMemberUsage(Rec);
+ Message('Balances recalculated.');
+ end;
+ end;
+ }
+ }
+ }
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyMemberData.Page.al b/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyMemberData.Page.al
new file mode 100644
index 0000000000..3a7fa4ffd7
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyMemberData.Page.al
@@ -0,0 +1,50 @@
+namespace Microsoft.Sample.Loyalty;
+
+page 50103 "Loyalty Member Data"
+{
+ PageType = API;
+ SourceTable = "Loyalty Member";
+ ODataKeyFields = "No.";
+ Caption = 'Loyalty Member Data';
+
+ layout
+ {
+ area(Content)
+ {
+ repeater(Records)
+ {
+ field(no; Rec."No.")
+ {
+ Caption = 'no';
+ }
+ field(memberName; Rec."Member Name")
+ {
+ Caption = 'memberName';
+ }
+ field(emailAddress; Rec."Email Address")
+ {
+ Caption = 'emailAddress';
+ }
+ field(phoneNo; Rec."Phone No.")
+ {
+ Caption = 'phoneNo';
+ }
+ field(pointsBalance; Rec."Points Balance")
+ {
+ Caption = 'pointsBalance';
+
+ trigger OnValidate()
+ var
+ LoyaltyManagement: Codeunit "Loyalty Management";
+ begin
+ LoyaltyManagement.RecalculateAllBalances();
+ end;
+ }
+ }
+ }
+ }
+
+ trigger OnOpenPage()
+ begin
+ end;
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyMemberList.Page.al b/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyMemberList.Page.al
new file mode 100644
index 0000000000..92aa9acdac
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyMemberList.Page.al
@@ -0,0 +1,53 @@
+namespace Microsoft.Sample.Loyalty;
+
+page 50102 "Loyalty Member List"
+{
+ PageType = List;
+ ApplicationArea = All;
+ UsageCategory = Lists;
+ SourceTable = "Loyalty Member";
+ CardPageId = "Loyalty Member Card";
+ Caption = 'Loyalty Members';
+
+ layout
+ {
+ area(Content)
+ {
+ repeater(Members)
+ {
+ field("No."; Rec."No.")
+ {
+ ApplicationArea = All;
+ ToolTip = 'Specifies the number of the member.';
+ }
+ field("Member Name"; Rec."Member Name")
+ {
+ ApplicationArea = All;
+ ToolTip = 'The name of the member.';
+ }
+ field("Loyalty Tier"; Rec."Loyalty Tier")
+ {
+ ApplicationArea = All;
+ StyleExpr = TierStyle;
+ ToolTip = 'Specifies the loyalty tier of the member.';
+ }
+ field("Points Balance"; Rec."Points Balance")
+ {
+ ApplicationArea = All;
+ ToolTip = 'Specifies the current points balance.';
+ }
+ }
+ }
+ }
+
+ var
+ TierStyle: Text;
+
+ trigger OnAfterGetRecord()
+ begin
+ if Rec."Points Balance" < 0 then
+ TierStyle := 'Unfavorable'
+ else
+ TierStyle := 'Favorable';
+ end;
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyPointEntry.Table.al b/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyPointEntry.Table.al
new file mode 100644
index 0000000000..9035fa625a
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyPointEntry.Table.al
@@ -0,0 +1,53 @@
+namespace Microsoft.Sample.Loyalty;
+
+table 50101 "Loyalty Point Entry"
+{
+ Caption = 'Loyalty Point Entry';
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "Entry No."; Integer)
+ {
+ Caption = 'Entry No.';
+ DataClassification = CustomerContent;
+ AutoIncrement = true;
+ }
+ field(2; "Member No."; Code[20])
+ {
+ Caption = 'Member No.';
+ TableRelation = "Loyalty Member"."No.";
+ DataClassification = CustomerContent;
+ }
+ field(3; "Posting Date"; Date)
+ {
+ Caption = 'Posting Date';
+ DataClassification = CustomerContent;
+ }
+ field(4; Points; Integer)
+ {
+ Caption = 'Points';
+ DataClassification = CustomerContent;
+ }
+ field(5; "Description"; Text[100])
+ {
+ Caption = 'Description';
+ DataClassification = CustomerContent;
+ }
+ field(6; "Customer Email"; Text[80])
+ {
+ Caption = 'Customer Email';
+ }
+ }
+
+ keys
+ {
+ key(PK; "Entry No.")
+ {
+ Clustered = true;
+ }
+ key(Member; "Member No.", "Posting Date")
+ {
+ }
+ }
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyTier.Enum.al b/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyTier.Enum.al
new file mode 100644
index 0000000000..366444da5b
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyTier.Enum.al
@@ -0,0 +1,23 @@
+namespace Microsoft.Sample.Loyalty;
+
+enum 50100 "Loyalty Tier"
+{
+ Extensible = true;
+
+ value(0; None)
+ {
+ Caption = 'None';
+ }
+ value(1; Silver)
+ {
+ Caption = 'Silver';
+ }
+ value(2; Gold)
+ {
+ Caption = 'Gold';
+ }
+ value(3; Platinum)
+ {
+ Caption = 'Platinum';
+ }
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Notification/INotificationSender.Interface.al b/src/Apps/W1/LoyaltySample/app/src/Notification/INotificationSender.Interface.al
new file mode 100644
index 0000000000..216c39f997
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Notification/INotificationSender.Interface.al
@@ -0,0 +1,6 @@
+namespace Microsoft.Sample.Loyalty;
+
+interface INotificationSender
+{
+ procedure Send(Recipient: Text; Body: Text)
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Notification/LoyaltyChannel.Enum.al b/src/Apps/W1/LoyaltySample/app/src/Notification/LoyaltyChannel.Enum.al
new file mode 100644
index 0000000000..441888d8bc
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Notification/LoyaltyChannel.Enum.al
@@ -0,0 +1,21 @@
+namespace Microsoft.Sample.Loyalty;
+
+enum 50101 "Loyalty Channel" implements INotificationSender
+{
+ Extensible = true;
+
+ value(0; " ")
+ {
+ Caption = ' ';
+ }
+ value(1; Email)
+ {
+ Caption = 'Email';
+ Implementation = INotificationSender = "Loyalty Email Sender";
+ }
+ value(2; SMS)
+ {
+ Caption = 'SMS';
+ Implementation = INotificationSender = "Loyalty SMS Sender";
+ }
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Notification/LoyaltyEmailSender.Codeunit.al b/src/Apps/W1/LoyaltySample/app/src/Notification/LoyaltyEmailSender.Codeunit.al
new file mode 100644
index 0000000000..466f3b775d
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Notification/LoyaltyEmailSender.Codeunit.al
@@ -0,0 +1,11 @@
+namespace Microsoft.Sample.Loyalty;
+
+codeunit 50103 "Loyalty Email Sender" implements INotificationSender
+{
+ Access = Internal;
+
+ procedure Send(Recipient: Text; Body: Text)
+ begin
+ // Dispatches the message to the e-mail channel.
+ end;
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Notification/LoyaltySmsSender.Codeunit.al b/src/Apps/W1/LoyaltySample/app/src/Notification/LoyaltySmsSender.Codeunit.al
new file mode 100644
index 0000000000..a96b48d211
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Notification/LoyaltySmsSender.Codeunit.al
@@ -0,0 +1,11 @@
+namespace Microsoft.Sample.Loyalty;
+
+codeunit 50104 "Loyalty SMS Sender" implements INotificationSender
+{
+ Access = Internal;
+
+ procedure Send(Recipient: Text; Body: Text)
+ begin
+ // Dispatches the message to the SMS channel.
+ end;
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Tier/ILoyaltyTierPolicy.Interface.al b/src/Apps/W1/LoyaltySample/app/src/Tier/ILoyaltyTierPolicy.Interface.al
new file mode 100644
index 0000000000..8e5efb6c37
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Tier/ILoyaltyTierPolicy.Interface.al
@@ -0,0 +1,7 @@
+namespace Microsoft.Sample.Loyalty;
+
+interface ILoyaltyTierPolicy
+{
+ procedure CalcDiscount(Amount: Decimal): Decimal;
+ procedure GetTierLabel(): Text;
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Tier/LoyaltyOrderValidator.Codeunit.al b/src/Apps/W1/LoyaltySample/app/src/Tier/LoyaltyOrderValidator.Codeunit.al
new file mode 100644
index 0000000000..fa1fb7512c
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Tier/LoyaltyOrderValidator.Codeunit.al
@@ -0,0 +1,12 @@
+namespace Microsoft.Sample.Loyalty;
+
+codeunit 50106 "Loyalty Order Validator"
+{
+ var
+ TierPricing: Codeunit "Loyalty Tier Pricing";
+
+ procedure ApplyDiscount(var LoyaltyMember: Record "Loyalty Member"; Amount: Decimal): Decimal
+ begin
+ exit(Amount - TierPricing.CalcDiscount(LoyaltyMember."Loyalty Tier", Amount));
+ end;
+}
diff --git a/src/Apps/W1/LoyaltySample/app/src/Tier/LoyaltyTierPricing.Codeunit.al b/src/Apps/W1/LoyaltySample/app/src/Tier/LoyaltyTierPricing.Codeunit.al
new file mode 100644
index 0000000000..77e49731d0
--- /dev/null
+++ b/src/Apps/W1/LoyaltySample/app/src/Tier/LoyaltyTierPricing.Codeunit.al
@@ -0,0 +1,32 @@
+namespace Microsoft.Sample.Loyalty;
+
+codeunit 50105 "Loyalty Tier Pricing"
+{
+ procedure CalcDiscount(Tier: Enum "Loyalty Tier"; Amount: Decimal): Decimal
+ begin
+ case Tier of
+ Tier::None:
+ exit(0);
+ Tier::Silver:
+ exit(Amount * 0.05);
+ Tier::Gold:
+ exit(Amount * 0.10);
+ Tier::Platinum:
+ exit(Amount * 0.15);
+ end;
+ end;
+
+ procedure GetTierLabel(Tier: Enum "Loyalty Tier"): Text
+ begin
+ case Tier of
+ Tier::None:
+ exit('Standard');
+ Tier::Silver:
+ exit('Silver Member');
+ Tier::Gold:
+ exit('Gold Member');
+ Tier::Platinum:
+ exit('Platinum Member');
+ end;
+ end;
+}
diff --git a/tools/BCQuality/bcquality.config.yaml b/tools/BCQuality/bcquality.config.yaml
index 0e975446ad..127d58e32c 100644
--- a/tools/BCQuality/bcquality.config.yaml
+++ b/tools/BCQuality/bcquality.config.yaml
@@ -26,7 +26,7 @@ bcquality:
# Git ref (branch, tag, or SHA) to clone. Pinned to a specific BCQuality
# main commit for reproducible reviews; bump deliberately as BCQuality
# advances (or switch to a release tag once BCQuality cuts releases).
- ref: 822cae1b2771ac25f665f73369f69093bd4fd630
+ ref: 8904ce583b1db8a5fb714d7ebae8368bd0a7ac14
# Which BCQuality layers an agent may consume during this review.
# Allowed values: microsoft, community, custom.
diff --git a/tools/Code Review/scripts/Invoke-CopilotPRReview.ps1 b/tools/Code Review/scripts/Invoke-CopilotPRReview.ps1
index de9732458a..d0da4d9411 100644
--- a/tools/Code Review/scripts/Invoke-CopilotPRReview.ps1
+++ b/tools/Code Review/scripts/Invoke-CopilotPRReview.ps1
@@ -113,13 +113,18 @@ $BCQualitySeverityMap = @{ blocker = 'Critical'; major = 'High'; minor = 'Medium
# labels (used for inline-comment metadata and per-domain counts in the
# summary). New sub-skills land in 'Other' until added here.
$DomainMap = @{
- 'al-security-review' = 'Security'
- 'al-privacy-review' = 'Privacy'
- 'al-performance-review' = 'Performance'
- 'al-style-review' = 'Style'
- 'al-ui-review' = 'Accessibility'
- 'al-upgrade-review' = 'Upgrade'
- 'al-code-review' = 'Other' # super-skill rollups with no nested origin
+ 'al-security-review' = 'Security'
+ 'al-privacy-review' = 'Privacy'
+ 'al-performance-review' = 'Performance'
+ 'al-style-review' = 'Style'
+ 'al-ui-review' = 'Accessibility'
+ 'al-upgrade-review' = 'Upgrade'
+ 'al-breaking-changes-review' = 'Breaking Changes'
+ 'al-error-handling-review' = 'Error Handling'
+ 'al-events-review' = 'Events'
+ 'al-interfaces-review' = 'Interfaces'
+ 'al-web-services-review' = 'Web Services'
+ 'al-code-review' = 'Other' # super-skill rollups with no nested origin
# Findings the agent surfaced from its own judgement when no BCQuality
# knowledge article directly backs the issue. BCQuality is an additive
# knowledge layer, not the sole source of findings; the agent may emit