From 10266f40a273ef296ecda1a3a467f3f8113f95d5 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Thu, 25 Jun 2026 16:09:11 +0200 Subject: [PATCH] [TEST - do not merge] Loyalty Sample across all 11 BCQuality domains + pin bump Replicates the byte-identical deliberately-flawed Loyalty Sample app from microsoft/BCAppsBCQuality#52 (26 files, 1005 insertions) to exercise the Copilot PR-review agent across all 11 BCQuality domains. Also bumps the BCQuality pin in tools/BCQuality/bcquality.config.yaml from 822cae1b to 8904ce583b (latest main) and registers the 5 new review sub-skills (breaking-changes, error-handling, events, interfaces, web-services) in $DomainMap so their findings are labeled by domain instead of Other. This is review-target test code with intentional mistakes; it is NOT meant to be merged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Apps/W1/LoyaltySample/README.md | 42 ++++++ src/Apps/W1/LoyaltySample/app/app.json | 30 +++++ .../LoyaltyAuditSubscriber.Codeunit.al | 20 +++ .../app/src/Loyalty/LoyaltyEvents.Codeunit.al | 120 ++++++++++++++++++ .../LoyaltyFullAccess.PermissionSet.al | 16 +++ .../src/Loyalty/LoyaltyInstall.Codeunit.al | 22 ++++ .../src/Loyalty/LoyaltyManagement.Codeunit.al | 106 ++++++++++++++++ .../src/Loyalty/LoyaltyPublicApi.Codeunit.al | 45 +++++++ .../src/Loyalty/LoyaltyUpgrade.Codeunit.al | 32 +++++ .../src/Loyalty/LoyaltyValidation.Codeunit.al | 50 ++++++++ .../src/Member/LoyaltyBadge.ControlAddIn.al | 12 ++ .../app/src/Member/LoyaltyBadge.js | 24 ++++ .../app/src/Member/LoyaltyMember.Table.al | 75 +++++++++++ .../app/src/Member/LoyaltyMemberAPI.Page.al | 41 ++++++ .../app/src/Member/LoyaltyMemberCard.Page.al | 91 +++++++++++++ .../app/src/Member/LoyaltyMemberData.Page.al | 50 ++++++++ .../app/src/Member/LoyaltyMemberList.Page.al | 53 ++++++++ .../app/src/Member/LoyaltyPointEntry.Table.al | 53 ++++++++ .../app/src/Member/LoyaltyTier.Enum.al | 23 ++++ .../INotificationSender.Interface.al | 6 + .../src/Notification/LoyaltyChannel.Enum.al | 21 +++ .../LoyaltyEmailSender.Codeunit.al | 11 ++ .../Notification/LoyaltySmsSender.Codeunit.al | 11 ++ .../src/Tier/ILoyaltyTierPolicy.Interface.al | 7 + .../Tier/LoyaltyOrderValidator.Codeunit.al | 12 ++ .../src/Tier/LoyaltyTierPricing.Codeunit.al | 32 +++++ tools/BCQuality/bcquality.config.yaml | 2 +- .../scripts/Invoke-CopilotPRReview.ps1 | 19 ++- 28 files changed, 1018 insertions(+), 8 deletions(-) create mode 100644 src/Apps/W1/LoyaltySample/README.md create mode 100644 src/Apps/W1/LoyaltySample/app/app.json create mode 100644 src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyAuditSubscriber.Codeunit.al create mode 100644 src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyEvents.Codeunit.al create mode 100644 src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyFullAccess.PermissionSet.al create mode 100644 src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyInstall.Codeunit.al create mode 100644 src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyManagement.Codeunit.al create mode 100644 src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyPublicApi.Codeunit.al create mode 100644 src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyUpgrade.Codeunit.al create mode 100644 src/Apps/W1/LoyaltySample/app/src/Loyalty/LoyaltyValidation.Codeunit.al create mode 100644 src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyBadge.ControlAddIn.al create mode 100644 src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyBadge.js create mode 100644 src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyMember.Table.al create mode 100644 src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyMemberAPI.Page.al create mode 100644 src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyMemberCard.Page.al create mode 100644 src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyMemberData.Page.al create mode 100644 src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyMemberList.Page.al create mode 100644 src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyPointEntry.Table.al create mode 100644 src/Apps/W1/LoyaltySample/app/src/Member/LoyaltyTier.Enum.al create mode 100644 src/Apps/W1/LoyaltySample/app/src/Notification/INotificationSender.Interface.al create mode 100644 src/Apps/W1/LoyaltySample/app/src/Notification/LoyaltyChannel.Enum.al create mode 100644 src/Apps/W1/LoyaltySample/app/src/Notification/LoyaltyEmailSender.Codeunit.al create mode 100644 src/Apps/W1/LoyaltySample/app/src/Notification/LoyaltySmsSender.Codeunit.al create mode 100644 src/Apps/W1/LoyaltySample/app/src/Tier/ILoyaltyTierPolicy.Interface.al create mode 100644 src/Apps/W1/LoyaltySample/app/src/Tier/LoyaltyOrderValidator.Codeunit.al create mode 100644 src/Apps/W1/LoyaltySample/app/src/Tier/LoyaltyTierPricing.Codeunit.al 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