-
Notifications
You must be signed in to change notification settings - Fork 390
[TEST – DO NOT MERGE] Loyalty Sample across all 11 BCQuality domains #8822
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OnBeforeRecalculate in LoyaltyEvents.Codeunit.al (lines 74-78) is declared [IntegrationEvent] but its body assigns LoyaltyMember."Points Balance" := 0 and then conditionally sets it to 100 for Platinum tier.In AL, the IntegrationEvent publisher body executes before subscribers are invoked; any subscriber that sets IsHandled := true cannot prevent these assignments from running. The publisher body of an IntegrationEvent should be empty (begin end;); move the defaulting logic to the caller of OnBeforeRecalculate so it is skipped when IsHandled is set. This concern crosses the boundary between the style and security leaf domains (event-body semantics + IsHandled bypass). Suggested fix (apply manually — could not be anchored as a one-click suggestion): [IntegrationEvent(false, false)]
local procedure OnBeforeRecalculate(var LoyaltyMember: Record "Loyalty Member"; var IsHandled: Boolean)
begin
end;👍 useful · ❤️ especially valuable · 👎 wrong - reply with why |
||
|
|
||
| [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; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,106 @@ | ||||||||||
| namespace Microsoft.Sample.Loyalty; | ||||||||||
|
|
||||||||||
| using System.Telemetry; | ||||||||||
|
|
||||||||||
| codeunit 50100 "Loyalty Management" | ||||||||||
| { | ||||||||||
| var | ||||||||||
| Text000: Label 'Failed to process member %1'; | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Text000 at line 8 is a Label with no approved suffix.Per CodeCop AA0074, every Label and TextConst must end with an approved suffix indicating its consuming call. Text000 is passed to Error(), so the suffix should be Err. Rename to a descriptive identifier such as MemberProcessingFailedErr.
Suggested change
Knowledge: 👍 useful · ❤️ especially valuable · 👎 wrong - reply with why |
||||||||||
| GatewayApiKey: Label 'sk-live-9f8a7b6c5d4e3f2a1b0c4d8e7f6a5b3c', Locked = true; | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. GatewayApiKey at line 9 is declared as a Label containing a live API key literal ('sk-live-9f8a7b6c5d4e3f2a1b0c4d8e7f6a5b3c').Labels are plain Text constants visible in source code and compiled symbol files. Any credential stored this way is permanently exposed to anyone who can read source or decompile the extension. Remove the Label; retrieve the key at runtime from IsolatedStorage using SetEncrypted/Get with a SecretText destination. Knowledge: 👍 useful · ❤️ especially valuable · 👎 wrong - reply with why |
||||||||||
|
|
||||||||||
| [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."); | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OtherMember.Get(PointEntry."Member No.") is called at line 25 inside the inner repeat loop.This is a classic N+1 pattern: one database round-trip per point-entry row against the Loyalty Member table. Additionally, OtherMember is never read after the Get, making this dead code. Remove the call; if the outer Member record is genuinely needed inside the inner loop, it is already in scope as Member. Knowledge: 👍 useful · ❤️ especially valuable · 👎 wrong - reply with why |
||||||||||
| TotalPoints += PointEntry.Points; | ||||||||||
| until PointEntry.Next() = 0; | ||||||||||
|
|
||||||||||
| Member.CalcFields("Entry Count"); | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Member.CalcFields("Entry Count") is called at line 29 inside the repeat...until loop in RecalculateAllBalances.Each call issues a separate SQL COUNT query per member row. Additionally, the resulting Entry Count value is never read anywhere in the loop body, making this call entirely dead. Remove the line. Knowledge: 👍 useful · ❤️ especially valuable · 👎 wrong - reply with why |
||||||||||
| Member."Points Balance" := TotalPoints; | ||||||||||
| Member.Modify(); | ||||||||||
| Commit(); | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Commit() is called at line 32 inside the repeat...until Member.Next() = 0 loop in RecalculateAllBalances.This produces one transaction per member row, prevents atomic rollback of the whole operation, and adds transaction-start overhead on every iteration. Remove Commit from inside the loop; if checkpoint batching is required, process N rows per outer iteration and commit once per checkpoint. Knowledge: 👍 useful · ❤️ especially valuable · 👎 wrong - reply with why |
||||||||||
| until Member.Next() = 0; | ||||||||||
| end; | ||||||||||
|
|
||||||||||
| procedure ArchiveMember(var Member: Record "Loyalty Member") | ||||||||||
| begin | ||||||||||
| Member.Delete(); | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ArchiveMember in LoyaltyManagement.Codeunit.al calls Member.Delete() at line 38 unconditionally, then asks Confirm(...) at line 39.If the user answers No, the record has already been permanently deleted with no rollback code in sight. The intended flow is: ask for confirmation first, then delete only when the user agrees. Swap the order: 👍 useful · ❤️ especially valuable · 👎 wrong - reply with why |
||||||||||
| 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); | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ConfigureGateway at line 45 calls IsolatedStorage.Set('GatewayToken', Token, DataScope::Module).The gateway token is a credential; storing it unencrypted means it is readable in plain text from the underlying storage if storage is ever exposed. Use IsolatedStorage.SetEncrypted instead.
Suggested change
Knowledge: 👍 useful · ❤️ especially valuable · 👎 wrong - reply with why |
||||||||||
| end; | ||||||||||
|
|
||||||||||
| procedure GetGatewayToken(): Text | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. GetGatewayToken() at line 48 declares StoredToken as Text and returns Text.Retrieving a credential from IsolatedStorage into a Text variable and returning it as Text exposes the value to the debugger on the way out. Use the SecretText overload of IsolatedStorage.Get and change the return type to SecretText so the value stays protected end-to-end. Knowledge: 👍 useful · ❤️ especially valuable · 👎 wrong - reply with why |
||||||||||
| var | ||||||||||
| StoredToken: Text; | ||||||||||
| begin | ||||||||||
| if IsolatedStorage.Get('GatewayToken', DataScope::Module, StoredToken) then | ||||||||||
| exit(StoredToken); | ||||||||||
| exit(GatewayApiKey); | ||||||||||
| end; | ||||||||||
|
|
||||||||||
| procedure UnwrapGatewaySecret(Secret: SecretText): Text | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. UnwrapGatewaySecret at line 57 calls Secret.Unwrap() inside a procedure that is not marked [NonDebuggable].The unwrapped value is a plain Text local that the debugger can read, defeating the purpose of SecretText. Add [NonDebuggable] to this procedure. Suggested fix (apply manually — could not be anchored as a one-click suggestion): [NonDebuggable]
procedure UnwrapGatewaySecret(Secret: SecretText): TextKnowledge: 👍 useful · ❤️ especially valuable · 👎 wrong - reply with why |
||||||||||
| 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); | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CallPaymentGateway at line 71 posts customer PII (name, email, phone) to an external endpoint, and LoyaltyAuditSubscriber.SendExternalAuditEmail also posts to an external audit endpoint — neither code path contains a PrivacyNotice.GetPrivacyNoticeApprovalState check anywhere upstream.The BC Privacy Notice framework requires admin consent before customer data is transmitted to external services. Add a consent check (or verify one exists in the page/wizard that invokes these procedures) before any outgoing request carrying customer data. Knowledge: 👍 useful · ❤️ especially valuable · 👎 wrong - reply with why |
||||||||||
| end; | ||||||||||
|
|
||||||||||
| procedure LogMemberUsage(Member: Record "Loyalty Member") | ||||||||||
| var | ||||||||||
| FeatureTelemetry: Codeunit "Feature Telemetry"; | ||||||||||
| Dimensions: Dictionary of [Text, Text]; | ||||||||||
| begin | ||||||||||
| Dimensions.Add('MemberName', Member."Member Name"); | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. LogMemberUsage at lines 79-80 adds Member."Member Name" and Member."Email Address" to the CustomDimensions dictionary before calling FeatureTelemetry.LogUsage.The platform does not classify dictionary values; member names and email addresses are CustomerContent/EUII and will be logged verbatim to telemetry. Replace both entries with non-personal context (e.g. tier name, point-balance band, or a pseudonymous member ID). Knowledge: 👍 useful · ❤️ especially valuable · 👎 wrong - reply with why |
||||||||||
| 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"); | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ThrowMemberError at line 93 assigns ErrorMessage := StrSubstNo(Text000, Member."Member Name") and then calls Error(ErrorMessage) at line 94.Pre-building the error text with StrSubstNo before passing it to Error produces an opaque Text string; the platform cannot classify or strip the member name before it reaches telemetry. Call Error(Text000, Member."Member Name") directly so the platform performs the substitution itself.
Suggested change
Knowledge: 👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ThrowMemberError at line 93 passes a pre-rendered StrSubstNo result to Error() via an intermediate Text variable.This pattern breaks translation toolchain analysis and (as noted in the privacy review) causes PII to reach telemetry unclassified. Pass the Label and the substitution argument directly: Error(MemberProcessingFailedErr, Member."Member Name").
Suggested change
Knowledge: 👍 useful · ❤️ especially valuable · 👎 wrong - reply with why |
||||||||||
| Error(ErrorMessage); | ||||||||||
| end; | ||||||||||
|
|
||||||||||
| procedure BuildMemberBadgeHtml(Member: Record "Loyalty Member"): Text | ||||||||||
| begin | ||||||||||
| exit('<div class="badge"><b>' + Member."Member Name" + '</b> <' + Member."Email Address" + '></div>'); | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. BuildMemberBadgeHtml at line 99 concatenates Member."Member Name" and Member."Email Address" directly into an HTML string without encoding.AL has no built-in HtmlEncode; a member name containing '<script>' or 'onerror=' will be interpreted as markup by any HTML renderer. Encode the four special characters (&, <, >, ") before concatenating, or avoid building raw HTML altogether. Knowledge: 👍 useful · ❤️ especially valuable · 👎 wrong - reply with why |
||||||||||
| end; | ||||||||||
|
|
||||||||||
| [IntegrationEvent(false, false)] | ||||||||||
| procedure OnBeforeChargeMember(Member: Record "Loyalty Member"; var GatewayToken: SecretText; var IsHandled: Boolean) | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OnBeforeChargeMember at line 103 declares var GatewayToken: SecretText as an event parameter.Any extension on the tenant can subscribe to this event, read the SecretText value, and persist it elsewhere. Even SecretText should not flow through an IntegrationEvent surface. Remove the GatewayToken parameter; the publisher must acquire and use the token internally before or after the event, never through it. Knowledge: 👍 useful · ❤️ especially valuable · 👎 wrong - reply with why |
||||||||||
| begin | ||||||||||
| end; | ||||||||||
| } | ||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
RecalculateMember in LoyaltyEvents.Codeunit.al declares IsHandled: Boolean, raises OnBeforeRecalculate(LoyaltyMember, IsHandled) at line 9, then unconditionally calls DoRecalculate(LoyaltyMember) at line 10 without checking IsHandled.
Any subscriber that sets IsHandled := true to signal it has handled recalculation will be silently ignored — the default recalculation runs anyway. This breaks the BC OnBefore/IsHandled contract. Add
if IsHandled then exit;between lines 9 and 10.👍 useful · ❤️ especially valuable · 👎 wrong - reply with why