From 357c436dec3e150ec909cd9a7bfbecb3c95cda58 Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Sat, 11 Jul 2026 16:43:40 +0200
Subject: [PATCH 01/32] [Shopify] Migrate Shopify Connector to Expiring Offline
Access Tokens
Adds support for Shopify expiring offline access tokens (public apps must
migrate by 2027-01-01):
- Persist token/refresh expiry on "Shpfy Registered Store New" and store the
refresh token in IsolatedStorage.
- Request expiring tokens on install (expiring=1), refresh before expiry, and
migrate legacy non-expiring tokens via token exchange (best-effort).
- Orchestrate on-demand from "Shpfy Communication Mgt." (GetAccessToken) with a
reactive 401 refresh-and-retry.
- Scheduled backstop job ("Shpfy Token Refresh" + per-shop worker) to keep
tokens and 90-day refresh tokens alive; registered via installer/upgrade.
- Shop Card reconnect notification when the refresh token has expired.
- Tests for the refresh-token expiry decision logic.
NOTE: "Shpfy Token Dev Tools" (page 30440) is temporary test scaffolding and
must be removed before merge.
Fixes AB#637954
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../ShpfyCommunicationMgt.Codeunit.al | 24 ++
.../Base/Codeunits/ShpfyInstaller.Codeunit.al | 8 +
.../Codeunits/ShpfyUpgradeMgt.Codeunit.al | 20 ++
.../App/src/Base/Pages/ShpfyShopCard.Page.al | 10 +
.../ShpfyAuthenticationMgt.Codeunit.al | 325 +++++++++++++++++-
.../Codeunits/ShpfyTokenRefresh.Codeunit.al | 82 +++++
.../ShpfyTokenRefreshShop.Codeunit.al | 27 ++
.../Pages/ShpfyTokenDevTools.Page.al | 181 ++++++++++
.../Tables/ShpfyRegisteredStoreNew.Table.al | 25 ++
.../ShpfyObjects.PermissionSet.al | 3 +
.../ShpfyTokenRefreshTest.Codeunit.al | 119 +++++++
11 files changed, 807 insertions(+), 17 deletions(-)
create mode 100644 src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
create mode 100644 src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefreshShop.Codeunit.al
create mode 100644 src/Apps/W1/Shopify/App/src/Integration/Pages/ShpfyTokenDevTools.Page.al
create mode 100644 src/Apps/W1/Shopify/Test/Integration/ShpfyTokenRefreshTest.Codeunit.al
diff --git a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al
index 84a0070bc3..6cb54fd709 100644
--- a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al
@@ -231,6 +231,13 @@ codeunit 30103 "Shpfy Communication Mgt."
end;
if HttpClient.Send(HttpRequestMessage, HttpResponseMessage) then begin
+ if HandleUnauthorizedResponse(HttpResponseMessage) then begin
+ Clear(HttpClient);
+ Clear(HttpRequestMessage);
+ Clear(HttpResponseMessage);
+ CreateHttpRequestMessage(Url, Method, Request, HttpRequestMessage);
+ HttpClient.Send(HttpRequestMessage, HttpResponseMessage);
+ end;
Clear(RetryCounter);
while (not HttpResponseMessage.IsBlockedByEnvironment) and (EvaluateResponse(HttpResponseMessage)) and (RetryCounter < MaxRetries) do begin
RetryCounter += 1;
@@ -374,6 +381,7 @@ codeunit 30103 "Shpfy Communication Mgt."
NoAccessTokenErr: label 'No Access token for the store "%1".\Please request an access token for this store.', Comment = '%1 = Store';
ChangedScopeErr: Label 'The application scope is changed, please request a new access token for the store "%1".', Comment = '%1 = Store';
begin
+ AuthenticationMgt.EnsureValidAccessToken(Store);
if RegisteredStoreNew.Get(Store) then
if RegisteredStoreNew."Requested Scope" = AuthenticationMgt.GetScope() then begin
AccessToken := RegisteredStoreNew.GetAccessToken();
@@ -528,6 +536,22 @@ codeunit 30103 "Shpfy Communication Mgt."
end;
end;
+ local procedure HandleUnauthorizedResponse(HttpResponseMessage: HttpResponseMessage): Boolean
+ var
+ AuthenticationMgt: Codeunit "Shpfy Authentication Mgt.";
+ Store: Text;
+ begin
+ // An expiring offline token may have been retired unexpectedly. Force a single refresh
+ // (or migration) so the request can be retried with a fresh token.
+ if HttpResponseMessage.HttpStatusCode() <> 401 then
+ exit(false);
+ Store := Shop.GetStoreName();
+ if Store = '' then
+ exit(false);
+ AuthenticationMgt.ForceTokenRefresh(Store);
+ exit(true);
+ end;
+
///
/// Description for SetShop.
///
diff --git a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyInstaller.Codeunit.al b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyInstaller.Codeunit.al
index 26f8fa4a86..ca5b05eac8 100644
--- a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyInstaller.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyInstaller.Codeunit.al
@@ -21,6 +21,14 @@ codeunit 30273 "Shpfy Installer"
begin
AddRetentionPolicyAllowedTables();
AddShopifyCueSetup();
+ ScheduleTokenRefreshJob();
+ end;
+
+ local procedure ScheduleTokenRefreshJob()
+ var
+ TokenRefresh: Codeunit "Shpfy Token Refresh";
+ begin
+ TokenRefresh.ScheduleRefreshJob();
end;
procedure AddRetentionPolicyAllowedTables()
diff --git a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyUpgradeMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyUpgradeMgt.Codeunit.al
index 39d6de5f96..0fe00e1e16 100644
--- a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyUpgradeMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyUpgradeMgt.Codeunit.al
@@ -45,6 +45,7 @@ codeunit 30106 "Shpfy Upgrade Mgt."
OrderTransactionShopCodeUpgrade();
HasAdvancedShopifyPlanUpgrade();
ItalianSardinianProvinceRenameUpgrade();
+ ScheduleTokenRefreshJobUpgrade();
end;
internal procedure UpgradeTemplatesData()
@@ -535,6 +536,19 @@ codeunit 30106 "Shpfy Upgrade Mgt."
UpgradeTag.SetUpgradeTag(GetHasAdvancedShopifyPlanUpgradeTag());
end;
+ local procedure ScheduleTokenRefreshJobUpgrade()
+ var
+ UpgradeTag: Codeunit "Upgrade Tag";
+ TokenRefresh: Codeunit "Shpfy Token Refresh";
+ begin
+ if UpgradeTag.HasUpgradeTag(GetScheduleTokenRefreshJobUpgradeTag()) then
+ exit;
+
+ TokenRefresh.ScheduleRefreshJob();
+
+ UpgradeTag.SetUpgradeTag(GetScheduleTokenRefreshJobUpgradeTag());
+ end;
+
internal procedure GetAllowOutgoingRequestseUpgradeTag(): Code[250]
begin
exit('MS-445989-AllowOutgoingRequestseUpgradeTag-20220816');
@@ -615,6 +629,11 @@ codeunit 30106 "Shpfy Upgrade Mgt."
exit('MS-630316-HasAdvancedShopifyPlanUpgrade-20260408');
end;
+ local procedure GetScheduleTokenRefreshJobUpgradeTag(): Code[250]
+ begin
+ exit('MS-637954-ScheduleTokenRefreshJob-20260711');
+ end;
+
local procedure ItalianSardinianProvinceRenameUpgrade()
var
UpgradeTag: Codeunit "Upgrade Tag";
@@ -674,5 +693,6 @@ codeunit 30106 "Shpfy Upgrade Mgt."
PerCompanyUpgradeTags.Add(GetOrderTransactionShopCodeUpgradeTag());
PerCompanyUpgradeTags.Add(GetHasAdvancedShopifyPlanUpgradeTag());
PerCompanyUpgradeTags.Add(GetItalianSardinianProvinceRenameUpgradeTag());
+ PerCompanyUpgradeTags.Add(GetScheduleTokenRefreshJobUpgradeTag());
end;
}
diff --git a/src/Apps/W1/Shopify/App/src/Base/Pages/ShpfyShopCard.Page.al b/src/Apps/W1/Shopify/App/src/Base/Pages/ShpfyShopCard.Page.al
index 9d574d9cdd..71370a1ad7 100644
--- a/src/Apps/W1/Shopify/App/src/Base/Pages/ShpfyShopCard.Page.al
+++ b/src/Apps/W1/Shopify/App/src/Base/Pages/ShpfyShopCard.Page.al
@@ -1225,6 +1225,8 @@ page 30101 "Shpfy Shop Card"
ApiVersion: Text;
ApiVersionExpiryDate: Date;
ScopeChangeConfirmLbl: Label 'The access scope of shop %1 for the Shopify connector has changed. Do you want to request a new access token?', Comment = '%1 - Shop Code';
+ RefreshTokenExpiredNotificationLbl: Label 'The connection to Shopify shop %1 has expired. Reconnect the shop to continue synchronizing.', Comment = '%1 - Shop Code';
+ ReconnectActionLbl: Label 'Reconnect';
ConnectionSuccessfulMsg: Label 'Connection successful.';
ConnectionAndWebhooksSuccessfulMsg: Label 'Connection successful and auto synchronize orders is configured correctly.';
OrderCreatedWebhookNotConfiguredMsg: Label 'Connection successful, but auto synchronize orders is misconfigured. Reactivate Auto Sync Orders setting.';
@@ -1235,6 +1237,7 @@ page 30101 "Shpfy Shop Card"
AuthenticationMgt: Codeunit "Shpfy Authentication Mgt.";
CommunicationMgt: Codeunit "Shpfy Communication Mgt.";
ShopReview: Codeunit "Shpfy Shop Review";
+ RefreshTokenExpiredNotification: Notification;
ApiVersionExpiryDateTime: DateTime;
begin
FeatureTelemetry.LogUptake('0000HUU', 'Shopify', Enum::"Feature Uptake Status"::Discovered);
@@ -1257,6 +1260,13 @@ page 30101 "Shpfy Shop Card"
Rec.UpdateFulfillmentService();
#endif
ShopReview.MaybeShowReviewReminder(Rec.GetStoreName());
+
+ if AuthenticationMgt.IsRefreshTokenExpired(Rec.GetStoreName()) then begin
+ RefreshTokenExpiredNotification.Message(StrSubstNo(RefreshTokenExpiredNotificationLbl, Rec.Code));
+ RefreshTokenExpiredNotification.SetData('ShopCode', Rec.Code);
+ RefreshTokenExpiredNotification.AddAction(ReconnectActionLbl, Codeunit::"Shpfy Authentication Mgt.", 'ReconnectFromNotification');
+ RefreshTokenExpiredNotification.Send();
+ end;
end;
end;
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
index 6bf47168d1..37d03a3fdd 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
@@ -31,6 +31,15 @@ codeunit 30199 "Shpfy Authentication Mgt."
EnableHttpRequestActionLbl: Label 'Allow HTTP requests';
InvalidShopUrlErr: Label 'The URL must refer to the internal shop location at myshopify.com. It must not be the public URL that customers use, such as myshop.com.';
NotSupportedOnPremErr: Label 'Shopify connector is only supported in SaaS environments.';
+ RefreshTokenExpiredErr: Label 'The Shopify access token for store "%1" has expired and could not be refreshed automatically. Open the Shopify Shop card and reconnect the store to continue.', Comment = '%1 = Store';
+ TokenExchangeGrantTypeTok: Label 'urn:ietf:params:oauth:grant-type:token-exchange', Locked = true;
+ RefreshTokenGrantTypeTok: Label 'refresh_token', Locked = true;
+ OfflineAccessTokenTypeTok: Label 'urn:shopify:params:oauth:token-type:offline-access-token', Locked = true;
+ TokenMigratedTxt: Label 'Migrated Shopify store to an expiring offline access token.', Locked = true;
+ TokenMigrationFailedTxt: Label 'Failed to migrate Shopify store to an expiring offline access token. The existing token is kept.', Locked = true;
+ TokenRefreshedTxt: Label 'Refreshed the Shopify expiring offline access token.', Locked = true;
+ TokenRefreshTransientTxt: Label 'A transient error occurred while refreshing the Shopify access token. The existing token is still valid and will be retried later.', Locked = true;
+ TokenRefreshExpiredTxt: Label 'The Shopify refresh token has expired. The store must be reconnected.', Locked = true;
[NonDebuggable]
local procedure GetClientId(): Text
@@ -110,25 +119,39 @@ codeunit 30199 "Shpfy Authentication Mgt."
[NonDebuggable]
local procedure GetToken(Store: Text; AuthorizationCode: SecretText)
var
- JsonHelper: Codeunit "Shpfy Json Helper";
- Body: Text;
+ RequestBody: JsonObject;
+ Credentials: Dictionary of [Text, SecretText];
+ ResponseBody: Text;
+ StatusCode: Integer;
+ begin
+ RequestBody.Add('client_id', GetClientId());
+ RequestBody.Add('client_secret', '');
+ RequestBody.Add('code', '');
+ RequestBody.Add('expiring', 1);
+ Credentials.Add('$.client_secret', GetClientSecret());
+ Credentials.Add('$.code', AuthorizationCode);
+
+ StatusCode := ExecuteTokenRequest(Store, RequestBody, Credentials, ResponseBody);
+ if not IsSuccessStatusCode(StatusCode) then
+ exit;
+ if not ResponseHasAccessToken(ResponseBody) then
+ exit;
+
+ SaveInstalledToken(Store, ResponseBody);
+ end;
+
+ [NonDebuggable]
+ local procedure ExecuteTokenRequest(Store: Text; RequestBody: JsonObject; Credentials: Dictionary of [Text, SecretText]; var ResponseBody: Text): Integer
+ var
SecretBody: SecretText;
Url: Text;
HttpClient: HttpClient;
RequestHeaders: HttpHeaders;
RequestHttpContent: HttpContent;
HttpResponseMessage: HttpResponseMessage;
- JObject: JsonObject;
- RequestBody: JsonObject;
- Credentials: Dictionary of [Text, SecretText];
AccessTokenURLTxt: Label 'https://%1/admin/oauth/access_token', Comment = '%1 = Store', Locked = true;
HttpRequestBlockedErrorInfo: ErrorInfo;
begin
- RequestBody.Add('client_id', GetClientId());
- RequestBody.Add('client_secret', '');
- RequestBody.Add('code', '');
- Credentials.Add('$.client_secret', GetClientSecret());
- Credentials.Add('$.code', AuthorizationCode);
RequestBody.WriteWithSecretsTo(Credentials, SecretBody);
Url := StrSubstNo(AccessTokenURLTxt, Store);
@@ -147,15 +170,14 @@ codeunit 30199 "Shpfy Authentication Mgt."
HttpRequestBlockedErrorInfo.AddAction(EnableHttpRequestActionLbl, Codeunit::"Shpfy Authentication Mgt.", 'EnableHttpRequestForShopifyConnector');
Error(HttpRequestBlockedErrorInfo);
end else
- exit;
+ exit(0);
- Clear(Body);
- HttpResponseMessage.Content().ReadAs(Body);
- JObject.ReadFrom(Body);
- SaveStoreInfo(Store, JsonHelper.GetValueAsText(JObject.AsToken(), 'scope'), JsonHelper.GetValueAsText(JObject.AsToken(), 'access_token'));
+ Clear(ResponseBody);
+ HttpResponseMessage.Content().ReadAs(ResponseBody);
+ exit(HttpResponseMessage.HttpStatusCode());
end;
- local procedure SaveStoreInfo(Store: Text; ActualScope: Text; AccessToken: SecretText)
+ local procedure SaveInstalledToken(Store: Text; ResponseBody: Text)
var
RegisteredStoreNew: Record "Shpfy Registered Store New";
begin
@@ -166,9 +188,248 @@ codeunit 30199 "Shpfy Authentication Mgt."
RegisteredStoreNew.Insert();
end;
RegisteredStoreNew."Requested Scope" := GetScope();
- RegisteredStoreNew."Actual Scope" := CopyStr(ActualScope, 1, MaxStrLen(RegisteredStoreNew."Actual Scope"));
RegisteredStoreNew.Modify();
+ SaveTokenResponse(RegisteredStoreNew, ResponseBody);
+ end;
+
+ [NonDebuggable]
+ local procedure SaveTokenResponse(var RegisteredStoreNew: Record "Shpfy Registered Store New"; ResponseBody: Text)
+ var
+ JsonHelper: Codeunit "Shpfy Json Helper";
+ JObject: JsonObject;
+ JToken: JsonToken;
+ AccessToken: SecretText;
+ RefreshToken: SecretText;
+ AccessTokenText: Text;
+ RefreshTokenText: Text;
+ ActualScope: Text;
+ ExpiresInSeconds: BigInteger;
+ RefreshExpiresInSeconds: BigInteger;
+ begin
+ if not JObject.ReadFrom(ResponseBody) then
+ exit;
+ JToken := JObject.AsToken();
+
+ AccessTokenText := JsonHelper.GetValueAsText(JToken, 'access_token');
+ if AccessTokenText = '' then
+ exit;
+
+ ActualScope := JsonHelper.GetValueAsText(JToken, 'scope');
+ if ActualScope <> '' then
+ RegisteredStoreNew."Actual Scope" := CopyStr(ActualScope, 1, MaxStrLen(RegisteredStoreNew."Actual Scope"));
+
+ ExpiresInSeconds := JsonHelper.GetValueAsBigInteger(JToken, 'expires_in');
+ if ExpiresInSeconds > 0 then
+ RegisteredStoreNew."Token Expires At" := AddSeconds(CurrentDateTime(), ExpiresInSeconds)
+ else
+ RegisteredStoreNew."Token Expires At" := 0DT;
+
+ RefreshExpiresInSeconds := JsonHelper.GetValueAsBigInteger(JToken, 'refresh_token_expires_in');
+ if RefreshExpiresInSeconds > 0 then
+ RegisteredStoreNew."Refresh Token Expires At" := AddSeconds(CurrentDateTime(), RefreshExpiresInSeconds)
+ else
+ RegisteredStoreNew."Refresh Token Expires At" := 0DT;
+
+ RegisteredStoreNew.Modify();
+
+ AccessToken := AccessTokenText;
RegisteredStoreNew.SetAccessToken(AccessToken);
+
+ RefreshTokenText := JsonHelper.GetValueAsText(JToken, 'refresh_token');
+ if RefreshTokenText <> '' then begin
+ RefreshToken := RefreshTokenText;
+ RegisteredStoreNew.SetRefreshToken(RefreshToken);
+ end;
+ end;
+
+ ///
+ /// Ensures the store has a valid, non-expired offline access token before it is used.
+ /// Legacy non-expiring tokens are migrated to expiring tokens on first use, and expiring
+ /// tokens are refreshed when they are close to expiry. Refresh/migration is serialized
+ /// across sessions and companies via a table lock to respect Shopify's single
+ /// refreshable token per app and store.
+ ///
+ /// The store URL.
+ internal procedure EnsureValidAccessToken(Store: Text)
+ var
+ RegisteredStoreNew: Record "Shpfy Registered Store New";
+ begin
+ Store := Store.ToLower();
+ if not RegisteredStoreNew.Get(Store) then
+ exit;
+
+ if RegisteredStoreNew.HasRefreshToken() and not TokenNeedsRefresh(RegisteredStoreNew) then
+ exit;
+
+ RegisteredStoreNew.LockTable();
+ if not RegisteredStoreNew.Get(Store) then
+ exit;
+
+ if RegisteredStoreNew.HasRefreshToken() then begin
+ if TokenNeedsRefresh(RegisteredStoreNew) then
+ RefreshAccessToken(Store, RegisteredStoreNew);
+ end else
+ if not RegisteredStoreNew.GetAccessToken().IsEmpty() then
+ MigrateToExpiringToken(Store, RegisteredStoreNew);
+
+ Commit();
+ end;
+
+ ///
+ /// Forces a token refresh (or migration) regardless of the remaining lifetime. Used when an
+ /// API call unexpectedly returns 401, indicating the current access token is no longer valid.
+ ///
+ /// The store URL.
+ internal procedure ForceTokenRefresh(Store: Text)
+ var
+ RegisteredStoreNew: Record "Shpfy Registered Store New";
+ begin
+ Store := Store.ToLower();
+ if not RegisteredStoreNew.Get(Store) then
+ exit;
+
+ RegisteredStoreNew.LockTable();
+ if not RegisteredStoreNew.Get(Store) then
+ exit;
+
+ if RegisteredStoreNew.HasRefreshToken() then
+ RefreshAccessToken(Store, RegisteredStoreNew)
+ else
+ if not RegisteredStoreNew.GetAccessToken().IsEmpty() then
+ MigrateToExpiringToken(Store, RegisteredStoreNew);
+
+ Commit();
+ end;
+
+ [NonDebuggable]
+ local procedure MigrateToExpiringToken(Store: Text; var RegisteredStoreNew: Record "Shpfy Registered Store New")
+ var
+ RequestBody: JsonObject;
+ Credentials: Dictionary of [Text, SecretText];
+ ResponseBody: Text;
+ StatusCode: Integer;
+ begin
+ RequestBody.Add('client_id', GetClientId());
+ RequestBody.Add('client_secret', '');
+ RequestBody.Add('grant_type', TokenExchangeGrantTypeTok);
+ RequestBody.Add('subject_token', '');
+ RequestBody.Add('subject_token_type', OfflineAccessTokenTypeTok);
+ RequestBody.Add('requested_token_type', OfflineAccessTokenTypeTok);
+ RequestBody.Add('expiring', 1);
+ Credentials.Add('$.client_secret', GetClientSecret());
+ Credentials.Add('$.subject_token', RegisteredStoreNew.GetAccessToken());
+
+ StatusCode := ExecuteTokenRequest(Store, RequestBody, Credentials, ResponseBody);
+
+ // Migration is best-effort: the non-expiring token still works until January 1, 2027,
+ // so a transient failure must not break the connector. On success the old token is revoked.
+ if IsSuccessStatusCode(StatusCode) and ResponseHasAccessToken(ResponseBody) then begin
+ SaveTokenResponse(RegisteredStoreNew, ResponseBody);
+ LogTokenTelemetry('0000QK1', TokenMigratedTxt);
+ end else
+ LogTokenTelemetry('0000QK2', TokenMigrationFailedTxt);
+ end;
+
+ [NonDebuggable]
+ local procedure RefreshAccessToken(Store: Text; var RegisteredStoreNew: Record "Shpfy Registered Store New")
+ var
+ RequestBody: JsonObject;
+ Credentials: Dictionary of [Text, SecretText];
+ ResponseBody: Text;
+ StatusCode: Integer;
+ Attempt: Integer;
+ MaxAttempts: Integer;
+ begin
+ if RefreshTokenExpired(RegisteredStoreNew) then begin
+ LogTokenTelemetry('0000QK3', TokenRefreshExpiredTxt);
+ Error(RefreshTokenExpiredErr, Store);
+ end;
+
+ MaxAttempts := 3;
+ for Attempt := 1 to MaxAttempts do begin
+ Clear(RequestBody);
+ Clear(Credentials);
+ RequestBody.Add('client_id', GetClientId());
+ RequestBody.Add('client_secret', '');
+ RequestBody.Add('grant_type', RefreshTokenGrantTypeTok);
+ RequestBody.Add('refresh_token', '');
+ Credentials.Add('$.client_secret', GetClientSecret());
+ // Retry uses the SAME refresh token: Shopify returns the same response for up to 1 hour.
+ Credentials.Add('$.refresh_token', RegisteredStoreNew.GetRefreshToken());
+
+ StatusCode := ExecuteTokenRequest(Store, RequestBody, Credentials, ResponseBody);
+
+ if IsSuccessStatusCode(StatusCode) and ResponseHasAccessToken(ResponseBody) then begin
+ SaveTokenResponse(RegisteredStoreNew, ResponseBody);
+ LogTokenTelemetry('0000QK4', TokenRefreshedTxt);
+ exit;
+ end;
+
+ // A 401 with an inactive refresh token is terminal: the merchant must reconnect.
+ if StatusCode = 401 then begin
+ LogTokenTelemetry('0000QK3', TokenRefreshExpiredTxt);
+ Error(RefreshTokenExpiredErr, Store);
+ end;
+
+ Sleep(1000 * Attempt);
+ end;
+
+ // Transient failures exhausted. If the current access token is already expired the store
+ // cannot make calls, so surface the reconnect error; otherwise keep the still-valid token.
+ if TokenExpired(RegisteredStoreNew) then
+ Error(RefreshTokenExpiredErr, Store);
+ LogTokenTelemetry('0000QK5', TokenRefreshTransientTxt);
+ end;
+
+ local procedure TokenNeedsRefresh(RegisteredStoreNew: Record "Shpfy Registered Store New"): Boolean
+ begin
+ // A non-expiring token (no expiry recorded) never needs refreshing.
+ if RegisteredStoreNew."Token Expires At" = 0DT then
+ exit(false);
+ exit(CurrentDateTime() + GetRefreshBufferMs() >= RegisteredStoreNew."Token Expires At");
+ end;
+
+ local procedure TokenExpired(RegisteredStoreNew: Record "Shpfy Registered Store New"): Boolean
+ begin
+ exit((RegisteredStoreNew."Token Expires At" <> 0DT) and (CurrentDateTime() >= RegisteredStoreNew."Token Expires At"));
+ end;
+
+ local procedure RefreshTokenExpired(RegisteredStoreNew: Record "Shpfy Registered Store New"): Boolean
+ begin
+ exit((RegisteredStoreNew."Refresh Token Expires At" <> 0DT) and (CurrentDateTime() >= RegisteredStoreNew."Refresh Token Expires At"));
+ end;
+
+ local procedure GetRefreshBufferMs(): Duration
+ begin
+ exit(5 * 60 * 1000); // Refresh 5 minutes before the access token expires.
+ end;
+
+ local procedure AddSeconds(StartDateTime: DateTime; Seconds: BigInteger): DateTime
+ var
+ Lifetime: Duration;
+ begin
+ Lifetime := Seconds * 1000;
+ exit(StartDateTime + Lifetime);
+ end;
+
+ local procedure IsSuccessStatusCode(StatusCode: Integer): Boolean
+ begin
+ exit((StatusCode >= 200) and (StatusCode < 300));
+ end;
+
+ local procedure ResponseHasAccessToken(ResponseBody: Text): Boolean
+ var
+ JsonHelper: Codeunit "Shpfy Json Helper";
+ JObject: JsonObject;
+ begin
+ if not JObject.ReadFrom(ResponseBody) then
+ exit(false);
+ exit(JsonHelper.GetValueAsText(JObject.AsToken(), 'access_token') <> '');
+ end;
+
+ local procedure LogTokenTelemetry(EventId: Text; Message: Text)
+ begin
+ Session.LogMessage(EventId, Message, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
end;
[NonDebuggable]
@@ -181,6 +442,36 @@ codeunit 30199 "Shpfy Authentication Mgt."
exit(not RegisteredStoreNew.GetAccessToken().IsEmpty());
end;
+ ///
+ /// Returns whether the store uses an expiring offline token whose refresh token has expired,
+ /// meaning the merchant must reconnect the store before the connector can be used again.
+ ///
+ /// The store URL.
+ internal procedure IsRefreshTokenExpired(Store: Text): Boolean
+ var
+ RegisteredStoreNew: Record "Shpfy Registered Store New";
+ begin
+ Store := Store.ToLower();
+ if not RegisteredStoreNew.Get(Store) then
+ exit(false);
+ if not RegisteredStoreNew.HasRefreshToken() then
+ exit(false);
+ exit(RefreshTokenExpired(RegisteredStoreNew));
+ end;
+
+ internal procedure ReconnectFromNotification(ReconnectNotification: Notification)
+ var
+ Shop: Record "Shpfy Shop";
+ ShopCode: Code[20];
+ begin
+ if Evaluate(ShopCode, ReconnectNotification.GetData('ShopCode')) then
+ if Shop.Get(ShopCode) then begin
+ Shop.RequestAccessToken();
+ Shop.GetShopSettings();
+ Shop.Modify();
+ end;
+ end;
+
internal procedure AssertValidShopUrl(ShopUrl: Text)
var
URI: Codeunit Uri;
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
new file mode 100644
index 0000000000..4cac0a36b8
--- /dev/null
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
@@ -0,0 +1,82 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+
+namespace Microsoft.Integration.Shopify;
+
+using System.Threading;
+
+///
+/// Codeunit Shpfy Token Refresh (ID 30431).
+/// Proactive backstop that keeps expiring offline access tokens (and their 90-day refresh tokens)
+/// alive for shops that are otherwise idle, and completes the one-time migration of legacy
+/// non-expiring tokens. It complements the on-demand refresh performed before every API call.
+/// Scheduled as a recurring Job Queue Entry; each shop is processed in its own transaction (via the
+/// "Shpfy Token Refresh Shop" worker) so a single failing shop does not abort the whole run.
+///
+codeunit 30431 "Shpfy Token Refresh"
+{
+ Access = Internal;
+ TableNo = "Job Queue Entry";
+
+ trigger OnRun()
+ begin
+ RefreshAllShops();
+ end;
+
+ local procedure RefreshAllShops()
+ var
+ Shop: Record "Shpfy Shop";
+ begin
+ Shop.SetRange(Enabled, true);
+ if Shop.FindSet() then
+ repeat
+ Commit();
+ // Each shop runs in its own transaction so one failure does not abort the run.
+ if not Codeunit.Run(Codeunit::"Shpfy Token Refresh Shop", Shop) then
+ LogRefreshFailure(Shop.Code, GetLastErrorText());
+ until Shop.Next() = 0;
+ end;
+
+ local procedure LogRefreshFailure(ShopCode: Code[20]; ErrorText: Text)
+ var
+ CategoryTok: Label 'Shopify Integration', Locked = true;
+ TokenRefreshJobFailedTxt: Label 'The scheduled Shopify token refresh failed for shop %1: %2', Comment = '%1 = shop code, %2 = error text', Locked = true;
+ begin
+ Session.LogMessage('0000QK6', StrSubstNo(TokenRefreshJobFailedTxt, ShopCode, ErrorText), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
+ end;
+
+ ///
+ /// Creates the recurring Job Queue Entry that runs this codeunit, unless one already exists.
+ ///
+ internal procedure ScheduleRefreshJob()
+ var
+ JobQueueEntry: Record "Job Queue Entry";
+ JobQueueCategoryLbl: Label 'SHPFY', Locked = true;
+ JobDescriptionTxt: Label 'Shopify: refresh expiring access tokens';
+ begin
+ JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit);
+ JobQueueEntry.SetRange("Object ID to Run", Codeunit::"Shpfy Token Refresh");
+ if not JobQueueEntry.IsEmpty() then
+ exit;
+
+ Clear(JobQueueEntry);
+ JobQueueEntry.Init();
+ JobQueueEntry."Object Type to Run" := JobQueueEntry."Object Type to Run"::Codeunit;
+ JobQueueEntry."Object ID to Run" := Codeunit::"Shpfy Token Refresh";
+ JobQueueEntry."Recurring Job" := true;
+ JobQueueEntry."Run on Mondays" := true;
+ JobQueueEntry."Run on Tuesdays" := true;
+ JobQueueEntry."Run on Wednesdays" := true;
+ JobQueueEntry."Run on Thursdays" := true;
+ JobQueueEntry."Run on Fridays" := true;
+ JobQueueEntry."Run on Saturdays" := true;
+ JobQueueEntry."Run on Sundays" := true;
+ JobQueueEntry."No. of Minutes between Runs" := 720;
+ JobQueueEntry."No. of Attempts to Run" := 5;
+ JobQueueEntry.Description := CopyStr(JobDescriptionTxt, 1, MaxStrLen(JobQueueEntry.Description));
+ JobQueueEntry."Job Queue Category Code" := JobQueueCategoryLbl;
+ Codeunit.Run(Codeunit::"Job Queue - Enqueue", JobQueueEntry);
+ end;
+}
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefreshShop.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefreshShop.Codeunit.al
new file mode 100644
index 0000000000..7d9325282a
--- /dev/null
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefreshShop.Codeunit.al
@@ -0,0 +1,27 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+
+namespace Microsoft.Integration.Shopify;
+
+///
+/// Codeunit Shpfy Token Refresh Shop (ID 30432).
+/// Per-shop worker for the "Shpfy Token Refresh" backstop. Run via Codeunit.Run so that a failure
+/// for one shop is isolated in its own transaction and does not abort the whole backstop run.
+///
+codeunit 30432 "Shpfy Token Refresh Shop"
+{
+ Access = Internal;
+ TableNo = "Shpfy Shop";
+
+ trigger OnRun()
+ var
+ AuthenticationMgt: Codeunit "Shpfy Authentication Mgt.";
+ Store: Text;
+ begin
+ Store := Rec.GetStoreName();
+ if Store <> '' then
+ AuthenticationMgt.EnsureValidAccessToken(Store);
+ end;
+}
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Pages/ShpfyTokenDevTools.Page.al b/src/Apps/W1/Shopify/App/src/Integration/Pages/ShpfyTokenDevTools.Page.al
new file mode 100644
index 0000000000..9f67263e16
--- /dev/null
+++ b/src/Apps/W1/Shopify/App/src/Integration/Pages/ShpfyTokenDevTools.Page.al
@@ -0,0 +1,181 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+
+namespace Microsoft.Integration.Shopify;
+
+///
+/// Page Shpfy Token Dev Tools (ID 30440).
+/// TEMPORARY manual-testing aid for the expiring offline access token migration (slice 637954).
+/// It exposes the internal "Shpfy Registered Store New" token metadata and provides actions to force
+/// token states (near-expiry, expired refresh token, legacy non-expiring) and to invoke the on-demand
+/// orchestrator and the scheduled backstop. DELETE THIS PAGE BEFORE SHIPPING.
+///
+page 30440 "Shpfy Token Dev Tools"
+{
+ Caption = 'Shopify Token Dev Tools';
+ PageType = List;
+ ApplicationArea = All;
+ UsageCategory = Administration;
+ SourceTable = "Shpfy Registered Store New";
+ Editable = true;
+ InsertAllowed = false;
+ DeleteAllowed = false;
+
+ layout
+ {
+ area(Content)
+ {
+ repeater(Stores)
+ {
+ field(Store; Rec.Store)
+ {
+ Caption = 'Store';
+ Editable = false;
+ ToolTip = 'Specifies the Shopify store URL.';
+ }
+ field(HasAccessToken; HasAccessTokenValue)
+ {
+ Caption = 'Has Access Token';
+ Editable = false;
+ ToolTip = 'Specifies whether an access token is stored for this store.';
+ }
+ field(HasRefreshToken; HasRefreshTokenValue)
+ {
+ Caption = 'Has Refresh Token';
+ Editable = false;
+ ToolTip = 'Specifies whether a refresh token is stored (i.e. the store uses an expiring token).';
+ }
+ field("Token Expires At"; Rec."Token Expires At")
+ {
+ ToolTip = 'Specifies when the access token expires. Zero means a non-expiring (legacy) token.';
+ }
+ field("Refresh Token Expires At"; Rec."Refresh Token Expires At")
+ {
+ ToolTip = 'Specifies when the refresh token expires. Zero means unknown/non-expiring.';
+ }
+ field("Requested Scope"; Rec."Requested Scope")
+ {
+ Editable = false;
+ ToolTip = 'Specifies the scopes requested for this store.';
+ }
+ field("Actual Scope"; Rec."Actual Scope")
+ {
+ Editable = false;
+ ToolTip = 'Specifies the scopes granted for this store.';
+ }
+ }
+ }
+ }
+
+ actions
+ {
+ area(Processing)
+ {
+ action(SetAccessTokenExpired)
+ {
+ Caption = 'Force Access Token Expired';
+ ApplicationArea = All;
+ Image = Timesheet;
+ ToolTip = 'Sets the access token expiry to now so the next API call refreshes it (path C4).';
+
+ trigger OnAction()
+ begin
+ Rec."Token Expires At" := CurrentDateTime();
+ Rec.Modify();
+ CurrPage.Update(false);
+ end;
+ }
+ action(SetAccessTokenNearExpiry)
+ {
+ Caption = 'Force Access Token Near Expiry';
+ ApplicationArea = All;
+ Image = Timesheet;
+ ToolTip = 'Sets the access token expiry to one minute from now, inside the 5-minute refresh buffer (path C4).';
+
+ trigger OnAction()
+ begin
+ Rec."Token Expires At" := OffsetNow(60000);
+ Rec.Modify();
+ CurrPage.Update(false);
+ end;
+ }
+ action(SetRefreshTokenExpired)
+ {
+ Caption = 'Force Refresh Token Expired';
+ ApplicationArea = All;
+ Image = Delete;
+ ToolTip = 'Sets both expiries to the past so a refresh is attempted but short-circuits to the reconnect error (paths D7, H18).';
+
+ trigger OnAction()
+ begin
+ Rec."Token Expires At" := OffsetNow(-60000);
+ Rec."Refresh Token Expires At" := OffsetNow(-86400000);
+ Rec.Modify();
+ CurrPage.Update(false);
+ end;
+ }
+ action(EnsureValidToken)
+ {
+ Caption = 'Run Ensure Valid Access Token';
+ ApplicationArea = All;
+ Image = Refresh;
+ ToolTip = 'Invokes the on-demand orchestrator for the selected store (migrate-if-legacy / refresh-if-near-expiry).';
+
+ trigger OnAction()
+ var
+ AuthenticationMgt: Codeunit "Shpfy Authentication Mgt.";
+ BeforeExpiresAt: DateTime;
+ BeforeHadRefreshToken: Boolean;
+ ChangedMsg: Label 'Done. Token Expires At: %1. Refresh Token Expires At: %2. Has Refresh Token: %3.', Comment = '%1 = token expiry, %2 = refresh token expiry, %3 = has refresh token';
+ NoChangeMsg: Label 'No change. The Shopify token exchange/refresh did not produce an expiring token. Check telemetry 0000QK1 (migrated) / 0000QK2 (migration failed) / 0000QK4 (refreshed). Verify: (1) the store has a real, valid access token, (2) the Shopify app is configured to issue EXPIRING tokens (otherwise expiring=1 is ignored and no expiry is returned), and (3) the extension is allowed to make HTTP requests.';
+ begin
+ BeforeExpiresAt := Rec."Token Expires At";
+ BeforeHadRefreshToken := Rec.HasRefreshToken();
+
+ AuthenticationMgt.EnsureValidAccessToken(Rec.Store);
+ Commit();
+ CurrPage.Update(false);
+
+ if Rec.Get(Rec.Store) then;
+ if (Rec."Token Expires At" <> BeforeExpiresAt) or (Rec.HasRefreshToken() <> BeforeHadRefreshToken) then
+ Message(ChangedMsg, Rec."Token Expires At", Rec."Refresh Token Expires At", Rec.HasRefreshToken())
+ else
+ Message(NoChangeMsg);
+ end;
+ }
+ action(RunBackstopJob)
+ {
+ Caption = 'Run Backstop Job (All Shops)';
+ ApplicationArea = All;
+ Image = Job;
+ ToolTip = 'Runs the scheduled token refresh backstop across all enabled shops (paths G15-G17).';
+
+ trigger OnAction()
+ begin
+ Codeunit.Run(Codeunit::"Shpfy Token Refresh");
+ CurrPage.Update(false);
+ end;
+ }
+ }
+ }
+
+ var
+ HasAccessTokenValue: Boolean;
+ HasRefreshTokenValue: Boolean;
+
+ trigger OnAfterGetRecord()
+ begin
+ HasAccessTokenValue := not Rec.GetAccessToken().IsEmpty();
+ HasRefreshTokenValue := Rec.HasRefreshToken();
+ end;
+
+ local procedure OffsetNow(Milliseconds: BigInteger): DateTime
+ var
+ Offset: Duration;
+ begin
+ Offset := Milliseconds;
+ exit(CurrentDateTime() + Offset);
+ end;
+}
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al b/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al
index 2339618a1f..1873ce94c9 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al
@@ -41,6 +41,16 @@ table 30138 "Shpfy Registered Store New"
Caption = 'Review Completed';
DataClassification = SystemMetadata;
}
+ field(6; "Token Expires At"; DateTime)
+ {
+ Caption = 'Token Expires At';
+ DataClassification = SystemMetadata;
+ }
+ field(7; "Refresh Token Expires At"; DateTime)
+ {
+ Caption = 'Refresh Token Expires At';
+ DataClassification = SystemMetadata;
+ }
}
keys
{
@@ -59,4 +69,19 @@ table 30138 "Shpfy Registered Store New"
begin
if not IsolatedStorage.Get('AccessToken(' + Rec.SystemId + ')', DataScope::Module, Result) then;
end;
+
+ internal procedure SetRefreshToken(RefreshToken: SecretText)
+ begin
+ IsolatedStorage.Set('RefreshToken(' + Rec.SystemId + ')', RefreshToken, DataScope::Module);
+ end;
+
+ internal procedure GetRefreshToken() Result: SecretText
+ begin
+ if not IsolatedStorage.Get('RefreshToken(' + Rec.SystemId + ')', DataScope::Module, Result) then;
+ end;
+
+ internal procedure HasRefreshToken(): Boolean
+ begin
+ exit(not GetRefreshToken().IsEmpty());
+ end;
}
diff --git a/src/Apps/W1/Shopify/App/src/PermissionSets/ShpfyObjects.PermissionSet.al b/src/Apps/W1/Shopify/App/src/PermissionSets/ShpfyObjects.PermissionSet.al
index 65c48a0472..972c9b5eb0 100644
--- a/src/Apps/W1/Shopify/App/src/PermissionSets/ShpfyObjects.PermissionSet.al
+++ b/src/Apps/W1/Shopify/App/src/PermissionSets/ShpfyObjects.PermissionSet.al
@@ -268,6 +268,8 @@ permissionset 30104 "Shpfy - Objects"
codeunit "Shpfy Tax Registration No." = X,
codeunit "Shpfy ToArchivedProduct" = X,
codeunit "Shpfy ToDraftProduct" = X,
+ codeunit "Shpfy Token Refresh" = X,
+ codeunit "Shpfy Token Refresh Shop" = X,
codeunit "Shpfy Translation API" = X,
codeunit "Shpfy Translation Mgt." = X,
codeunit "Shpfy Transactions" = X,
@@ -354,6 +356,7 @@ permissionset 30104 "Shpfy - Objects"
page "Shpfy Tag Factbox" = X,
page "Shpfy Tags" = X,
page "Shpfy Tax Areas" = X,
+ page "Shpfy Token Dev Tools" = X,
page "Shpfy Transaction Gateways" = X,
page "Shpfy Transactions" = X,
page "Shpfy Variants" = X;
diff --git a/src/Apps/W1/Shopify/Test/Integration/ShpfyTokenRefreshTest.Codeunit.al b/src/Apps/W1/Shopify/Test/Integration/ShpfyTokenRefreshTest.Codeunit.al
new file mode 100644
index 0000000000..c8d5981274
--- /dev/null
+++ b/src/Apps/W1/Shopify/Test/Integration/ShpfyTokenRefreshTest.Codeunit.al
@@ -0,0 +1,119 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+
+namespace Microsoft.Integration.Shopify.Test;
+
+using Microsoft.Integration.Shopify;
+using System.TestLibraries.Utilities;
+
+codeunit 139635 "Shpfy Token Refresh Test"
+{
+ Subtype = Test;
+ TestType = IntegrationTest;
+ TestPermissions = Disabled;
+ TestHttpRequestPolicy = BlockOutboundRequests;
+
+ var
+ LibraryAssert: Codeunit "Library Assert";
+ Any: Codeunit Any;
+ StoreTok: Label 'shpfytokentest.myshopify.com', Locked = true;
+
+ [Test]
+ procedure RefreshTokenRoundTrips()
+ var
+ RegisteredStoreNew: Record "Shpfy Registered Store New";
+ begin
+ // [SCENARIO] A refresh token stored for a registered store can be retrieved.
+ // [GIVEN] A registered store with a stored refresh token.
+ SetupStore(RegisteredStoreNew, true, DaysFromNow(1));
+
+ // [THEN] The refresh token is present.
+ LibraryAssert.IsTrue(RegisteredStoreNew.HasRefreshToken(), 'Refresh token should be stored.');
+ LibraryAssert.IsFalse(RegisteredStoreNew.GetRefreshToken().IsEmpty(), 'Refresh token should not be empty.');
+ end;
+
+ [Test]
+ procedure IsRefreshTokenExpiredFalseWhenNoRefreshToken()
+ var
+ RegisteredStoreNew: Record "Shpfy Registered Store New";
+ AuthenticationMgt: Codeunit "Shpfy Authentication Mgt.";
+ begin
+ // [SCENARIO] A legacy non-expiring token (no refresh token) is never reported as expired.
+ // [GIVEN] A registered store without a refresh token, even with a past refresh expiry.
+ SetupStore(RegisteredStoreNew, false, DaysFromNow(-1));
+
+ // [THEN] The refresh token is not considered expired.
+ LibraryAssert.IsFalse(AuthenticationMgt.IsRefreshTokenExpired(StoreTok), 'A store without a refresh token must not be reported as expired.');
+ end;
+
+ [Test]
+ procedure IsRefreshTokenExpiredTrueWhenExpired()
+ var
+ RegisteredStoreNew: Record "Shpfy Registered Store New";
+ AuthenticationMgt: Codeunit "Shpfy Authentication Mgt.";
+ begin
+ // [SCENARIO] An expiring token whose refresh token lifetime has passed is reported as expired.
+ // [GIVEN] A registered store with a refresh token and a refresh expiry in the past.
+ SetupStore(RegisteredStoreNew, true, DaysFromNow(-1));
+
+ // [THEN] The refresh token is considered expired.
+ LibraryAssert.IsTrue(AuthenticationMgt.IsRefreshTokenExpired(StoreTok), 'An expired refresh token must be reported as expired.');
+ end;
+
+ [Test]
+ procedure IsRefreshTokenExpiredFalseWhenValid()
+ var
+ RegisteredStoreNew: Record "Shpfy Registered Store New";
+ AuthenticationMgt: Codeunit "Shpfy Authentication Mgt.";
+ begin
+ // [SCENARIO] An expiring token whose refresh token is still within its lifetime is not expired.
+ // [GIVEN] A registered store with a refresh token and a refresh expiry in the future.
+ SetupStore(RegisteredStoreNew, true, DaysFromNow(30));
+
+ // [THEN] The refresh token is not considered expired.
+ LibraryAssert.IsFalse(AuthenticationMgt.IsRefreshTokenExpired(StoreTok), 'A valid refresh token must not be reported as expired.');
+ end;
+
+ [Test]
+ procedure IsRefreshTokenExpiredFalseWhenExpiryUnknown()
+ var
+ RegisteredStoreNew: Record "Shpfy Registered Store New";
+ AuthenticationMgt: Codeunit "Shpfy Authentication Mgt.";
+ begin
+ // [SCENARIO] A refresh token with no recorded expiry is not reported as expired.
+ // [GIVEN] A registered store with a refresh token but a blank refresh expiry.
+ SetupStore(RegisteredStoreNew, true, 0DT);
+
+ // [THEN] The refresh token is not considered expired.
+ LibraryAssert.IsFalse(AuthenticationMgt.IsRefreshTokenExpired(StoreTok), 'A refresh token with no recorded expiry must not be reported as expired.');
+ end;
+
+ local procedure SetupStore(var RegisteredStoreNew: Record "Shpfy Registered Store New"; SetRefresh: Boolean; RefreshExpiresAt: DateTime)
+ var
+ RefreshToken: SecretText;
+ begin
+ if RegisteredStoreNew.Get(StoreTok) then
+ RegisteredStoreNew.Delete();
+ RegisteredStoreNew.Init();
+ RegisteredStoreNew.Store := CopyStr(StoreTok, 1, MaxStrLen(RegisteredStoreNew.Store));
+ RegisteredStoreNew."Refresh Token Expires At" := RefreshExpiresAt;
+ RegisteredStoreNew.Insert();
+ if SetRefresh then begin
+ RefreshToken := Any.AlphanumericText(20);
+ RegisteredStoreNew.SetRefreshToken(RefreshToken);
+ end;
+ end;
+
+ local procedure DaysFromNow(Days: Integer) Result: DateTime
+ var
+ Milliseconds: BigInteger;
+ Offset: Duration;
+ begin
+ Milliseconds := Days;
+ Milliseconds := Milliseconds * 24 * 60 * 60 * 1000;
+ Offset := Milliseconds;
+ exit(CurrentDateTime() + Offset);
+ end;
+}
From ca6ec0d1b28e22419d9f83f4101ae83b74067c29 Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Sat, 11 Jul 2026 17:06:57 +0200
Subject: [PATCH 02/32] [Shopify] Remove test scaffolding and clear telemetry
event IDs
- Remove the temporary "Shpfy Token Dev Tools" page (30440) and its permission-set entry.
- Clear the new telemetry event IDs (set to '') so they can be assigned by the tagging script.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../ShpfyAuthenticationMgt.Codeunit.al | 12 +-
.../Codeunits/ShpfyTokenRefresh.Codeunit.al | 2 +-
.../Pages/ShpfyTokenDevTools.Page.al | 181 ------------------
.../ShpfyObjects.PermissionSet.al | 1 -
4 files changed, 7 insertions(+), 189 deletions(-)
delete mode 100644 src/Apps/W1/Shopify/App/src/Integration/Pages/ShpfyTokenDevTools.Page.al
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
index 37d03a3fdd..b4afe7cf09 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
@@ -325,9 +325,9 @@ codeunit 30199 "Shpfy Authentication Mgt."
// so a transient failure must not break the connector. On success the old token is revoked.
if IsSuccessStatusCode(StatusCode) and ResponseHasAccessToken(ResponseBody) then begin
SaveTokenResponse(RegisteredStoreNew, ResponseBody);
- LogTokenTelemetry('0000QK1', TokenMigratedTxt);
+ LogTokenTelemetry('', TokenMigratedTxt);
end else
- LogTokenTelemetry('0000QK2', TokenMigrationFailedTxt);
+ LogTokenTelemetry('', TokenMigrationFailedTxt);
end;
[NonDebuggable]
@@ -341,7 +341,7 @@ codeunit 30199 "Shpfy Authentication Mgt."
MaxAttempts: Integer;
begin
if RefreshTokenExpired(RegisteredStoreNew) then begin
- LogTokenTelemetry('0000QK3', TokenRefreshExpiredTxt);
+ LogTokenTelemetry('', TokenRefreshExpiredTxt);
Error(RefreshTokenExpiredErr, Store);
end;
@@ -361,13 +361,13 @@ codeunit 30199 "Shpfy Authentication Mgt."
if IsSuccessStatusCode(StatusCode) and ResponseHasAccessToken(ResponseBody) then begin
SaveTokenResponse(RegisteredStoreNew, ResponseBody);
- LogTokenTelemetry('0000QK4', TokenRefreshedTxt);
+ LogTokenTelemetry('', TokenRefreshedTxt);
exit;
end;
// A 401 with an inactive refresh token is terminal: the merchant must reconnect.
if StatusCode = 401 then begin
- LogTokenTelemetry('0000QK3', TokenRefreshExpiredTxt);
+ LogTokenTelemetry('', TokenRefreshExpiredTxt);
Error(RefreshTokenExpiredErr, Store);
end;
@@ -378,7 +378,7 @@ codeunit 30199 "Shpfy Authentication Mgt."
// cannot make calls, so surface the reconnect error; otherwise keep the still-valid token.
if TokenExpired(RegisteredStoreNew) then
Error(RefreshTokenExpiredErr, Store);
- LogTokenTelemetry('0000QK5', TokenRefreshTransientTxt);
+ LogTokenTelemetry('', TokenRefreshTransientTxt);
end;
local procedure TokenNeedsRefresh(RegisteredStoreNew: Record "Shpfy Registered Store New"): Boolean
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
index 4cac0a36b8..64bb9e112f 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
@@ -44,7 +44,7 @@ codeunit 30431 "Shpfy Token Refresh"
CategoryTok: Label 'Shopify Integration', Locked = true;
TokenRefreshJobFailedTxt: Label 'The scheduled Shopify token refresh failed for shop %1: %2', Comment = '%1 = shop code, %2 = error text', Locked = true;
begin
- Session.LogMessage('0000QK6', StrSubstNo(TokenRefreshJobFailedTxt, ShopCode, ErrorText), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
+ Session.LogMessage('', StrSubstNo(TokenRefreshJobFailedTxt, ShopCode, ErrorText), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
end;
///
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Pages/ShpfyTokenDevTools.Page.al b/src/Apps/W1/Shopify/App/src/Integration/Pages/ShpfyTokenDevTools.Page.al
deleted file mode 100644
index 9f67263e16..0000000000
--- a/src/Apps/W1/Shopify/App/src/Integration/Pages/ShpfyTokenDevTools.Page.al
+++ /dev/null
@@ -1,181 +0,0 @@
-// ------------------------------------------------------------------------------------------------
-// Copyright (c) Microsoft Corporation. All rights reserved.
-// Licensed under the MIT License. See License.txt in the project root for license information.
-// ------------------------------------------------------------------------------------------------
-
-namespace Microsoft.Integration.Shopify;
-
-///
-/// Page Shpfy Token Dev Tools (ID 30440).
-/// TEMPORARY manual-testing aid for the expiring offline access token migration (slice 637954).
-/// It exposes the internal "Shpfy Registered Store New" token metadata and provides actions to force
-/// token states (near-expiry, expired refresh token, legacy non-expiring) and to invoke the on-demand
-/// orchestrator and the scheduled backstop. DELETE THIS PAGE BEFORE SHIPPING.
-///
-page 30440 "Shpfy Token Dev Tools"
-{
- Caption = 'Shopify Token Dev Tools';
- PageType = List;
- ApplicationArea = All;
- UsageCategory = Administration;
- SourceTable = "Shpfy Registered Store New";
- Editable = true;
- InsertAllowed = false;
- DeleteAllowed = false;
-
- layout
- {
- area(Content)
- {
- repeater(Stores)
- {
- field(Store; Rec.Store)
- {
- Caption = 'Store';
- Editable = false;
- ToolTip = 'Specifies the Shopify store URL.';
- }
- field(HasAccessToken; HasAccessTokenValue)
- {
- Caption = 'Has Access Token';
- Editable = false;
- ToolTip = 'Specifies whether an access token is stored for this store.';
- }
- field(HasRefreshToken; HasRefreshTokenValue)
- {
- Caption = 'Has Refresh Token';
- Editable = false;
- ToolTip = 'Specifies whether a refresh token is stored (i.e. the store uses an expiring token).';
- }
- field("Token Expires At"; Rec."Token Expires At")
- {
- ToolTip = 'Specifies when the access token expires. Zero means a non-expiring (legacy) token.';
- }
- field("Refresh Token Expires At"; Rec."Refresh Token Expires At")
- {
- ToolTip = 'Specifies when the refresh token expires. Zero means unknown/non-expiring.';
- }
- field("Requested Scope"; Rec."Requested Scope")
- {
- Editable = false;
- ToolTip = 'Specifies the scopes requested for this store.';
- }
- field("Actual Scope"; Rec."Actual Scope")
- {
- Editable = false;
- ToolTip = 'Specifies the scopes granted for this store.';
- }
- }
- }
- }
-
- actions
- {
- area(Processing)
- {
- action(SetAccessTokenExpired)
- {
- Caption = 'Force Access Token Expired';
- ApplicationArea = All;
- Image = Timesheet;
- ToolTip = 'Sets the access token expiry to now so the next API call refreshes it (path C4).';
-
- trigger OnAction()
- begin
- Rec."Token Expires At" := CurrentDateTime();
- Rec.Modify();
- CurrPage.Update(false);
- end;
- }
- action(SetAccessTokenNearExpiry)
- {
- Caption = 'Force Access Token Near Expiry';
- ApplicationArea = All;
- Image = Timesheet;
- ToolTip = 'Sets the access token expiry to one minute from now, inside the 5-minute refresh buffer (path C4).';
-
- trigger OnAction()
- begin
- Rec."Token Expires At" := OffsetNow(60000);
- Rec.Modify();
- CurrPage.Update(false);
- end;
- }
- action(SetRefreshTokenExpired)
- {
- Caption = 'Force Refresh Token Expired';
- ApplicationArea = All;
- Image = Delete;
- ToolTip = 'Sets both expiries to the past so a refresh is attempted but short-circuits to the reconnect error (paths D7, H18).';
-
- trigger OnAction()
- begin
- Rec."Token Expires At" := OffsetNow(-60000);
- Rec."Refresh Token Expires At" := OffsetNow(-86400000);
- Rec.Modify();
- CurrPage.Update(false);
- end;
- }
- action(EnsureValidToken)
- {
- Caption = 'Run Ensure Valid Access Token';
- ApplicationArea = All;
- Image = Refresh;
- ToolTip = 'Invokes the on-demand orchestrator for the selected store (migrate-if-legacy / refresh-if-near-expiry).';
-
- trigger OnAction()
- var
- AuthenticationMgt: Codeunit "Shpfy Authentication Mgt.";
- BeforeExpiresAt: DateTime;
- BeforeHadRefreshToken: Boolean;
- ChangedMsg: Label 'Done. Token Expires At: %1. Refresh Token Expires At: %2. Has Refresh Token: %3.', Comment = '%1 = token expiry, %2 = refresh token expiry, %3 = has refresh token';
- NoChangeMsg: Label 'No change. The Shopify token exchange/refresh did not produce an expiring token. Check telemetry 0000QK1 (migrated) / 0000QK2 (migration failed) / 0000QK4 (refreshed). Verify: (1) the store has a real, valid access token, (2) the Shopify app is configured to issue EXPIRING tokens (otherwise expiring=1 is ignored and no expiry is returned), and (3) the extension is allowed to make HTTP requests.';
- begin
- BeforeExpiresAt := Rec."Token Expires At";
- BeforeHadRefreshToken := Rec.HasRefreshToken();
-
- AuthenticationMgt.EnsureValidAccessToken(Rec.Store);
- Commit();
- CurrPage.Update(false);
-
- if Rec.Get(Rec.Store) then;
- if (Rec."Token Expires At" <> BeforeExpiresAt) or (Rec.HasRefreshToken() <> BeforeHadRefreshToken) then
- Message(ChangedMsg, Rec."Token Expires At", Rec."Refresh Token Expires At", Rec.HasRefreshToken())
- else
- Message(NoChangeMsg);
- end;
- }
- action(RunBackstopJob)
- {
- Caption = 'Run Backstop Job (All Shops)';
- ApplicationArea = All;
- Image = Job;
- ToolTip = 'Runs the scheduled token refresh backstop across all enabled shops (paths G15-G17).';
-
- trigger OnAction()
- begin
- Codeunit.Run(Codeunit::"Shpfy Token Refresh");
- CurrPage.Update(false);
- end;
- }
- }
- }
-
- var
- HasAccessTokenValue: Boolean;
- HasRefreshTokenValue: Boolean;
-
- trigger OnAfterGetRecord()
- begin
- HasAccessTokenValue := not Rec.GetAccessToken().IsEmpty();
- HasRefreshTokenValue := Rec.HasRefreshToken();
- end;
-
- local procedure OffsetNow(Milliseconds: BigInteger): DateTime
- var
- Offset: Duration;
- begin
- Offset := Milliseconds;
- exit(CurrentDateTime() + Offset);
- end;
-}
diff --git a/src/Apps/W1/Shopify/App/src/PermissionSets/ShpfyObjects.PermissionSet.al b/src/Apps/W1/Shopify/App/src/PermissionSets/ShpfyObjects.PermissionSet.al
index 972c9b5eb0..90878df7c8 100644
--- a/src/Apps/W1/Shopify/App/src/PermissionSets/ShpfyObjects.PermissionSet.al
+++ b/src/Apps/W1/Shopify/App/src/PermissionSets/ShpfyObjects.PermissionSet.al
@@ -356,7 +356,6 @@ permissionset 30104 "Shpfy - Objects"
page "Shpfy Tag Factbox" = X,
page "Shpfy Tags" = X,
page "Shpfy Tax Areas" = X,
- page "Shpfy Token Dev Tools" = X,
page "Shpfy Transaction Gateways" = X,
page "Shpfy Transactions" = X,
page "Shpfy Variants" = X;
From 725c73d6ba197dcc0d486807a66c37f7b4fc906a Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Sat, 11 Jul 2026 17:30:41 +0200
Subject: [PATCH 03/32] [Shopify] Update app docs for expiring offline access
tokens
Refresh the living docs (al-docs update) to cover slice 637954:
- business-logic.md: new "Authentication and token lifecycle" section.
- data-model.md: Shpfy Registered Store New token/expiry storage + ER entry.
- patterns.md: Job Queue dispatcher/worker per-shop isolation pattern.
- CLAUDE.md (app + Base): auth overview and "things to know".
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
src/Apps/W1/Shopify/App/.docs-updated | 4 ++--
src/Apps/W1/Shopify/App/CLAUDE.md | 5 +++++
src/Apps/W1/Shopify/App/docs/business-logic.md | 16 ++++++++++++++++
src/Apps/W1/Shopify/App/docs/data-model.md | 5 +++++
src/Apps/W1/Shopify/App/docs/patterns.md | 8 ++++++++
src/Apps/W1/Shopify/App/src/Base/docs/CLAUDE.md | 13 +++++++++++++
6 files changed, 49 insertions(+), 2 deletions(-)
diff --git a/src/Apps/W1/Shopify/App/.docs-updated b/src/Apps/W1/Shopify/App/.docs-updated
index 51bbc28924..e8cafa2be0 100644
--- a/src/Apps/W1/Shopify/App/.docs-updated
+++ b/src/Apps/W1/Shopify/App/.docs-updated
@@ -1,4 +1,4 @@
# Documentation last updated
-commit: 343aa21d1e5737d250aabf05cd9233d15c411cdc
-date: 2026-04-08
+commit: ca6ec0d1b28e22419d9f83f4101ae83b74067c29
+date: 2026-07-11
scope: full
diff --git a/src/Apps/W1/Shopify/App/CLAUDE.md b/src/Apps/W1/Shopify/App/CLAUDE.md
index 55f6fca0d5..0efa31993a 100644
--- a/src/Apps/W1/Shopify/App/CLAUDE.md
+++ b/src/Apps/W1/Shopify/App/CLAUDE.md
@@ -19,6 +19,10 @@ All Shopify API communication goes through GraphQL. The `ShpfyCommunicationMgt.C
*Updated: 2026-03-24 -- GraphQL resource file refactoring*
+Authentication uses Shopify **expiring offline access tokens**. On install the connector requests an expiring token (`expiring=1`) and stores it -- with its `Token Expires At` and 90-day `Refresh Token Expires At` -- on `Shpfy Registered Store New`, keyed by store URL (access and refresh tokens live in IsolatedStorage as `SecretText`). `ShpfyAuthenticationMgt.EnsureValidAccessToken`, called from `ShpfyCommunicationMgt` before every request, transparently refreshes a near-expiry token and performs the one-time (irreversible) migration of legacy non-expiring tokens via token exchange. A recurring backstop job (`Shpfy Token Refresh`) keeps tokens and their refresh tokens alive for idle shops.
+
+*Updated: 2026-07-11 -- Expiring offline access token support (slice 637954)*
+
Mapping strategies are interface-driven throughout. Customer mapping (`ICustomerMapping`) selects between by-email/phone, by-bill-to, or default-customer strategies. Company mapping (`ICompanyMapping`) can match by email/phone or tax ID. Stock calculation uses `IStockAvailable` and `IStockCalculation` interfaces. Product status on creation, removal actions for blocked items, county resolution, and customer name formatting are all interface-backed enums. The Shop record's enum fields (e.g., `"Customer Mapping Type"`, `"Stock Calculation"`, `"Status for Created Products"`) select which implementation to use at runtime.
Sync is incremental via the Synchronization Info table (`ShpfySynchronizationInfo.Table.al`), which stores the last sync timestamp per shop and sync type. An empty/zero date falls back to a sentinel value of `2004-01-01` (see `GetEmptySyncTime()`). Products use hash-based change detection -- the Product table stores `"Image Hash"`, `"Tags Hash"`, and `"Description Html Hash"` fields, computed via a custom hash algorithm in `ShpfyHash`, to avoid unnecessary API calls when nothing has actually changed.
@@ -62,6 +66,7 @@ Records link to BC entities via SystemId (GUID), not Code/No. For example, `Shpf
- The Shop table is the god object -- nearly every configuration setting lives there, with over 100 fields controlling sync directions, mapping strategies, account mappings, plan-based feature flags, webhook config, and more. The `"Advanced Shopify Plan"` field gates features requiring Plus/Advanced plans (currently staff members). B2B features are now unconditionally available on all plans.
- All API calls go through GraphQL, never REST. Queries are `.graphql` resource files in `.resources/graphql/{Area}/`, loaded via `NavApp.GetResourceAsText()` and dispatched through the `ShpfyGraphQLType` enum.
+- Authentication uses expiring offline access tokens: the connector requests `expiring=1` on install, refreshes the 1-hour access token before expiry (rotating the 90-day refresh token), and migrates legacy non-expiring tokens on first use. `EnsureValidAccessToken` (from `ShpfyCommunicationMgt.GetAccessToken`) handles this on demand; the recurring `Shpfy Token Refresh` job is the backstop. If the refresh token lapses (90 days idle), the merchant must reconnect the shop from the Shop Card. Migration is one-time and irreversible per shop.
- Products use hash-based change detection (`"Image Hash"`, `"Tags Hash"`, `"Description Html Hash"`) via a custom hash algorithm to skip unnecessary API calls when nothing has changed.
- Records link to BC entities via SystemId (GUID), not Code/No. -- FlowFields like `"Item No."` display the human-readable values via CalcFormula lookup. Renumbering BC items does not break Shopify links.
- Orders store every monetary amount in dual currency: shop currency fields (`"Total Amount"`, `"VAT Amount"`) and presentment/customer-facing currency fields (`"Presentment Total Amount"`, `"Presentment VAT Amount"`). The `"Currency Handling"` setting on Shop controls which is used for BC documents.
diff --git a/src/Apps/W1/Shopify/App/docs/business-logic.md b/src/Apps/W1/Shopify/App/docs/business-logic.md
index 5942d3a3b9..3da74758e1 100644
--- a/src/Apps/W1/Shopify/App/docs/business-logic.md
+++ b/src/Apps/W1/Shopify/App/docs/business-logic.md
@@ -2,6 +2,22 @@
This document covers the major processing flows in the Shopify Connector, focusing on decision points, non-obvious behavior, and what can go wrong.
+## Authentication and token lifecycle
+
+The connector authenticates to Shopify with **expiring offline access tokens** (public apps must migrate to these by 2027-01-01). An access token lives for 1 hour; a refresh token lives for 90 days and is rotated on each refresh. Tokens are held on `Shpfy Registered Store New` (keyed by store URL) -- the access and refresh tokens as `SecretText` in IsolatedStorage, with `Token Expires At` and `Refresh Token Expires At` as fields.
+
+`ShpfyCommunicationMgt.GetAccessToken` calls `ShpfyAuthenticationMgt.EnsureValidAccessToken` before every API request, so the following happens transparently in any session, interactive or background:
+
+- **Fast path** -- a valid expiring token is used as-is.
+- **Refresh** -- when the access token is within a 5-minute buffer of expiry, `RefreshAccessToken` exchanges the refresh token (`grant_type=refresh_token`) for a new access + refresh token. Transient failures are retried with the *same* refresh token (Shopify returns the same response for ~1 hour); a 401 or a lapsed refresh token is terminal and surfaces a "reconnect the store" error.
+- **Migration** -- a legacy non-expiring token (no refresh token stored) is migrated once via token exchange. This is best-effort: if it fails the existing token is kept (it still works until the deadline); on success Shopify revokes the old token, so the exchange is irreversible per shop.
+
+Refresh and migration are serialized with a table lock plus a double-checked re-read, because Shopify allows only one refreshable token per app and store -- this keeps concurrent sessions (or multiple BC companies sharing a shop) from thrashing the token. As a safety net, an unexpected 401 from a normal API call triggers a single forced refresh and retry.
+
+The `Shpfy Token Refresh` job (a recurring Job Queue entry registered on install and upgrade) is the proactive backstop. It iterates enabled shops and runs a per-shop worker so one shop's failure does not abort the run, keeping access tokens and 90-day refresh tokens alive for shops that are otherwise idle. When a refresh token has fully lapsed, the Shop Card shows a reconnect notification.
+
+*Updated: 2026-07-11 -- Expiring offline access token support (slice 637954)*
+
## Product synchronization
Product sync is bi-directional, controlled by the Shop's `"Sync Item"` setting (To Shopify, From Shopify, or disabled). The two directions have fundamentally different architectures.
diff --git a/src/Apps/W1/Shopify/App/docs/data-model.md b/src/Apps/W1/Shopify/App/docs/data-model.md
index 4ac3e57900..abefb6bf5b 100644
--- a/src/Apps/W1/Shopify/App/docs/data-model.md
+++ b/src/Apps/W1/Shopify/App/docs/data-model.md
@@ -14,12 +14,17 @@ The Shop table includes plan-based feature flags. The `"Advanced Shopify Plan"`
Shop Location maps Shopify fulfillment locations to BC warehouse locations. Each mapping includes a stock calculation enum that determines how to compute available stock for that pairing.
+Registered Store (`Shpfy Registered Store New`) holds the OAuth state for each connected shop, keyed by store URL (not Shop Code): the requested and actual scopes, plus expiring-token metadata (`Token Expires At`, `Refresh Token Expires At`). The access and refresh tokens themselves are not table fields -- they are stored as `SecretText` in IsolatedStorage (module scope), keyed by the record's SystemId. This is a per-app+store credential, so multiple BC companies connected to the same shop each keep their own copy. See business-logic.md for the token lifecycle.
+
+*Updated: 2026-07-11 -- Expiring offline access token metadata (slice 637954)*
+
```mermaid
erDiagram
SHOP ||--o{ SYNCHRONIZATION_INFO : tracks
SHOP ||--o{ SHOP_LOCATION : maps
SHOP ||--o{ PRODUCT : contains
SHOP ||--o{ ORDER_HEADER : contains
+ SHOP ||..|| REGISTERED_STORE : "OAuth tokens (by URL)"
```
The Shop's `GetEmptySyncTime()` returns `2004-01-01` as a sentinel for "never synced" -- not `0DT`. This date is far enough in the past to import all data on first sync but avoids edge cases with zero-date handling in AL. The `SetLastSyncTime()` method stores `CurrentDateTime` after a successful sync. When the next sync runs, it passes this timestamp to Shopify's `updated_at` filter to fetch only changed records.
diff --git a/src/Apps/W1/Shopify/App/docs/patterns.md b/src/Apps/W1/Shopify/App/docs/patterns.md
index 42edc27ae7..3aff0760c0 100644
--- a/src/Apps/W1/Shopify/App/docs/patterns.md
+++ b/src/Apps/W1/Shopify/App/docs/patterns.md
@@ -159,6 +159,14 @@ CustomerNo := ICustomerMapping.DoMapping(...);
This pattern repeats for stock calculation, product status, removal actions, customer name formatting, county resolution, company mapping, return/refund processing, metafield types, metafield owners, bulk operations, and document link handlers. Once you understand one instance, you understand them all.
+## Job Queue dispatcher and per-shop worker
+
+Background work that must run per shop with error isolation uses a two-codeunit split. The scheduled `Shpfy Token Refresh` codeunit has `TableNo = "Job Queue Entry"` and iterates the enabled shops. For each shop it calls `Codeunit.Run` on a separate worker, `Shpfy Token Refresh Shop`, which has `TableNo = "Shpfy Shop"`. Running the worker via `Codeunit.Run` gives each shop its own transaction, so a failure for one shop is caught and logged without aborting the whole run.
+
+The `TableNo` on the dispatcher is not optional: the Job Queue dispatcher passes the `Job Queue Entry` record to the codeunit it runs, so a Job-Queue-scheduled codeunit must use `TableNo = "Job Queue Entry"` (or none). Giving it the record it actually processes (e.g. `"Shpfy Shop"`) fails at runtime with "Record(472) is not compatible". Keep the dispatcher on `"Job Queue Entry"` and delegate record-specific work to a worker codeunit.
+
+*Updated: 2026-07-11 -- Token refresh backstop (slice 637954)*
+
## Legacy patterns
### Config Template Header (removed in v25)
diff --git a/src/Apps/W1/Shopify/App/src/Base/docs/CLAUDE.md b/src/Apps/W1/Shopify/App/src/Base/docs/CLAUDE.md
index c725bde0bb..e675c43002 100644
--- a/src/Apps/W1/Shopify/App/src/Base/docs/CLAUDE.md
+++ b/src/Apps/W1/Shopify/App/src/Base/docs/CLAUDE.md
@@ -21,6 +21,18 @@ GraphQL queries. `Shpfy Communication Events` publishes internal events
for every API interaction (`OnClientSend`, `OnClientPost`, `OnClientGet`,
`OnGetContent`, `OnGetAccessToken`) -- tests use these to mock responses.
+`GetAccessToken` ensures a valid expiring offline access token before each
+request via `ShpfyAuthenticationMgt.EnsureValidAccessToken` (refresh a
+near-expiry token or migrate a legacy non-expiring one, on demand), and
+`ExecuteWebRequest` forces a single token refresh and retry on an
+unexpected 401. The `Shpfy Token Refresh` dispatcher plus
+`Shpfy Token Refresh Shop` worker are a recurring backstop that keeps
+tokens and their 90-day refresh tokens alive; `Shpfy Installer` and
+`Shpfy Upgrade Mgt.` register that Job Queue entry. See the app-level
+business-logic.md for the full token lifecycle.
+
+*Updated: 2026-07-11 -- Expiring offline access token support (slice 637954)*
+
`Shpfy Background Syncs` orchestrates all sync operations via Job Queue,
splitting between background-allowed and foreground-only shops.
@@ -61,5 +73,6 @@ syncs companies and catalog prices regardless of plan.
- The `Shpfy Cue` table uses FlowFields for role center counts: unmapped
customers/products/companies, unprocessed orders/shipments, sync errors.
- Empty sync time sentinel is `2004-01-01` (`GetEmptySyncTime()`), not `0DT`.
+- Authentication uses expiring offline access tokens: `EnsureValidAccessToken` (from `GetAccessToken`) refreshes before expiry and migrates legacy non-expiring tokens; the `Shpfy Token Refresh` dispatcher + `Shpfy Token Refresh Shop` worker are the proactive backstop, registered as a Job Queue entry on install/upgrade. A lapsed 90-day refresh token requires reconnecting the shop from the Shop Card.
- Three page extensions embed Shopify Activities into standard role centers.
- `ShpfyConnectorGuide` and `ShpfyInitialImport` provide first-time setup.
From 71a73a6f44195b2be4f954c1e474831ab2d274ac Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Sat, 11 Jul 2026 17:33:23 +0200
Subject: [PATCH 04/32] [Shopify] Inline token telemetry calls for the tagging
script
Remove the LogTokenTelemetry helper and inline Session.LogMessage('', ...) at
each call site so the telemetry tagging script can assign event IDs (it only
fills literal Session.LogMessage first-args). Matches the existing connector
convention. Tags left empty pending the script.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../ShpfyAuthenticationMgt.Codeunit.al | 17 ++++++-----------
1 file changed, 6 insertions(+), 11 deletions(-)
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
index b4afe7cf09..4f950f1640 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
@@ -325,9 +325,9 @@ codeunit 30199 "Shpfy Authentication Mgt."
// so a transient failure must not break the connector. On success the old token is revoked.
if IsSuccessStatusCode(StatusCode) and ResponseHasAccessToken(ResponseBody) then begin
SaveTokenResponse(RegisteredStoreNew, ResponseBody);
- LogTokenTelemetry('', TokenMigratedTxt);
+ Session.LogMessage('', TokenMigratedTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
end else
- LogTokenTelemetry('', TokenMigrationFailedTxt);
+ Session.LogMessage('', TokenMigrationFailedTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
end;
[NonDebuggable]
@@ -341,7 +341,7 @@ codeunit 30199 "Shpfy Authentication Mgt."
MaxAttempts: Integer;
begin
if RefreshTokenExpired(RegisteredStoreNew) then begin
- LogTokenTelemetry('', TokenRefreshExpiredTxt);
+ Session.LogMessage('', TokenRefreshExpiredTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
Error(RefreshTokenExpiredErr, Store);
end;
@@ -361,13 +361,13 @@ codeunit 30199 "Shpfy Authentication Mgt."
if IsSuccessStatusCode(StatusCode) and ResponseHasAccessToken(ResponseBody) then begin
SaveTokenResponse(RegisteredStoreNew, ResponseBody);
- LogTokenTelemetry('', TokenRefreshedTxt);
+ Session.LogMessage('', TokenRefreshedTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
exit;
end;
// A 401 with an inactive refresh token is terminal: the merchant must reconnect.
if StatusCode = 401 then begin
- LogTokenTelemetry('', TokenRefreshExpiredTxt);
+ Session.LogMessage('', TokenRefreshExpiredTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
Error(RefreshTokenExpiredErr, Store);
end;
@@ -378,7 +378,7 @@ codeunit 30199 "Shpfy Authentication Mgt."
// cannot make calls, so surface the reconnect error; otherwise keep the still-valid token.
if TokenExpired(RegisteredStoreNew) then
Error(RefreshTokenExpiredErr, Store);
- LogTokenTelemetry('', TokenRefreshTransientTxt);
+ Session.LogMessage('', TokenRefreshTransientTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
end;
local procedure TokenNeedsRefresh(RegisteredStoreNew: Record "Shpfy Registered Store New"): Boolean
@@ -427,11 +427,6 @@ codeunit 30199 "Shpfy Authentication Mgt."
exit(JsonHelper.GetValueAsText(JObject.AsToken(), 'access_token') <> '');
end;
- local procedure LogTokenTelemetry(EventId: Text; Message: Text)
- begin
- Session.LogMessage(EventId, Message, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
- end;
-
[NonDebuggable]
internal procedure AccessTokenExist(Store: Text): Boolean
var
From c0e21fdb36a7ab56f511e41f4da1b79efcbb8493 Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Sat, 11 Jul 2026 17:34:44 +0200
Subject: [PATCH 05/32] [Shopify] Assign telemetry event IDs
Fill the token lifecycle telemetry event IDs (0000UIV-0000UJ1) via the tagging script.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../Codeunits/ShpfyAuthenticationMgt.Codeunit.al | 12 ++++++------
.../Codeunits/ShpfyTokenRefresh.Codeunit.al | 2 +-
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
index 4f950f1640..2c149ab2b1 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
@@ -325,9 +325,9 @@ codeunit 30199 "Shpfy Authentication Mgt."
// so a transient failure must not break the connector. On success the old token is revoked.
if IsSuccessStatusCode(StatusCode) and ResponseHasAccessToken(ResponseBody) then begin
SaveTokenResponse(RegisteredStoreNew, ResponseBody);
- Session.LogMessage('', TokenMigratedTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
+ Session.LogMessage('0000UIW', TokenMigratedTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
end else
- Session.LogMessage('', TokenMigrationFailedTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
+ Session.LogMessage('0000UIX', TokenMigrationFailedTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
end;
[NonDebuggable]
@@ -341,7 +341,7 @@ codeunit 30199 "Shpfy Authentication Mgt."
MaxAttempts: Integer;
begin
if RefreshTokenExpired(RegisteredStoreNew) then begin
- Session.LogMessage('', TokenRefreshExpiredTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
+ Session.LogMessage('0000UIY', TokenRefreshExpiredTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
Error(RefreshTokenExpiredErr, Store);
end;
@@ -361,13 +361,13 @@ codeunit 30199 "Shpfy Authentication Mgt."
if IsSuccessStatusCode(StatusCode) and ResponseHasAccessToken(ResponseBody) then begin
SaveTokenResponse(RegisteredStoreNew, ResponseBody);
- Session.LogMessage('', TokenRefreshedTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
+ Session.LogMessage('0000UIZ', TokenRefreshedTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
exit;
end;
// A 401 with an inactive refresh token is terminal: the merchant must reconnect.
if StatusCode = 401 then begin
- Session.LogMessage('', TokenRefreshExpiredTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
+ Session.LogMessage('0000UJ0', TokenRefreshExpiredTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
Error(RefreshTokenExpiredErr, Store);
end;
@@ -378,7 +378,7 @@ codeunit 30199 "Shpfy Authentication Mgt."
// cannot make calls, so surface the reconnect error; otherwise keep the still-valid token.
if TokenExpired(RegisteredStoreNew) then
Error(RefreshTokenExpiredErr, Store);
- Session.LogMessage('', TokenRefreshTransientTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
+ Session.LogMessage('0000UJ1', TokenRefreshTransientTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
end;
local procedure TokenNeedsRefresh(RegisteredStoreNew: Record "Shpfy Registered Store New"): Boolean
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
index 64bb9e112f..4f35cc921c 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
@@ -44,7 +44,7 @@ codeunit 30431 "Shpfy Token Refresh"
CategoryTok: Label 'Shopify Integration', Locked = true;
TokenRefreshJobFailedTxt: Label 'The scheduled Shopify token refresh failed for shop %1: %2', Comment = '%1 = shop code, %2 = error text', Locked = true;
begin
- Session.LogMessage('', StrSubstNo(TokenRefreshJobFailedTxt, ShopCode, ErrorText), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
+ Session.LogMessage('0000UIV', StrSubstNo(TokenRefreshJobFailedTxt, ShopCode, ErrorText), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
end;
///
From 56aa07dbc6bcc321987063e694c460877337ba35 Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Sun, 12 Jul 2026 14:46:55 +0200
Subject: [PATCH 06/32] [Shopify] Address PR feedback on token handling
- Mark SaveInstalledToken [NonDebuggable] (it holds the token-bearing response body).
- Give the token-refresh backstop its own Job Queue category (SHPFYAUTH) so it is
not serialized behind long-running SHPFY sync jobs (pricing/inventory) and can
refresh 1-hour access tokens promptly.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al | 1 +
.../App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
index 2c149ab2b1..1bbc6be44e 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
@@ -177,6 +177,7 @@ codeunit 30199 "Shpfy Authentication Mgt."
exit(HttpResponseMessage.HttpStatusCode());
end;
+ [NonDebuggable]
local procedure SaveInstalledToken(Store: Text; ResponseBody: Text)
var
RegisteredStoreNew: Record "Shpfy Registered Store New";
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
index 4f35cc921c..ac2863f10b 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
@@ -53,7 +53,7 @@ codeunit 30431 "Shpfy Token Refresh"
internal procedure ScheduleRefreshJob()
var
JobQueueEntry: Record "Job Queue Entry";
- JobQueueCategoryLbl: Label 'SHPFY', Locked = true;
+ JobQueueCategoryLbl: Label 'SHPFYAUTH', Locked = true;
JobDescriptionTxt: Label 'Shopify: refresh expiring access tokens';
begin
JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit);
From e3359e6a72a08b7b462e89aeb562c881477347a8 Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 13 Jul 2026 10:29:06 +0200
Subject: [PATCH 07/32] [Shopify] Address PR review feedback (round 2)
- Encrypt the refresh token at rest: IsolatedStorage.SetEncrypted instead of Set
(90-day credential; Get transparently decrypts).
- Mark ResponseHasAccessToken [NonDebuggable] (handles the token-bearing body).
- Remove the redundant per-iteration Commit() in the token-refresh dispatcher
loop; the per-shop worker (Codeunit.Run) already commits on success.
- Guard Codeunit.Run("Job Queue - Enqueue") in ScheduleRefreshJob so an enqueue
failure is logged instead of aborting the install/upgrade.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../Codeunits/ShpfyAuthenticationMgt.Codeunit.al | 1 +
.../Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al | 9 ++++++---
.../Integration/Tables/ShpfyRegisteredStoreNew.Table.al | 2 +-
3 files changed, 8 insertions(+), 4 deletions(-)
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
index 1bbc6be44e..ef24c9217c 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
@@ -418,6 +418,7 @@ codeunit 30199 "Shpfy Authentication Mgt."
exit((StatusCode >= 200) and (StatusCode < 300));
end;
+ [NonDebuggable]
local procedure ResponseHasAccessToken(ResponseBody: Text): Boolean
var
JsonHelper: Codeunit "Shpfy Json Helper";
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
index ac2863f10b..3f353401a2 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
@@ -32,8 +32,7 @@ codeunit 30431 "Shpfy Token Refresh"
Shop.SetRange(Enabled, true);
if Shop.FindSet() then
repeat
- Commit();
- // Each shop runs in its own transaction so one failure does not abort the run.
+ // Each shop runs in its own transaction (Codeunit.Run) so one failure does not abort the run.
if not Codeunit.Run(Codeunit::"Shpfy Token Refresh Shop", Shop) then
LogRefreshFailure(Shop.Code, GetLastErrorText());
until Shop.Next() = 0;
@@ -55,6 +54,8 @@ codeunit 30431 "Shpfy Token Refresh"
JobQueueEntry: Record "Job Queue Entry";
JobQueueCategoryLbl: Label 'SHPFYAUTH', Locked = true;
JobDescriptionTxt: Label 'Shopify: refresh expiring access tokens';
+ CategoryTok: Label 'Shopify Integration', Locked = true;
+ ScheduleFailedTxt: Label 'Failed to schedule the Shopify token refresh job: %1', Comment = '%1 = error text', Locked = true;
begin
JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit);
JobQueueEntry.SetRange("Object ID to Run", Codeunit::"Shpfy Token Refresh");
@@ -77,6 +78,8 @@ codeunit 30431 "Shpfy Token Refresh"
JobQueueEntry."No. of Attempts to Run" := 5;
JobQueueEntry.Description := CopyStr(JobDescriptionTxt, 1, MaxStrLen(JobQueueEntry.Description));
JobQueueEntry."Job Queue Category Code" := JobQueueCategoryLbl;
- Codeunit.Run(Codeunit::"Job Queue - Enqueue", JobQueueEntry);
+ // Do not let an enqueue failure abort the install/upgrade that schedules this job.
+ if not Codeunit.Run(Codeunit::"Job Queue - Enqueue", JobQueueEntry) then
+ Session.LogMessage('', StrSubstNo(ScheduleFailedTxt, GetLastErrorText()), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
end;
}
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al b/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al
index 1873ce94c9..c18ff52da6 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al
@@ -72,7 +72,7 @@ table 30138 "Shpfy Registered Store New"
internal procedure SetRefreshToken(RefreshToken: SecretText)
begin
- IsolatedStorage.Set('RefreshToken(' + Rec.SystemId + ')', RefreshToken, DataScope::Module);
+ IsolatedStorage.SetEncrypted('RefreshToken(' + Rec.SystemId + ')', RefreshToken, DataScope::Module);
end;
internal procedure GetRefreshToken() Result: SecretText
From 5e8ec0d7b1268344d1b31e130caaa07b367312c8 Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 13 Jul 2026 10:50:39 +0200
Subject: [PATCH 08/32] [Shopify] Assign telemetry event ID for token-refresh
schedule failure
Fill 0000UJ6 (enqueue-failure warning) via the tagging script.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
index 3f353401a2..1bd7594aaf 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
@@ -80,6 +80,6 @@ codeunit 30431 "Shpfy Token Refresh"
JobQueueEntry."Job Queue Category Code" := JobQueueCategoryLbl;
// Do not let an enqueue failure abort the install/upgrade that schedules this job.
if not Codeunit.Run(Codeunit::"Job Queue - Enqueue", JobQueueEntry) then
- Session.LogMessage('', StrSubstNo(ScheduleFailedTxt, GetLastErrorText()), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
+ Session.LogMessage('0000UJ6', StrSubstNo(ScheduleFailedTxt, GetLastErrorText()), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
end;
}
From da2108ba8ad7e0591ac65dbff45a25fee3ff48d6 Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 13 Jul 2026 10:56:37 +0200
Subject: [PATCH 09/32] [Shopify] Throttle legacy token migration attempts
A legacy store has no refresh token, so EnsureValidAccessToken never hits the
fast path and re-attempts migration on every API call -- in a sync loop over
many records a failing migration means many redundant lock+HTTP+commit round
trips. Add a "Last Migration Attempt" timestamp on Shpfy Registered Store New
and only re-attempt migration once per hour (ShouldAttemptMigration/TryMigrate,
shared by EnsureValidAccessToken and ForceTokenRefresh). Docs updated.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../W1/Shopify/App/docs/business-logic.md | 2 +-
src/Apps/W1/Shopify/App/docs/data-model.md | 2 +-
.../ShpfyAuthenticationMgt.Codeunit.al | 38 ++++++++++++++++---
.../Tables/ShpfyRegisteredStoreNew.Table.al | 5 +++
4 files changed, 39 insertions(+), 8 deletions(-)
diff --git a/src/Apps/W1/Shopify/App/docs/business-logic.md b/src/Apps/W1/Shopify/App/docs/business-logic.md
index 3da74758e1..90731daaa6 100644
--- a/src/Apps/W1/Shopify/App/docs/business-logic.md
+++ b/src/Apps/W1/Shopify/App/docs/business-logic.md
@@ -10,7 +10,7 @@ The connector authenticates to Shopify with **expiring offline access tokens** (
- **Fast path** -- a valid expiring token is used as-is.
- **Refresh** -- when the access token is within a 5-minute buffer of expiry, `RefreshAccessToken` exchanges the refresh token (`grant_type=refresh_token`) for a new access + refresh token. Transient failures are retried with the *same* refresh token (Shopify returns the same response for ~1 hour); a 401 or a lapsed refresh token is terminal and surfaces a "reconnect the store" error.
-- **Migration** -- a legacy non-expiring token (no refresh token stored) is migrated once via token exchange. This is best-effort: if it fails the existing token is kept (it still works until the deadline); on success Shopify revokes the old token, so the exchange is irreversible per shop.
+- **Migration** -- a legacy non-expiring token (no refresh token stored) is migrated once via token exchange. This is best-effort: if it fails the existing token is kept (it still works until the deadline); on success Shopify revokes the old token, so the exchange is irreversible per shop. Because a legacy store has no refresh token, the fast-path check never short-circuits it, so migration is throttled by a `Last Migration Attempt` timestamp (retried at most once per hour) to avoid re-attempting a failing migration on every API call in a sync loop.
Refresh and migration are serialized with a table lock plus a double-checked re-read, because Shopify allows only one refreshable token per app and store -- this keeps concurrent sessions (or multiple BC companies sharing a shop) from thrashing the token. As a safety net, an unexpected 401 from a normal API call triggers a single forced refresh and retry.
diff --git a/src/Apps/W1/Shopify/App/docs/data-model.md b/src/Apps/W1/Shopify/App/docs/data-model.md
index abefb6bf5b..b95002766b 100644
--- a/src/Apps/W1/Shopify/App/docs/data-model.md
+++ b/src/Apps/W1/Shopify/App/docs/data-model.md
@@ -14,7 +14,7 @@ The Shop table includes plan-based feature flags. The `"Advanced Shopify Plan"`
Shop Location maps Shopify fulfillment locations to BC warehouse locations. Each mapping includes a stock calculation enum that determines how to compute available stock for that pairing.
-Registered Store (`Shpfy Registered Store New`) holds the OAuth state for each connected shop, keyed by store URL (not Shop Code): the requested and actual scopes, plus expiring-token metadata (`Token Expires At`, `Refresh Token Expires At`). The access and refresh tokens themselves are not table fields -- they are stored as `SecretText` in IsolatedStorage (module scope), keyed by the record's SystemId. This is a per-app+store credential, so multiple BC companies connected to the same shop each keep their own copy. See business-logic.md for the token lifecycle.
+Registered Store (`Shpfy Registered Store New`) holds the OAuth state for each connected shop, keyed by store URL (not Shop Code): the requested and actual scopes, plus expiring-token metadata (`Token Expires At`, `Refresh Token Expires At`, and `Last Migration Attempt` for migration throttling). The access and refresh tokens themselves are not table fields -- they are stored as `SecretText` in IsolatedStorage (module scope), keyed by the record's SystemId (the refresh token via `SetEncrypted`). This is a per-app+store credential, so multiple BC companies connected to the same shop each keep their own copy. See business-logic.md for the token lifecycle.
*Updated: 2026-07-11 -- Expiring offline access token metadata (slice 637954)*
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
index ef24c9217c..99db4628ff 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
@@ -259,8 +259,12 @@ codeunit 30199 "Shpfy Authentication Mgt."
if not RegisteredStoreNew.Get(Store) then
exit;
- if RegisteredStoreNew.HasRefreshToken() and not TokenNeedsRefresh(RegisteredStoreNew) then
- exit;
+ if RegisteredStoreNew.HasRefreshToken() then begin
+ if not TokenNeedsRefresh(RegisteredStoreNew) then
+ exit;
+ end else
+ if not ShouldAttemptMigration(RegisteredStoreNew) then
+ exit;
RegisteredStoreNew.LockTable();
if not RegisteredStoreNew.Get(Store) then
@@ -270,8 +274,7 @@ codeunit 30199 "Shpfy Authentication Mgt."
if TokenNeedsRefresh(RegisteredStoreNew) then
RefreshAccessToken(Store, RegisteredStoreNew);
end else
- if not RegisteredStoreNew.GetAccessToken().IsEmpty() then
- MigrateToExpiringToken(Store, RegisteredStoreNew);
+ TryMigrate(Store, RegisteredStoreNew);
Commit();
end;
@@ -296,12 +299,35 @@ codeunit 30199 "Shpfy Authentication Mgt."
if RegisteredStoreNew.HasRefreshToken() then
RefreshAccessToken(Store, RegisteredStoreNew)
else
- if not RegisteredStoreNew.GetAccessToken().IsEmpty() then
- MigrateToExpiringToken(Store, RegisteredStoreNew);
+ TryMigrate(Store, RegisteredStoreNew);
Commit();
end;
+ local procedure TryMigrate(Store: Text; var RegisteredStoreNew: Record "Shpfy Registered Store New")
+ begin
+ // Throttle migration attempts: a legacy store has no refresh token, so without this guard
+ // every API call in a sync loop would re-attempt a failing migration (lock + HTTP + commit).
+ if not ShouldAttemptMigration(RegisteredStoreNew) then
+ exit;
+ RegisteredStoreNew."Last Migration Attempt" := CurrentDateTime();
+ RegisteredStoreNew.Modify();
+ if not RegisteredStoreNew.GetAccessToken().IsEmpty() then
+ MigrateToExpiringToken(Store, RegisteredStoreNew);
+ end;
+
+ local procedure ShouldAttemptMigration(RegisteredStoreNew: Record "Shpfy Registered Store New"): Boolean
+ begin
+ if RegisteredStoreNew."Last Migration Attempt" = 0DT then
+ exit(true);
+ exit(CurrentDateTime() - RegisteredStoreNew."Last Migration Attempt" >= GetMigrationCooldown());
+ end;
+
+ local procedure GetMigrationCooldown(): Duration
+ begin
+ exit(60 * 60 * 1000); // Retry a failed migration at most once per hour.
+ end;
+
[NonDebuggable]
local procedure MigrateToExpiringToken(Store: Text; var RegisteredStoreNew: Record "Shpfy Registered Store New")
var
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al b/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al
index c18ff52da6..eb51039ee7 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al
@@ -51,6 +51,11 @@ table 30138 "Shpfy Registered Store New"
Caption = 'Refresh Token Expires At';
DataClassification = SystemMetadata;
}
+ field(8; "Last Migration Attempt"; DateTime)
+ {
+ Caption = 'Last Migration Attempt';
+ DataClassification = SystemMetadata;
+ }
}
keys
{
From fcc6ce611128d923b42e618bcbaf6b35d0e2182d Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 13 Jul 2026 11:12:20 +0200
Subject: [PATCH 10/32] [Shopify] Keep customer content out of token-refresh
telemetry messages
- Do not interpolate GetLastErrorText() into the telemetry message string
(LogRefreshFailure and the schedule-failure log). Use a generic message,
move the error detail and shop code to custom dimensions, and classify the
event as CustomerContent so the platform can handle it appropriately.
- Move the shared telemetry Labels to the codeunit object scope.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../Codeunits/ShpfyTokenRefresh.Codeunit.al | 28 +++++++++++++++----
1 file changed, 22 insertions(+), 6 deletions(-)
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
index 1bd7594aaf..4b7bc63306 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
@@ -25,6 +25,11 @@ codeunit 30431 "Shpfy Token Refresh"
RefreshAllShops();
end;
+ var
+ CategoryTok: Label 'Shopify Integration', Locked = true;
+ TokenRefreshJobFailedTxt: Label 'The scheduled Shopify token refresh failed for a store.', Locked = true;
+ ScheduleFailedTxt: Label 'Failed to schedule the Shopify token refresh job.', Locked = true;
+
local procedure RefreshAllShops()
var
Shop: Record "Shpfy Shop";
@@ -40,10 +45,23 @@ codeunit 30431 "Shpfy Token Refresh"
local procedure LogRefreshFailure(ShopCode: Code[20]; ErrorText: Text)
var
- CategoryTok: Label 'Shopify Integration', Locked = true;
- TokenRefreshJobFailedTxt: Label 'The scheduled Shopify token refresh failed for shop %1: %2', Comment = '%1 = shop code, %2 = error text', Locked = true;
+ Dimensions: Dictionary of [Text, Text];
begin
- Session.LogMessage('0000UIV', StrSubstNo(TokenRefreshJobFailedTxt, ShopCode, ErrorText), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
+ // Keep the message generic; the error detail (which may carry customer content) goes into a
+ // custom dimension and the whole event is classified accordingly.
+ Dimensions.Add('Category', CategoryTok);
+ Dimensions.Add('ShopCode', ShopCode);
+ Dimensions.Add('ErrorText', ErrorText);
+ Session.LogMessage('0000UIV', TokenRefreshJobFailedTxt, Verbosity::Warning, DataClassification::CustomerContent, TelemetryScope::ExtensionPublisher, Dimensions);
+ end;
+
+ local procedure LogScheduleFailure(ErrorText: Text)
+ var
+ Dimensions: Dictionary of [Text, Text];
+ begin
+ Dimensions.Add('Category', CategoryTok);
+ Dimensions.Add('ErrorText', ErrorText);
+ Session.LogMessage('0000UJ6', ScheduleFailedTxt, Verbosity::Warning, DataClassification::CustomerContent, TelemetryScope::ExtensionPublisher, Dimensions);
end;
///
@@ -54,8 +72,6 @@ codeunit 30431 "Shpfy Token Refresh"
JobQueueEntry: Record "Job Queue Entry";
JobQueueCategoryLbl: Label 'SHPFYAUTH', Locked = true;
JobDescriptionTxt: Label 'Shopify: refresh expiring access tokens';
- CategoryTok: Label 'Shopify Integration', Locked = true;
- ScheduleFailedTxt: Label 'Failed to schedule the Shopify token refresh job: %1', Comment = '%1 = error text', Locked = true;
begin
JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit);
JobQueueEntry.SetRange("Object ID to Run", Codeunit::"Shpfy Token Refresh");
@@ -80,6 +96,6 @@ codeunit 30431 "Shpfy Token Refresh"
JobQueueEntry."Job Queue Category Code" := JobQueueCategoryLbl;
// Do not let an enqueue failure abort the install/upgrade that schedules this job.
if not Codeunit.Run(Codeunit::"Job Queue - Enqueue", JobQueueEntry) then
- Session.LogMessage('0000UJ6', StrSubstNo(ScheduleFailedTxt, GetLastErrorText()), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
+ LogScheduleFailure(GetLastErrorText());
end;
}
From a99064974a7027735fb87f1f9ec581172950f659 Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 13 Jul 2026 12:31:09 +0200
Subject: [PATCH 11/32] [Shopify] Fix clashing test object ID (139635 ->
139613)
Codeunit 139635 is already used by "E-Doc. Receive Files" in the EDocument
test app; BCApps validates test object IDs globally. Move Shpfy Token Refresh
Test to 139613, which is free repo-wide within the Shopify test ID ranges.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../Shopify/Test/Integration/ShpfyTokenRefreshTest.Codeunit.al | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Apps/W1/Shopify/Test/Integration/ShpfyTokenRefreshTest.Codeunit.al b/src/Apps/W1/Shopify/Test/Integration/ShpfyTokenRefreshTest.Codeunit.al
index c8d5981274..7b005a98f8 100644
--- a/src/Apps/W1/Shopify/Test/Integration/ShpfyTokenRefreshTest.Codeunit.al
+++ b/src/Apps/W1/Shopify/Test/Integration/ShpfyTokenRefreshTest.Codeunit.al
@@ -8,7 +8,7 @@ namespace Microsoft.Integration.Shopify.Test;
using Microsoft.Integration.Shopify;
using System.TestLibraries.Utilities;
-codeunit 139635 "Shpfy Token Refresh Test"
+codeunit 139613 "Shpfy Token Refresh Test"
{
Subtype = Test;
TestType = IntegrationTest;
From be53e6de87048ded13d49f59a799ef29da164f57 Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 13 Jul 2026 12:42:14 +0200
Subject: [PATCH 12/32] [Shopify] Use test object ID 139627 (139613 taken on
main by Shpfy Variant API Test)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../Shopify/Test/Integration/ShpfyTokenRefreshTest.Codeunit.al | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Apps/W1/Shopify/Test/Integration/ShpfyTokenRefreshTest.Codeunit.al b/src/Apps/W1/Shopify/Test/Integration/ShpfyTokenRefreshTest.Codeunit.al
index 7b005a98f8..05bddd1647 100644
--- a/src/Apps/W1/Shopify/Test/Integration/ShpfyTokenRefreshTest.Codeunit.al
+++ b/src/Apps/W1/Shopify/Test/Integration/ShpfyTokenRefreshTest.Codeunit.al
@@ -8,7 +8,7 @@ namespace Microsoft.Integration.Shopify.Test;
using Microsoft.Integration.Shopify;
using System.TestLibraries.Utilities;
-codeunit 139613 "Shpfy Token Refresh Test"
+codeunit 139627 "Shpfy Token Refresh Test"
{
Subtype = Test;
TestType = IntegrationTest;
From 7442e00c74b016835f7436bc8572264598af468c Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 13 Jul 2026 13:45:08 +0200
Subject: [PATCH 13/32] [Shopify] Fix AA0137 unused return variable in test
helper
The country builds compile the test app with CodeCop treating AA0137 as an error. DaysFromNow used exit() with a named return variable 'Result' left unassigned; drop the name.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../Shopify/Test/Integration/ShpfyTokenRefreshTest.Codeunit.al | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Apps/W1/Shopify/Test/Integration/ShpfyTokenRefreshTest.Codeunit.al b/src/Apps/W1/Shopify/Test/Integration/ShpfyTokenRefreshTest.Codeunit.al
index 05bddd1647..983a047fd5 100644
--- a/src/Apps/W1/Shopify/Test/Integration/ShpfyTokenRefreshTest.Codeunit.al
+++ b/src/Apps/W1/Shopify/Test/Integration/ShpfyTokenRefreshTest.Codeunit.al
@@ -106,7 +106,7 @@ codeunit 139627 "Shpfy Token Refresh Test"
end;
end;
- local procedure DaysFromNow(Days: Integer) Result: DateTime
+ local procedure DaysFromNow(Days: Integer): DateTime
var
Milliseconds: BigInteger;
Offset: Duration;
From a7ac1c99d8ff2ef85816299c6b726e71dda3ba28 Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 13 Jul 2026 13:55:19 +0200
Subject: [PATCH 14/32] [Shopify] Clarify EnsureValidAccessToken concurrency
doc
Correct the doc comment: the table lock serializes refresh/migration per company only (the store table is DataPerCompany), not across companies. Document that the lock is deliberately held across the token request and that cross-company safety relies on Shopify's idempotent-response window.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../Codeunits/ShpfyAuthenticationMgt.Codeunit.al | 15 ++++++++++++---
1 file changed, 12 insertions(+), 3 deletions(-)
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
index 99db4628ff..51e3aa429c 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
@@ -246,9 +246,18 @@ codeunit 30199 "Shpfy Authentication Mgt."
///
/// Ensures the store has a valid, non-expired offline access token before it is used.
/// Legacy non-expiring tokens are migrated to expiring tokens on first use, and expiring
- /// tokens are refreshed when they are close to expiry. Refresh/migration is serialized
- /// across sessions and companies via a table lock to respect Shopify's single
- /// refreshable token per app and store.
+ /// tokens are refreshed when they are close to expiry.
+ ///
+ /// Concurrency: a cheap check runs outside any lock; only when a refresh or migration is
+ /// actually needed does the code take a table lock and re-read the row (double-checked
+ /// locking). Concurrent sessions in the same company therefore serialize on the store row and,
+ /// on the second pass, find the token already fresh and return without a second HTTP call. The
+ /// lock is intentionally held across the token request to Shopify (a single round-trip on the
+ /// happy path; short backoff retries only on transient errors) so parallel sessions cannot
+ /// retire each other's refresh token - a deliberate trade-off of brief blocking for
+ /// correctness. Note the lock is per-company: two BC companies connected to the same shop keep
+ /// separate rows and cannot block each other, so cross-company safety relies on Shopify
+ /// returning the same tokens for ~1 hour after a rotation rather than on this lock.
///
/// The store URL.
internal procedure EnsureValidAccessToken(Store: Text)
From 86043d3fe09af645c5bc6b1d747c528e5a2daef7 Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 13 Jul 2026 14:18:00 +0200
Subject: [PATCH 15/32] [Shopify] Move ScheduleRefreshJob labels to object
scope
Declare JobQueueCategoryLbl and JobDescriptionTxt in the codeunit's top-level var block so they are reliably extracted for XLIFF/translation and visible at object-level review.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
index 4b7bc63306..76ecef3f53 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
@@ -29,6 +29,8 @@ codeunit 30431 "Shpfy Token Refresh"
CategoryTok: Label 'Shopify Integration', Locked = true;
TokenRefreshJobFailedTxt: Label 'The scheduled Shopify token refresh failed for a store.', Locked = true;
ScheduleFailedTxt: Label 'Failed to schedule the Shopify token refresh job.', Locked = true;
+ JobQueueCategoryLbl: Label 'SHPFYAUTH', Locked = true;
+ JobDescriptionTxt: Label 'Shopify: refresh expiring access tokens';
local procedure RefreshAllShops()
var
@@ -70,8 +72,6 @@ codeunit 30431 "Shpfy Token Refresh"
internal procedure ScheduleRefreshJob()
var
JobQueueEntry: Record "Job Queue Entry";
- JobQueueCategoryLbl: Label 'SHPFYAUTH', Locked = true;
- JobDescriptionTxt: Label 'Shopify: refresh expiring access tokens';
begin
JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit);
JobQueueEntry.SetRange("Object ID to Run", Codeunit::"Shpfy Token Refresh");
From 9ccd49846b08a8ac183fbb4db3fd9d835dafada5 Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 13 Jul 2026 14:37:36 +0200
Subject: [PATCH 16/32] [Shopify] Encrypt access token at rest
(IsolatedStorage.SetEncrypted)
The access token is an OAuth credential of the same sensitivity as the refresh token, which this PR already stores encrypted. SetAccessToken now uses SetEncrypted for parity; existing unencrypted values remain readable via IsolatedStorage.Get, and future writes are encrypted.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al b/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al
index eb51039ee7..1fb5daea50 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al
@@ -67,7 +67,7 @@ table 30138 "Shpfy Registered Store New"
internal procedure SetAccessToken(AccessToken: SecretText)
begin
- IsolatedStorage.Set('AccessToken(' + Rec.SystemId + ')', AccessToken, DataScope::Module);
+ IsolatedStorage.SetEncrypted('AccessToken(' + Rec.SystemId + ')', AccessToken, DataScope::Module);
end;
internal procedure GetAccessToken() Result: SecretText
From 14e8487891e30f2b1ee180a57d26537e1489741e Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 13 Jul 2026 14:37:37 +0200
Subject: [PATCH 17/32] [Shopify] Guard retry send result after 401 in
ExecuteWebRequest
After a 401 forces a token refresh, the retried Send's boolean result was discarded; a transient failure then left an empty response that the retry loop would evaluate as status 0. Track the send result and gate the retry loop on it so a failed (re)send exits cleanly instead of evaluating an empty response.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al
index 6cb54fd709..ed9d89c200 100644
--- a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al
@@ -212,6 +212,7 @@ codeunit 30103 "Shpfy Communication Mgt."
HttpRequestMessage: HttpRequestMessage;
HttpResponseMessage: HttpResponseMessage;
RetryCounter: Integer;
+ Sent: Boolean;
begin
FeatureTelemetry.LogUptake('0000HUV', 'Shopify', Enum::"Feature Uptake Status"::Used);
if CheckOutgoingRequest then
@@ -230,16 +231,17 @@ codeunit 30103 "Shpfy Communication Mgt."
Sleep(Wait);
end;
- if HttpClient.Send(HttpRequestMessage, HttpResponseMessage) then begin
+ Sent := HttpClient.Send(HttpRequestMessage, HttpResponseMessage);
+ if Sent then begin
if HandleUnauthorizedResponse(HttpResponseMessage) then begin
Clear(HttpClient);
Clear(HttpRequestMessage);
Clear(HttpResponseMessage);
CreateHttpRequestMessage(Url, Method, Request, HttpRequestMessage);
- HttpClient.Send(HttpRequestMessage, HttpResponseMessage);
+ Sent := HttpClient.Send(HttpRequestMessage, HttpResponseMessage);
end;
Clear(RetryCounter);
- while (not HttpResponseMessage.IsBlockedByEnvironment) and (EvaluateResponse(HttpResponseMessage)) and (RetryCounter < MaxRetries) do begin
+ while Sent and (not HttpResponseMessage.IsBlockedByEnvironment) and (EvaluateResponse(HttpResponseMessage)) and (RetryCounter < MaxRetries) do begin
RetryCounter += 1;
Sleep(1000);
LogShopifyRequest(Url, Method, Request, HttpResponseMessage, Response, RetryCounter);
@@ -247,7 +249,7 @@ codeunit 30103 "Shpfy Communication Mgt."
Clear(HttpRequestMessage);
Clear(HttpResponseMessage);
CreateHttpRequestMessage(Url, Method, Request, HttpRequestMessage);
- HttpClient.Send(HttpRequestMessage, HttpResponseMessage);
+ Sent := HttpClient.Send(HttpRequestMessage, HttpResponseMessage);
end;
end;
if GetContent(HttpResponseMessage, Response) then;
From 2260086ae30fb9f470ca93ebb600807acdc375a2 Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 13 Jul 2026 15:00:01 +0200
Subject: [PATCH 18/32] [Shopify] Commit before enqueuing token refresh job on
install/upgrade
Codeunit.Run cannot nest inside an open write transaction. On a fresh install AddRetentionPolicyAllowedTables (and on upgrade the earlier per-company steps) write before ScheduleRefreshJob runs Codeunit.Run(Job Queue - Enqueue), which would throw at runtime. Commit before the guarded enqueue; this is a one-time install/upgrade action, not a loop.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
index 76ecef3f53..bfbf2ccb4d 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
@@ -94,7 +94,11 @@ codeunit 30431 "Shpfy Token Refresh"
JobQueueEntry."No. of Attempts to Run" := 5;
JobQueueEntry.Description := CopyStr(JobDescriptionTxt, 1, MaxStrLen(JobQueueEntry.Description));
JobQueueEntry."Job Queue Category Code" := JobQueueCategoryLbl;
- // Do not let an enqueue failure abort the install/upgrade that schedules this job.
+ // Codeunit.Run cannot nest inside the caller's open write transaction, and the install/upgrade
+ // steps that schedule this job write before calling it, so commit first. This is a one-time
+ // install/upgrade action (not a loop), so a single commit here is safe. An enqueue failure must
+ // not abort that install/upgrade, hence the guarded Codeunit.Run.
+ Commit();
if not Codeunit.Run(Codeunit::"Job Queue - Enqueue", JobQueueEntry) then
LogScheduleFailure(GetLastErrorText());
end;
From 9da7eef6ae7481565e51234cafaa14e44b5be42b Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Wed, 15 Jul 2026 16:38:30 +0200
Subject: [PATCH 19/32] [Shopify] Do not Commit in install/upgrade when
scheduling token refresh job
COMMIT (explicit or implicit) is not allowed inside install/upgrade triggers; the Commit added in 2260086ae3 crashed OnInstallAppPerCompany (fresh install). Codeunit 'Job Queue - Enqueue' performs no implicit commit, so no prior commit is needed - matching the platform's own JobQueueEntry.EnqueueTask and the existing ShpfyBackgroundSyncs pattern. The guarded Codeunit.Run still prevents an enqueue failure from aborting the install/upgrade.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al | 8 +++-----
1 file changed, 3 insertions(+), 5 deletions(-)
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
index bfbf2ccb4d..0b8b668be6 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
@@ -94,11 +94,9 @@ codeunit 30431 "Shpfy Token Refresh"
JobQueueEntry."No. of Attempts to Run" := 5;
JobQueueEntry.Description := CopyStr(JobDescriptionTxt, 1, MaxStrLen(JobQueueEntry.Description));
JobQueueEntry."Job Queue Category Code" := JobQueueCategoryLbl;
- // Codeunit.Run cannot nest inside the caller's open write transaction, and the install/upgrade
- // steps that schedule this job write before calling it, so commit first. This is a one-time
- // install/upgrade action (not a loop), so a single commit here is safe. An enqueue failure must
- // not abort that install/upgrade, hence the guarded Codeunit.Run.
- Commit();
+ // This runs from the install/upgrade flow, where COMMIT (explicit or implicit) is not allowed.
+ // "Job Queue - Enqueue" performs no implicit commit, so no prior commit is needed; the guarded
+ // Codeunit.Run keeps an enqueue failure from aborting the install/upgrade.
if not Codeunit.Run(Codeunit::"Job Queue - Enqueue", JobQueueEntry) then
LogScheduleFailure(GetLastErrorText());
end;
From 9ba446c2e9c311d46f32cd7c222ce6434301f757 Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 20 Jul 2026 13:00:52 +0200
Subject: [PATCH 20/32] [Shopify] Make token-reconnect error actionable with a
fix-it action
The three RefreshTokenExpiredErr sites raised a plain Error whose message told the merchant to open the Shop card and reconnect, but offered no way to get there. Wrap them in an ErrorInfo carrying a Reconnect fix-it action (mirroring the existing HttpRequestBlockedErrorInfo pattern) that reconnects the matching store directly, so the error is self-service instead of a dead end. In non-interactive contexts the action is simply not surfaced.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../ShpfyAuthenticationMgt.Codeunit.al | 38 +++++++++++++++++--
1 file changed, 35 insertions(+), 3 deletions(-)
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
index 51e3aa429c..c4beac12a5 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
@@ -32,6 +32,8 @@ codeunit 30199 "Shpfy Authentication Mgt."
InvalidShopUrlErr: Label 'The URL must refer to the internal shop location at myshopify.com. It must not be the public URL that customers use, such as myshop.com.';
NotSupportedOnPremErr: Label 'Shopify connector is only supported in SaaS environments.';
RefreshTokenExpiredErr: Label 'The Shopify access token for store "%1" has expired and could not be refreshed automatically. Open the Shopify Shop card and reconnect the store to continue.', Comment = '%1 = Store';
+ ReconnectActionLbl: Label 'Reconnect';
+ StoreDimensionTok: Label 'Store', Locked = true;
TokenExchangeGrantTypeTok: Label 'urn:ietf:params:oauth:grant-type:token-exchange', Locked = true;
RefreshTokenGrantTypeTok: Label 'refresh_token', Locked = true;
OfflineAccessTokenTypeTok: Label 'urn:shopify:params:oauth:token-type:offline-access-token', Locked = true;
@@ -378,7 +380,7 @@ codeunit 30199 "Shpfy Authentication Mgt."
begin
if RefreshTokenExpired(RegisteredStoreNew) then begin
Session.LogMessage('0000UIY', TokenRefreshExpiredTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
- Error(RefreshTokenExpiredErr, Store);
+ Error(CreateReconnectErrorInfo(Store));
end;
MaxAttempts := 3;
@@ -404,7 +406,7 @@ codeunit 30199 "Shpfy Authentication Mgt."
// A 401 with an inactive refresh token is terminal: the merchant must reconnect.
if StatusCode = 401 then begin
Session.LogMessage('0000UJ0', TokenRefreshExpiredTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
- Error(RefreshTokenExpiredErr, Store);
+ Error(CreateReconnectErrorInfo(Store));
end;
Sleep(1000 * Attempt);
@@ -413,7 +415,7 @@ codeunit 30199 "Shpfy Authentication Mgt."
// Transient failures exhausted. If the current access token is already expired the store
// cannot make calls, so surface the reconnect error; otherwise keep the still-valid token.
if TokenExpired(RegisteredStoreNew) then
- Error(RefreshTokenExpiredErr, Store);
+ Error(CreateReconnectErrorInfo(Store));
Session.LogMessage('0000UJ1', TokenRefreshTransientTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
end;
@@ -504,6 +506,36 @@ codeunit 30199 "Shpfy Authentication Mgt."
end;
end;
+ local procedure CreateReconnectErrorInfo(Store: Text) ReconnectError: ErrorInfo
+ begin
+ ReconnectError.DataClassification := ReconnectError.DataClassification::SystemMetadata;
+ ReconnectError.ErrorType := ReconnectError.ErrorType::Client;
+ ReconnectError.Verbosity := ReconnectError.Verbosity::Error;
+ ReconnectError.Message := StrSubstNo(RefreshTokenExpiredErr, Store);
+ ReconnectError.CustomDimensions.Add(StoreDimensionTok, Store);
+ ReconnectError.AddAction(ReconnectActionLbl, Codeunit::"Shpfy Authentication Mgt.", 'ReconnectFromError');
+ end;
+
+ internal procedure ReconnectFromError(ErrorInfo: ErrorInfo)
+ var
+ Shop: Record "Shpfy Shop";
+ Store: Text;
+ begin
+ if not ErrorInfo.CustomDimensions.ContainsKey(StoreDimensionTok) then
+ exit;
+ Store := ErrorInfo.CustomDimensions.Get(StoreDimensionTok).ToLower();
+ Shop.SetRange(Enabled, true);
+ if Shop.FindSet() then
+ repeat
+ if Shop.GetStoreName() = Store then begin
+ Shop.RequestAccessToken();
+ Shop.GetShopSettings();
+ Shop.Modify();
+ exit;
+ end;
+ until Shop.Next() = 0;
+ end;
+
internal procedure AssertValidShopUrl(ShopUrl: Text)
var
URI: Codeunit Uri;
From e5abc2d1c8d2ccbc817014931457048356715416 Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 20 Jul 2026 13:25:24 +0200
Subject: [PATCH 21/32] [Shopify] Classify reconnect ErrorInfo as
CustomerContent
CreateReconnectErrorInfo embeds the store domain (customer-bearing) in the message and a custom dimension, and ErrorInfo content flows to telemetry, so DataClassification must be CustomerContent - consistent with LogRefreshFailure in the same codeunit - rather than SystemMetadata.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
index c4beac12a5..ace18e0984 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
@@ -508,7 +508,9 @@ codeunit 30199 "Shpfy Authentication Mgt."
local procedure CreateReconnectErrorInfo(Store: Text) ReconnectError: ErrorInfo
begin
- ReconnectError.DataClassification := ReconnectError.DataClassification::SystemMetadata;
+ // The message and custom dimension embed the store domain (customer-bearing), and ErrorInfo
+ // content flows to telemetry, so classify as CustomerContent (consistent with LogRefreshFailure).
+ ReconnectError.DataClassification := ReconnectError.DataClassification::CustomerContent;
ReconnectError.ErrorType := ReconnectError.ErrorType::Client;
ReconnectError.Verbosity := ReconnectError.Verbosity::Error;
ReconnectError.Message := StrSubstNo(RefreshTokenExpiredErr, Store);
From 52af7aac10ae8dc931ae7beac47a76a0a2abee6d Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 20 Jul 2026 13:26:41 +0200
Subject: [PATCH 22/32] [Shopify] Remove unnecessary comment on reconnect
ErrorInfo classification
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al | 2 --
1 file changed, 2 deletions(-)
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
index ace18e0984..9349d80d0c 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
@@ -508,8 +508,6 @@ codeunit 30199 "Shpfy Authentication Mgt."
local procedure CreateReconnectErrorInfo(Store: Text) ReconnectError: ErrorInfo
begin
- // The message and custom dimension embed the store domain (customer-bearing), and ErrorInfo
- // content flows to telemetry, so classify as CustomerContent (consistent with LogRefreshFailure).
ReconnectError.DataClassification := ReconnectError.DataClassification::CustomerContent;
ReconnectError.ErrorType := ReconnectError.ErrorType::Client;
ReconnectError.Verbosity := ReconnectError.Verbosity::Error;
From a0eef99442d10f62fb00aa5c6a2e05bf0718b831 Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 20 Jul 2026 13:48:39 +0200
Subject: [PATCH 23/32] [Shopify] Log token failures at Warning and load only
needed shop field
Token-refresh/migration failure branches (migration failed, refresh-token
expired, transient-exhausted) were logged at Verbosity::Normal, which hides
them from severity-based alerting; raise them to Warning (success events stay
Normal). Also SetLoadFields for the Shopify URL in RefreshAllShops since the
per-shop worker only needs the store URL, avoiding full-record transfer of the
wide Shop table on the recurring job.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../Codeunits/ShpfyAuthenticationMgt.Codeunit.al | 8 ++++----
.../Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al | 1 +
2 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
index 9349d80d0c..31145b38f6 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
@@ -365,7 +365,7 @@ codeunit 30199 "Shpfy Authentication Mgt."
SaveTokenResponse(RegisteredStoreNew, ResponseBody);
Session.LogMessage('0000UIW', TokenMigratedTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
end else
- Session.LogMessage('0000UIX', TokenMigrationFailedTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
+ Session.LogMessage('0000UIX', TokenMigrationFailedTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
end;
[NonDebuggable]
@@ -379,7 +379,7 @@ codeunit 30199 "Shpfy Authentication Mgt."
MaxAttempts: Integer;
begin
if RefreshTokenExpired(RegisteredStoreNew) then begin
- Session.LogMessage('0000UIY', TokenRefreshExpiredTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
+ Session.LogMessage('0000UIY', TokenRefreshExpiredTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
Error(CreateReconnectErrorInfo(Store));
end;
@@ -405,7 +405,7 @@ codeunit 30199 "Shpfy Authentication Mgt."
// A 401 with an inactive refresh token is terminal: the merchant must reconnect.
if StatusCode = 401 then begin
- Session.LogMessage('0000UJ0', TokenRefreshExpiredTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
+ Session.LogMessage('0000UJ0', TokenRefreshExpiredTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
Error(CreateReconnectErrorInfo(Store));
end;
@@ -416,7 +416,7 @@ codeunit 30199 "Shpfy Authentication Mgt."
// cannot make calls, so surface the reconnect error; otherwise keep the still-valid token.
if TokenExpired(RegisteredStoreNew) then
Error(CreateReconnectErrorInfo(Store));
- Session.LogMessage('0000UJ1', TokenRefreshTransientTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
+ Session.LogMessage('0000UJ1', TokenRefreshTransientTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
end;
local procedure TokenNeedsRefresh(RegisteredStoreNew: Record "Shpfy Registered Store New"): Boolean
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
index 0b8b668be6..4fdac30b1f 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
@@ -37,6 +37,7 @@ codeunit 30431 "Shpfy Token Refresh"
Shop: Record "Shpfy Shop";
begin
Shop.SetRange(Enabled, true);
+ Shop.SetLoadFields("Shopify URL");
if Shop.FindSet() then
repeat
// Each shop runs in its own transaction (Codeunit.Run) so one failure does not abort the run.
From eb5f42d5d54161947fad3757a0d2f49cda62d745 Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 20 Jul 2026 13:59:37 +0200
Subject: [PATCH 24/32] [Shopify] Harden reactive token refresh, transient
errors, and upgrade tag
Three safe PR-review fixes:
- 401 circuit breaker: ForceTokenRefresh now records a per-store timestamp and
skips a repeat forced refresh within a one-minute cooldown, so a store that
returns 401 for a non-expiry reason no longer re-refreshes (and re-serializes
on the row lock) once per record during a bulk sync. It returns whether a
refresh was attempted so the caller only retries when it was.
- Transient-failure error: when refresh fails only on transient 5xx/network and
the access token is already expired, raise a non-actionable "try again later"
error instead of the reconnect fix-it action, which is not the real remedy.
- Upgrade tag: ScheduleRefreshJob returns success; the upgrade step only records
its tag when enqueuing succeeded, so a transient enqueue failure retries on a
later upgrade run instead of permanently suppressing the backstop.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../ShpfyCommunicationMgt.Codeunit.al | 3 +-
.../Codeunits/ShpfyUpgradeMgt.Codeunit.al | 7 ++--
.../ShpfyAuthenticationMgt.Codeunit.al | 42 ++++++++++++++++---
.../Codeunits/ShpfyTokenRefresh.Codeunit.al | 13 ++++--
.../Tables/ShpfyRegisteredStoreNew.Table.al | 5 +++
5 files changed, 55 insertions(+), 15 deletions(-)
diff --git a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al
index ed9d89c200..63c3ee520c 100644
--- a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al
@@ -550,8 +550,7 @@ codeunit 30103 "Shpfy Communication Mgt."
Store := Shop.GetStoreName();
if Store = '' then
exit(false);
- AuthenticationMgt.ForceTokenRefresh(Store);
- exit(true);
+ exit(AuthenticationMgt.ForceTokenRefresh(Store));
end;
///
diff --git a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyUpgradeMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyUpgradeMgt.Codeunit.al
index 0fe00e1e16..90088dd54a 100644
--- a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyUpgradeMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyUpgradeMgt.Codeunit.al
@@ -544,9 +544,10 @@ codeunit 30106 "Shpfy Upgrade Mgt."
if UpgradeTag.HasUpgradeTag(GetScheduleTokenRefreshJobUpgradeTag()) then
exit;
- TokenRefresh.ScheduleRefreshJob();
-
- UpgradeTag.SetUpgradeTag(GetScheduleTokenRefreshJobUpgradeTag());
+ // Only record the upgrade tag once the job is actually scheduled; otherwise a transient
+ // enqueue failure would permanently suppress the backstop on later upgrade runs.
+ if TokenRefresh.ScheduleRefreshJob() then
+ UpgradeTag.SetUpgradeTag(GetScheduleTokenRefreshJobUpgradeTag());
end;
internal procedure GetAllowOutgoingRequestseUpgradeTag(): Code[250]
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
index 31145b38f6..941de1b29f 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
@@ -32,6 +32,7 @@ codeunit 30199 "Shpfy Authentication Mgt."
InvalidShopUrlErr: Label 'The URL must refer to the internal shop location at myshopify.com. It must not be the public URL that customers use, such as myshop.com.';
NotSupportedOnPremErr: Label 'Shopify connector is only supported in SaaS environments.';
RefreshTokenExpiredErr: Label 'The Shopify access token for store "%1" has expired and could not be refreshed automatically. Open the Shopify Shop card and reconnect the store to continue.', Comment = '%1 = Store';
+ TokenRefreshTransientErr: Label 'The Shopify access token for store "%1" could not be refreshed because of a temporary problem contacting Shopify. Please try again later.', Comment = '%1 = Store';
ReconnectActionLbl: Label 'Reconnect';
StoreDimensionTok: Label 'Store', Locked = true;
TokenExchangeGrantTypeTok: Label 'urn:ietf:params:oauth:grant-type:token-exchange', Locked = true;
@@ -293,19 +294,32 @@ codeunit 30199 "Shpfy Authentication Mgt."
///
/// Forces a token refresh (or migration) regardless of the remaining lifetime. Used when an
/// API call unexpectedly returns 401, indicating the current access token is no longer valid.
+ /// A short cooldown prevents re-refreshing on every request when a store returns 401 for a
+ /// reason other than token expiry (e.g. revoked access), which would otherwise hammer the
+ /// refresh endpoint and serialize sessions once per record during a bulk sync.
+ /// Returns true if a refresh/migration was attempted (so the caller can retry the request),
+ /// false if it was skipped by the cooldown.
///
/// The store URL.
- internal procedure ForceTokenRefresh(Store: Text)
+ internal procedure ForceTokenRefresh(Store: Text): Boolean
var
RegisteredStoreNew: Record "Shpfy Registered Store New";
begin
Store := Store.ToLower();
if not RegisteredStoreNew.Get(Store) then
- exit;
+ exit(false);
+
+ if not ShouldForceRefresh(RegisteredStoreNew) then
+ exit(false);
RegisteredStoreNew.LockTable();
if not RegisteredStoreNew.Get(Store) then
- exit;
+ exit(false);
+ if not ShouldForceRefresh(RegisteredStoreNew) then
+ exit(false);
+
+ RegisteredStoreNew."Last Force Refresh At" := CurrentDateTime();
+ RegisteredStoreNew.Modify();
if RegisteredStoreNew.HasRefreshToken() then
RefreshAccessToken(Store, RegisteredStoreNew)
@@ -313,6 +327,19 @@ codeunit 30199 "Shpfy Authentication Mgt."
TryMigrate(Store, RegisteredStoreNew);
Commit();
+ exit(true);
+ end;
+
+ local procedure ShouldForceRefresh(RegisteredStoreNew: Record "Shpfy Registered Store New"): Boolean
+ begin
+ if RegisteredStoreNew."Last Force Refresh At" = 0DT then
+ exit(true);
+ exit(CurrentDateTime() - RegisteredStoreNew."Last Force Refresh At" >= GetForceRefreshCooldown());
+ end;
+
+ local procedure GetForceRefreshCooldown(): Duration
+ begin
+ exit(60 * 1000); // Force a reactive refresh for a store at most once per minute.
end;
local procedure TryMigrate(Store: Text; var RegisteredStoreNew: Record "Shpfy Registered Store New")
@@ -412,10 +439,13 @@ codeunit 30199 "Shpfy Authentication Mgt."
Sleep(1000 * Attempt);
end;
- // Transient failures exhausted. If the current access token is already expired the store
- // cannot make calls, so surface the reconnect error; otherwise keep the still-valid token.
+ // Transient failures exhausted. The refresh token is still valid (it passed the expiry
+ // check above and we never hit a 401), so this is a temporary Shopify/network problem, not
+ // a reconnect situation. If the current access token is already expired the store cannot
+ // make calls right now, so surface a non-actionable "try again later" error; otherwise keep
+ // the still-valid token and let the caller proceed.
if TokenExpired(RegisteredStoreNew) then
- Error(CreateReconnectErrorInfo(Store));
+ Error(TokenRefreshTransientErr, Store);
Session.LogMessage('0000UJ1', TokenRefreshTransientTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
end;
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
index 4fdac30b1f..3390b8fe6f 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
@@ -69,15 +69,18 @@ codeunit 30431 "Shpfy Token Refresh"
///
/// Creates the recurring Job Queue Entry that runs this codeunit, unless one already exists.
+ /// Returns true when the job exists after the call (already present or successfully enqueued),
+ /// false when enqueuing failed - so an upgrade caller only records its upgrade tag on success
+ /// and retries on a later run instead of permanently suppressing the backstop.
///
- internal procedure ScheduleRefreshJob()
+ internal procedure ScheduleRefreshJob(): Boolean
var
JobQueueEntry: Record "Job Queue Entry";
begin
JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit);
JobQueueEntry.SetRange("Object ID to Run", Codeunit::"Shpfy Token Refresh");
if not JobQueueEntry.IsEmpty() then
- exit;
+ exit(true);
Clear(JobQueueEntry);
JobQueueEntry.Init();
@@ -98,7 +101,9 @@ codeunit 30431 "Shpfy Token Refresh"
// This runs from the install/upgrade flow, where COMMIT (explicit or implicit) is not allowed.
// "Job Queue - Enqueue" performs no implicit commit, so no prior commit is needed; the guarded
// Codeunit.Run keeps an enqueue failure from aborting the install/upgrade.
- if not Codeunit.Run(Codeunit::"Job Queue - Enqueue", JobQueueEntry) then
- LogScheduleFailure(GetLastErrorText());
+ if Codeunit.Run(Codeunit::"Job Queue - Enqueue", JobQueueEntry) then
+ exit(true);
+ LogScheduleFailure(GetLastErrorText());
+ exit(false);
end;
}
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al b/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al
index 1fb5daea50..9f1318472a 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al
@@ -56,6 +56,11 @@ table 30138 "Shpfy Registered Store New"
Caption = 'Last Migration Attempt';
DataClassification = SystemMetadata;
}
+ field(9; "Last Force Refresh At"; DateTime)
+ {
+ Caption = 'Last Force Refresh At';
+ DataClassification = SystemMetadata;
+ }
}
keys
{
From 5d7a51b5edcd4554ef9cc2d084c695ceb3340fcd Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 20 Jul 2026 14:34:54 +0200
Subject: [PATCH 25/32] [Shopify] Persist force-refresh cooldown, enrich
reconnect error, fail loudly on token exchange
- Circuit-breaker cooldown: ForceTokenRefresh committed the Last Force Refresh At
marker only after RefreshAccessToken returned, so a terminal reconnect error
rolled it back and every subsequent 401 re-entered the refresh. Commit the
cooldown before the refresh attempt so the throttle persists on the failing path.
- Reconnect ErrorInfo: set Title and DetailedMessage alongside the existing
Reconnect action so the actionable error has a clear heading and supporting detail.
- Initial OAuth token exchange: GetToken exited silently on a non-success status or
a payload without an access token, so Request Access/reconnect failed with no
feedback. Raise a specific error instead.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../Codeunits/ShpfyAuthenticationMgt.Codeunit.al | 14 ++++++++++++--
1 file changed, 12 insertions(+), 2 deletions(-)
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
index 941de1b29f..028a9e79f9 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
@@ -43,6 +43,9 @@ codeunit 30199 "Shpfy Authentication Mgt."
TokenRefreshedTxt: Label 'Refreshed the Shopify expiring offline access token.', Locked = true;
TokenRefreshTransientTxt: Label 'A transient error occurred while refreshing the Shopify access token. The existing token is still valid and will be retried later.', Locked = true;
TokenRefreshExpiredTxt: Label 'The Shopify refresh token has expired. The store must be reconnected.', Locked = true;
+ ReconnectRequiredTitleLbl: Label 'Shopify reconnection required';
+ ReconnectDetailedLbl: Label 'The stored access token could not be refreshed automatically. This usually means the 90-day refresh window lapsed or access was revoked in Shopify. Choose Reconnect to sign in to Shopify again and restore synchronization.';
+ TokenExchangeFailedErr: Label 'Could not obtain an access token from Shopify for store "%1". Please try connecting the store again.', Comment = '%1 = Store';
[NonDebuggable]
local procedure GetClientId(): Text
@@ -136,9 +139,9 @@ codeunit 30199 "Shpfy Authentication Mgt."
StatusCode := ExecuteTokenRequest(Store, RequestBody, Credentials, ResponseBody);
if not IsSuccessStatusCode(StatusCode) then
- exit;
+ Error(TokenExchangeFailedErr, Store);
if not ResponseHasAccessToken(ResponseBody) then
- exit;
+ Error(TokenExchangeFailedErr, Store);
SaveInstalledToken(Store, ResponseBody);
end;
@@ -320,6 +323,11 @@ codeunit 30199 "Shpfy Authentication Mgt."
RegisteredStoreNew."Last Force Refresh At" := CurrentDateTime();
RegisteredStoreNew.Modify();
+ // Persist the cooldown BEFORE attempting the refresh: RefreshAccessToken raises the reconnect
+ // error on a terminal failure, which would otherwise roll back this Modify and let every
+ // subsequent 401 in a bulk sync re-enter and re-hit the refresh endpoint. Committing here
+ // keeps the throttle effective on the failing path.
+ Commit();
if RegisteredStoreNew.HasRefreshToken() then
RefreshAccessToken(Store, RegisteredStoreNew)
@@ -541,7 +549,9 @@ codeunit 30199 "Shpfy Authentication Mgt."
ReconnectError.DataClassification := ReconnectError.DataClassification::CustomerContent;
ReconnectError.ErrorType := ReconnectError.ErrorType::Client;
ReconnectError.Verbosity := ReconnectError.Verbosity::Error;
+ ReconnectError.Title := ReconnectRequiredTitleLbl;
ReconnectError.Message := StrSubstNo(RefreshTokenExpiredErr, Store);
+ ReconnectError.DetailedMessage := ReconnectDetailedLbl;
ReconnectError.CustomDimensions.Add(StoreDimensionTok, Store);
ReconnectError.AddAction(ReconnectActionLbl, Codeunit::"Shpfy Authentication Mgt.", 'ReconnectFromError');
end;
From b6d0b50659301e6aa513419dc7984b1362721802 Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 20 Jul 2026 14:54:27 +0200
Subject: [PATCH 26/32] [Shopify] Schedule token-refresh backstop lazily
instead of from install/upgrade
Enqueuing a Job Queue Entry runs Codeunit.Run("Job Queue - Enqueue"), which
implicitly commits on success (and ScheduleTask commits to register the task).
Implicit commits are not allowed inside install/upgrade triggers, so scheduling
the backstop from OnInstallAppPerCompany / OnUpgradePerCompany crashed a fresh
install with "the transaction is stopped".
Move scheduling out of the install/upgrade transaction entirely: schedule the
recurring job lazily from ShpfyCommunicationMgt.GetAccessToken (the single
chokepoint for all connector API usage), once per session via a SingleInstance
guard. This runs in a normal committable runtime context and uniformly covers
install, upgrade, new companies, and pre-existing shops. Removed the install and
upgrade scheduling calls and the now-unused upgrade tag.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../ShpfyCommunicationMgt.Codeunit.al | 17 +++++++++++++++
.../Base/Codeunits/ShpfyInstaller.Codeunit.al | 8 -------
.../Codeunits/ShpfyUpgradeMgt.Codeunit.al | 21 -------------------
.../Codeunits/ShpfyTokenRefresh.Codeunit.al | 12 +++++------
4 files changed, 23 insertions(+), 35 deletions(-)
diff --git a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al
index 63c3ee520c..33765f9477 100644
--- a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al
@@ -21,6 +21,7 @@ codeunit 30103 "Shpfy Communication Mgt."
Shop: Record "Shpfy Shop";
GraphQLQueries: Codeunit "Shpfy GraphQL Queries";
NextExecutionTime: DateTime;
+ BackstopScheduleChecked: Boolean;
VersionTok: Label '2026-07', Locked = true;
OutgoingRequestsNotEnabledConfirmLbl: Label 'Importing data to your Shopify shop is not enabled, do you want to go to shop card to enable?';
OutgoingRequestsNotEnabledErr: Label 'Importing data to your Shopify shop is not enabled, navigate to shop card to enable.';
@@ -383,6 +384,7 @@ codeunit 30103 "Shpfy Communication Mgt."
NoAccessTokenErr: label 'No Access token for the store "%1".\Please request an access token for this store.', Comment = '%1 = Store';
ChangedScopeErr: Label 'The application scope is changed, please request a new access token for the store "%1".', Comment = '%1 = Store';
begin
+ EnsureBackstopScheduled();
AuthenticationMgt.EnsureValidAccessToken(Store);
if RegisteredStoreNew.Get(Store) then
if RegisteredStoreNew."Requested Scope" = AuthenticationMgt.GetScope() then begin
@@ -396,6 +398,21 @@ codeunit 30103 "Shpfy Communication Mgt."
Error(NoAccessTokenErr, Store);
end;
+ local procedure EnsureBackstopScheduled()
+ var
+ TokenRefresh: Codeunit "Shpfy Token Refresh";
+ begin
+ // Schedule the recurring token-refresh backstop lazily, the first time the connector makes an
+ // API call in this session. It cannot be scheduled from the install/upgrade codeunits because
+ // enqueuing a Job Queue Entry implicitly commits, which is not allowed in those triggers.
+ // ScheduleRefreshJob is idempotent (it exits when the job already exists), so this only
+ // enqueues once and is a no-op thereafter.
+ if BackstopScheduleChecked then
+ exit;
+ BackstopScheduleChecked := true;
+ if TokenRefresh.ScheduleRefreshJob() then;
+ end;
+
///
/// Create Shopify Log Entry.
///
diff --git a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyInstaller.Codeunit.al b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyInstaller.Codeunit.al
index ca5b05eac8..26f8fa4a86 100644
--- a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyInstaller.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyInstaller.Codeunit.al
@@ -21,14 +21,6 @@ codeunit 30273 "Shpfy Installer"
begin
AddRetentionPolicyAllowedTables();
AddShopifyCueSetup();
- ScheduleTokenRefreshJob();
- end;
-
- local procedure ScheduleTokenRefreshJob()
- var
- TokenRefresh: Codeunit "Shpfy Token Refresh";
- begin
- TokenRefresh.ScheduleRefreshJob();
end;
procedure AddRetentionPolicyAllowedTables()
diff --git a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyUpgradeMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyUpgradeMgt.Codeunit.al
index 90088dd54a..39d6de5f96 100644
--- a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyUpgradeMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyUpgradeMgt.Codeunit.al
@@ -45,7 +45,6 @@ codeunit 30106 "Shpfy Upgrade Mgt."
OrderTransactionShopCodeUpgrade();
HasAdvancedShopifyPlanUpgrade();
ItalianSardinianProvinceRenameUpgrade();
- ScheduleTokenRefreshJobUpgrade();
end;
internal procedure UpgradeTemplatesData()
@@ -536,20 +535,6 @@ codeunit 30106 "Shpfy Upgrade Mgt."
UpgradeTag.SetUpgradeTag(GetHasAdvancedShopifyPlanUpgradeTag());
end;
- local procedure ScheduleTokenRefreshJobUpgrade()
- var
- UpgradeTag: Codeunit "Upgrade Tag";
- TokenRefresh: Codeunit "Shpfy Token Refresh";
- begin
- if UpgradeTag.HasUpgradeTag(GetScheduleTokenRefreshJobUpgradeTag()) then
- exit;
-
- // Only record the upgrade tag once the job is actually scheduled; otherwise a transient
- // enqueue failure would permanently suppress the backstop on later upgrade runs.
- if TokenRefresh.ScheduleRefreshJob() then
- UpgradeTag.SetUpgradeTag(GetScheduleTokenRefreshJobUpgradeTag());
- end;
-
internal procedure GetAllowOutgoingRequestseUpgradeTag(): Code[250]
begin
exit('MS-445989-AllowOutgoingRequestseUpgradeTag-20220816');
@@ -630,11 +615,6 @@ codeunit 30106 "Shpfy Upgrade Mgt."
exit('MS-630316-HasAdvancedShopifyPlanUpgrade-20260408');
end;
- local procedure GetScheduleTokenRefreshJobUpgradeTag(): Code[250]
- begin
- exit('MS-637954-ScheduleTokenRefreshJob-20260711');
- end;
-
local procedure ItalianSardinianProvinceRenameUpgrade()
var
UpgradeTag: Codeunit "Upgrade Tag";
@@ -694,6 +674,5 @@ codeunit 30106 "Shpfy Upgrade Mgt."
PerCompanyUpgradeTags.Add(GetOrderTransactionShopCodeUpgradeTag());
PerCompanyUpgradeTags.Add(GetHasAdvancedShopifyPlanUpgradeTag());
PerCompanyUpgradeTags.Add(GetItalianSardinianProvinceRenameUpgradeTag());
- PerCompanyUpgradeTags.Add(GetScheduleTokenRefreshJobUpgradeTag());
end;
}
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
index 3390b8fe6f..1fba0dfc3c 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
@@ -69,9 +69,10 @@ codeunit 30431 "Shpfy Token Refresh"
///
/// Creates the recurring Job Queue Entry that runs this codeunit, unless one already exists.
- /// Returns true when the job exists after the call (already present or successfully enqueued),
- /// false when enqueuing failed - so an upgrade caller only records its upgrade tag on success
- /// and retries on a later run instead of permanently suppressing the backstop.
+ /// Called lazily from the connector's runtime path (before the first API call in a session), not
+ /// from install/upgrade: enqueuing a Job Queue Entry implicitly commits, which is not allowed in
+ /// install/upgrade triggers. Returns true when the job exists after the call (already present or
+ /// successfully enqueued), false when enqueuing failed.
///
internal procedure ScheduleRefreshJob(): Boolean
var
@@ -98,9 +99,8 @@ codeunit 30431 "Shpfy Token Refresh"
JobQueueEntry."No. of Attempts to Run" := 5;
JobQueueEntry.Description := CopyStr(JobDescriptionTxt, 1, MaxStrLen(JobQueueEntry.Description));
JobQueueEntry."Job Queue Category Code" := JobQueueCategoryLbl;
- // This runs from the install/upgrade flow, where COMMIT (explicit or implicit) is not allowed.
- // "Job Queue - Enqueue" performs no implicit commit, so no prior commit is needed; the guarded
- // Codeunit.Run keeps an enqueue failure from aborting the install/upgrade.
+ // An enqueue failure must not surface to the caller's API operation; it is logged and retried
+ // on a later call (the once-per-session guard resets each session).
if Codeunit.Run(Codeunit::"Job Queue - Enqueue", JobQueueEntry) then
exit(true);
LogScheduleFailure(GetLastErrorText());
From fec3d5df01cc74a8d9b07817e98e089b1f726ab8 Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 20 Jul 2026 15:14:53 +0200
Subject: [PATCH 27/32] [Shopify] Guard token encryption with EncryptionEnabled
IsolatedStorage.SetEncrypted throws "An encryption key is required" when no
encryption key is configured (e.g. on-prem). Guard both SetAccessToken and
SetRefreshToken with EncryptionEnabled(), falling back to IsolatedStorage.Set
otherwise - the same pattern the base app OAuth token storage uses. On SaaS
(where encryption is always on) tokens remain encrypted at rest.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../Tables/ShpfyRegisteredStoreNew.Table.al | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al b/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al
index 9f1318472a..a1b4ec19c1 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al
@@ -72,7 +72,12 @@ table 30138 "Shpfy Registered Store New"
internal procedure SetAccessToken(AccessToken: SecretText)
begin
- IsolatedStorage.SetEncrypted('AccessToken(' + Rec.SystemId + ')', AccessToken, DataScope::Module);
+ // Encrypt at rest when encryption is configured; fall back to unencrypted storage otherwise
+ // (e.g. on-prem without an encryption key), matching the base app OAuth token pattern.
+ if EncryptionEnabled() then
+ IsolatedStorage.SetEncrypted('AccessToken(' + Rec.SystemId + ')', AccessToken, DataScope::Module)
+ else
+ IsolatedStorage.Set('AccessToken(' + Rec.SystemId + ')', AccessToken, DataScope::Module);
end;
internal procedure GetAccessToken() Result: SecretText
@@ -82,7 +87,10 @@ table 30138 "Shpfy Registered Store New"
internal procedure SetRefreshToken(RefreshToken: SecretText)
begin
- IsolatedStorage.SetEncrypted('RefreshToken(' + Rec.SystemId + ')', RefreshToken, DataScope::Module);
+ if EncryptionEnabled() then
+ IsolatedStorage.SetEncrypted('RefreshToken(' + Rec.SystemId + ')', RefreshToken, DataScope::Module)
+ else
+ IsolatedStorage.Set('RefreshToken(' + Rec.SystemId + ')', RefreshToken, DataScope::Module);
end;
internal procedure GetRefreshToken() Result: SecretText
From d9ef328ba7039232a87e9decc6364bf3fe426424 Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 20 Jul 2026 15:46:25 +0200
Subject: [PATCH 28/32] [Shopify] Schedule backstop after request commit, not
inside caller transaction
EnsureBackstopScheduled ran from GetAccessToken at the start of ExecuteWebRequest,
so its Codeunit.Run("Job Queue - Enqueue") could execute while the caller still had
an open write transaction (nesting / implicit-commit hazard). Move the once-per-
session scheduling to the end of ExecuteWebRequest, right after the request's own
Commit(), where no write transaction is open. The flag is now only set once the
scheduling actually runs, so an early-erroring first request retries on the next.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../Codeunits/ShpfyCommunicationMgt.Codeunit.al | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al
index 33765f9477..ecbecaa3a7 100644
--- a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al
@@ -257,6 +257,9 @@ codeunit 30103 "Shpfy Communication Mgt."
ResponseHeaders := HttpResponseMessage.Headers();
LogShopifyRequest(Url, Method, Request, HttpResponseMessage, Response, RetryCounter);
Commit();
+ // Schedule the recurring backstop after the request's own commit, when no write transaction
+ // is open, so enqueuing (which runs Codeunit.Run) never nests in or commits a caller transaction.
+ EnsureBackstopScheduled();
end;
[NonDebuggable]
@@ -384,7 +387,6 @@ codeunit 30103 "Shpfy Communication Mgt."
NoAccessTokenErr: label 'No Access token for the store "%1".\Please request an access token for this store.', Comment = '%1 = Store';
ChangedScopeErr: Label 'The application scope is changed, please request a new access token for the store "%1".', Comment = '%1 = Store';
begin
- EnsureBackstopScheduled();
AuthenticationMgt.EnsureValidAccessToken(Store);
if RegisteredStoreNew.Get(Store) then
if RegisteredStoreNew."Requested Scope" = AuthenticationMgt.GetScope() then begin
@@ -402,11 +404,11 @@ codeunit 30103 "Shpfy Communication Mgt."
var
TokenRefresh: Codeunit "Shpfy Token Refresh";
begin
- // Schedule the recurring token-refresh backstop lazily, the first time the connector makes an
- // API call in this session. It cannot be scheduled from the install/upgrade codeunits because
- // enqueuing a Job Queue Entry implicitly commits, which is not allowed in those triggers.
- // ScheduleRefreshJob is idempotent (it exits when the job already exists), so this only
- // enqueues once and is a no-op thereafter.
+ // Schedule the recurring token-refresh backstop lazily, once per session, and only from the
+ // end of ExecuteWebRequest (right after its Commit) so no caller write transaction is open:
+ // enqueuing runs Codeunit.Run("Job Queue - Enqueue"), which must not nest in or implicitly
+ // commit a caller's transaction. It cannot be scheduled from install/upgrade for the same
+ // reason. ScheduleRefreshJob is idempotent (exits when the job already exists).
if BackstopScheduleChecked then
exit;
BackstopScheduleChecked := true;
From e47203f5b2b4889bd7966bb1c121f60519d68b41 Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 20 Jul 2026 19:43:16 +0200
Subject: [PATCH 29/32] [Shopify] Schedule token-refresh backstop from Shop
Card open, not the API path
Move the once-only backstop scheduling out of ShpfyCommunicationMgt entirely and
into ShpfyShopCard.OnOpenPage. The API request path no longer runs any job-queue
query or Codeunit.Run enqueue - scheduling now happens in the page/UI context when
an enabled Shop Card is opened. ScheduleRefreshJob stays idempotent (exits when the
job already exists). Removes the per-session EnsureBackstopScheduled hook and its
flag from the SingleInstance communication codeunit.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../ShpfyCommunicationMgt.Codeunit.al | 19 -------------------
.../App/src/Base/Pages/ShpfyShopCard.Page.al | 6 ++++++
2 files changed, 6 insertions(+), 19 deletions(-)
diff --git a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al
index ecbecaa3a7..63c3ee520c 100644
--- a/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al
@@ -21,7 +21,6 @@ codeunit 30103 "Shpfy Communication Mgt."
Shop: Record "Shpfy Shop";
GraphQLQueries: Codeunit "Shpfy GraphQL Queries";
NextExecutionTime: DateTime;
- BackstopScheduleChecked: Boolean;
VersionTok: Label '2026-07', Locked = true;
OutgoingRequestsNotEnabledConfirmLbl: Label 'Importing data to your Shopify shop is not enabled, do you want to go to shop card to enable?';
OutgoingRequestsNotEnabledErr: Label 'Importing data to your Shopify shop is not enabled, navigate to shop card to enable.';
@@ -257,9 +256,6 @@ codeunit 30103 "Shpfy Communication Mgt."
ResponseHeaders := HttpResponseMessage.Headers();
LogShopifyRequest(Url, Method, Request, HttpResponseMessage, Response, RetryCounter);
Commit();
- // Schedule the recurring backstop after the request's own commit, when no write transaction
- // is open, so enqueuing (which runs Codeunit.Run) never nests in or commits a caller transaction.
- EnsureBackstopScheduled();
end;
[NonDebuggable]
@@ -400,21 +396,6 @@ codeunit 30103 "Shpfy Communication Mgt."
Error(NoAccessTokenErr, Store);
end;
- local procedure EnsureBackstopScheduled()
- var
- TokenRefresh: Codeunit "Shpfy Token Refresh";
- begin
- // Schedule the recurring token-refresh backstop lazily, once per session, and only from the
- // end of ExecuteWebRequest (right after its Commit) so no caller write transaction is open:
- // enqueuing runs Codeunit.Run("Job Queue - Enqueue"), which must not nest in or implicitly
- // commit a caller's transaction. It cannot be scheduled from install/upgrade for the same
- // reason. ScheduleRefreshJob is idempotent (exits when the job already exists).
- if BackstopScheduleChecked then
- exit;
- BackstopScheduleChecked := true;
- if TokenRefresh.ScheduleRefreshJob() then;
- end;
-
///
/// Create Shopify Log Entry.
///
diff --git a/src/Apps/W1/Shopify/App/src/Base/Pages/ShpfyShopCard.Page.al b/src/Apps/W1/Shopify/App/src/Base/Pages/ShpfyShopCard.Page.al
index 71370a1ad7..1b580f395d 100644
--- a/src/Apps/W1/Shopify/App/src/Base/Pages/ShpfyShopCard.Page.al
+++ b/src/Apps/W1/Shopify/App/src/Base/Pages/ShpfyShopCard.Page.al
@@ -1237,6 +1237,7 @@ page 30101 "Shpfy Shop Card"
AuthenticationMgt: Codeunit "Shpfy Authentication Mgt.";
CommunicationMgt: Codeunit "Shpfy Communication Mgt.";
ShopReview: Codeunit "Shpfy Shop Review";
+ TokenRefresh: Codeunit "Shpfy Token Refresh";
RefreshTokenExpiredNotification: Notification;
ApiVersionExpiryDateTime: DateTime;
begin
@@ -1247,6 +1248,11 @@ page 30101 "Shpfy Shop Card"
ApiVersionExpiryDate := DT2Date(ApiVersionExpiryDateTime);
Rec.CheckApiVersionExpiryDate(ApiVersion, ApiVersionExpiryDateTime);
+ // Ensure the recurring token-refresh backstop exists (idempotent). Scheduled from page
+ // open rather than the API request path so enqueuing never runs inside a caller's
+ // business transaction.
+ if TokenRefresh.ScheduleRefreshJob() then;
+
if AuthenticationMgt.CheckScopeChange(Rec) then
if Confirm(StrSubstNo(ScopeChangeConfirmLbl, Rec.Code)) then begin
Rec.RequestAccessToken();
From bacd351bf2c0cdb801433cb4508be94b4953cc9b Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 20 Jul 2026 19:47:25 +0200
Subject: [PATCH 30/32] [Shopify] Schedule backstop once per company via
upgrade-tag marker
Scheduling from the Shop Card open would recreate the Job Queue Entry every time
the page opened, so an administrator could not permanently remove it. Add
EnsureBackstopScheduled, which schedules the job at most once per company using an
upgrade tag as a persistent marker: once scheduled, the tag is set and the job is
never silently recreated - if an admin deletes it, it stays deleted. The tag is set
only on a successful enqueue (a transient failure retries on a later open). The tag
is intentionally NOT registered in OnGetPerCompanyUpgradeTags so newly created
companies still schedule on first Shop Card open.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../App/src/Base/Pages/ShpfyShopCard.Page.al | 7 ++--
.../Codeunits/ShpfyTokenRefresh.Codeunit.al | 34 +++++++++++++++----
2 files changed, 31 insertions(+), 10 deletions(-)
diff --git a/src/Apps/W1/Shopify/App/src/Base/Pages/ShpfyShopCard.Page.al b/src/Apps/W1/Shopify/App/src/Base/Pages/ShpfyShopCard.Page.al
index 1b580f395d..e632fdf460 100644
--- a/src/Apps/W1/Shopify/App/src/Base/Pages/ShpfyShopCard.Page.al
+++ b/src/Apps/W1/Shopify/App/src/Base/Pages/ShpfyShopCard.Page.al
@@ -1248,10 +1248,9 @@ page 30101 "Shpfy Shop Card"
ApiVersionExpiryDate := DT2Date(ApiVersionExpiryDateTime);
Rec.CheckApiVersionExpiryDate(ApiVersion, ApiVersionExpiryDateTime);
- // Ensure the recurring token-refresh backstop exists (idempotent). Scheduled from page
- // open rather than the API request path so enqueuing never runs inside a caller's
- // business transaction.
- if TokenRefresh.ScheduleRefreshJob() then;
+ // Ensure the recurring token-refresh backstop exists. Scheduled once per company (guarded
+ // by an upgrade tag) so an administrator can delete the job without it being recreated.
+ TokenRefresh.EnsureBackstopScheduled();
if AuthenticationMgt.CheckScopeChange(Rec) then
if Confirm(StrSubstNo(ScopeChangeConfirmLbl, Rec.Code)) then begin
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
index 1fba0dfc3c..d640f8a391 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
@@ -6,6 +6,7 @@
namespace Microsoft.Integration.Shopify;
using System.Threading;
+using System.Upgrade;
///
/// Codeunit Shpfy Token Refresh (ID 30431).
@@ -67,12 +68,34 @@ codeunit 30431 "Shpfy Token Refresh"
Session.LogMessage('0000UJ6', ScheduleFailedTxt, Verbosity::Warning, DataClassification::CustomerContent, TelemetryScope::ExtensionPublisher, Dimensions);
end;
+ ///
+ /// Ensures the recurring backstop job is scheduled at most once per company. An upgrade tag is
+ /// used as a persistent marker so that, once scheduled, the job is never silently re-created -
+ /// if an administrator deletes the Job Queue Entry, it stays deleted. Safe to call repeatedly
+ /// (e.g. from the Shop Card open) since it becomes a no-op after the first successful schedule.
+ ///
+ internal procedure EnsureBackstopScheduled()
+ var
+ UpgradeTag: Codeunit "Upgrade Tag";
+ begin
+ if UpgradeTag.HasUpgradeTag(GetScheduledUpgradeTag()) then
+ exit;
+ // Only record the tag once the job is actually scheduled, so a transient enqueue failure is
+ // retried on a later call rather than permanently suppressing the backstop.
+ if ScheduleRefreshJob() then
+ UpgradeTag.SetUpgradeTag(GetScheduledUpgradeTag());
+ end;
+
+ local procedure GetScheduledUpgradeTag(): Code[250]
+ begin
+ exit('MS-637954-ScheduleTokenRefreshJob-20260711');
+ end;
+
///
/// Creates the recurring Job Queue Entry that runs this codeunit, unless one already exists.
- /// Called lazily from the connector's runtime path (before the first API call in a session), not
- /// from install/upgrade: enqueuing a Job Queue Entry implicitly commits, which is not allowed in
- /// install/upgrade triggers. Returns true when the job exists after the call (already present or
- /// successfully enqueued), false when enqueuing failed.
+ /// Not called from install/upgrade triggers: enqueuing a Job Queue Entry implicitly commits,
+ /// which is not allowed there. Returns true when the job exists after the call (already present
+ /// or successfully enqueued), false when enqueuing failed.
///
internal procedure ScheduleRefreshJob(): Boolean
var
@@ -99,8 +122,7 @@ codeunit 30431 "Shpfy Token Refresh"
JobQueueEntry."No. of Attempts to Run" := 5;
JobQueueEntry.Description := CopyStr(JobDescriptionTxt, 1, MaxStrLen(JobQueueEntry.Description));
JobQueueEntry."Job Queue Category Code" := JobQueueCategoryLbl;
- // An enqueue failure must not surface to the caller's API operation; it is logged and retried
- // on a later call (the once-per-session guard resets each session).
+ // An enqueue failure must not surface to the caller; it is logged and retried on a later call.
if Codeunit.Run(Codeunit::"Job Queue - Enqueue", JobQueueEntry) then
exit(true);
LogScheduleFailure(GetLastErrorText());
From 6a8ffab180af1e40e0f39660359a259e92e1da8b Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 20 Jul 2026 20:21:07 +0200
Subject: [PATCH 31/32] [Shopify] Simplify backstop scheduling and trim
comments
- Drop the upgrade-tag "scheduled once" marker; ScheduleRefreshJob keeps only the
existing-entry check, so new shops are always covered and an admin can disable the
backstop by setting the Job Queue Entry On Hold. Make it a plain procedure (the
Boolean return was only needed by the removed tag caller) and call it directly
from the Shop Card.
- Trim verbose explanatory comments in the token codeunits to concise notes.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
.../App/src/Base/Pages/ShpfyShopCard.Page.al | 7 ++--
.../ShpfyAuthenticationMgt.Codeunit.al | 39 ++++-------------
.../Codeunits/ShpfyTokenRefresh.Codeunit.al | 42 ++++---------------
3 files changed, 20 insertions(+), 68 deletions(-)
diff --git a/src/Apps/W1/Shopify/App/src/Base/Pages/ShpfyShopCard.Page.al b/src/Apps/W1/Shopify/App/src/Base/Pages/ShpfyShopCard.Page.al
index e632fdf460..5a76f31345 100644
--- a/src/Apps/W1/Shopify/App/src/Base/Pages/ShpfyShopCard.Page.al
+++ b/src/Apps/W1/Shopify/App/src/Base/Pages/ShpfyShopCard.Page.al
@@ -1248,9 +1248,10 @@ page 30101 "Shpfy Shop Card"
ApiVersionExpiryDate := DT2Date(ApiVersionExpiryDateTime);
Rec.CheckApiVersionExpiryDate(ApiVersion, ApiVersionExpiryDateTime);
- // Ensure the recurring token-refresh backstop exists. Scheduled once per company (guarded
- // by an upgrade tag) so an administrator can delete the job without it being recreated.
- TokenRefresh.EnsureBackstopScheduled();
+ // Ensure the recurring token-refresh backstop exists. Scheduled here (not the API path or
+ // install/upgrade) so enqueuing never runs inside a caller's business transaction; an admin
+ // can disable it by setting the Job Queue Entry On Hold.
+ TokenRefresh.ScheduleRefreshJob();
if AuthenticationMgt.CheckScopeChange(Rec) then
if Confirm(StrSubstNo(ScopeChangeConfirmLbl, Rec.Code)) then begin
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
index 028a9e79f9..ee912f72d3 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al
@@ -250,20 +250,10 @@ codeunit 30199 "Shpfy Authentication Mgt."
end;
///
- /// Ensures the store has a valid, non-expired offline access token before it is used.
- /// Legacy non-expiring tokens are migrated to expiring tokens on first use, and expiring
- /// tokens are refreshed when they are close to expiry.
- ///
- /// Concurrency: a cheap check runs outside any lock; only when a refresh or migration is
- /// actually needed does the code take a table lock and re-read the row (double-checked
- /// locking). Concurrent sessions in the same company therefore serialize on the store row and,
- /// on the second pass, find the token already fresh and return without a second HTTP call. The
- /// lock is intentionally held across the token request to Shopify (a single round-trip on the
- /// happy path; short backoff retries only on transient errors) so parallel sessions cannot
- /// retire each other's refresh token - a deliberate trade-off of brief blocking for
- /// correctness. Note the lock is per-company: two BC companies connected to the same shop keep
- /// separate rows and cannot block each other, so cross-company safety relies on Shopify
- /// returning the same tokens for ~1 hour after a rotation rather than on this lock.
+ /// Ensures the store has a valid, non-expired offline access token before use: legacy
+ /// non-expiring tokens are migrated on first use, expiring tokens refreshed near expiry.
+ /// Uses double-checked locking; the row lock is held across the token request so concurrent
+ /// sessions cannot retire each other's refresh token. The lock is per-company.
///
/// The store URL.
internal procedure EnsureValidAccessToken(Store: Text)
@@ -295,13 +285,8 @@ codeunit 30199 "Shpfy Authentication Mgt."
end;
///
- /// Forces a token refresh (or migration) regardless of the remaining lifetime. Used when an
- /// API call unexpectedly returns 401, indicating the current access token is no longer valid.
- /// A short cooldown prevents re-refreshing on every request when a store returns 401 for a
- /// reason other than token expiry (e.g. revoked access), which would otherwise hammer the
- /// refresh endpoint and serialize sessions once per record during a bulk sync.
- /// Returns true if a refresh/migration was attempted (so the caller can retry the request),
- /// false if it was skipped by the cooldown.
+ /// Forces a token refresh (or migration) on an unexpected 401, throttled to at most once per
+ /// minute per store. Returns true if a refresh was attempted (caller may retry), false if throttled.
///
/// The store URL.
internal procedure ForceTokenRefresh(Store: Text): Boolean
@@ -323,10 +308,7 @@ codeunit 30199 "Shpfy Authentication Mgt."
RegisteredStoreNew."Last Force Refresh At" := CurrentDateTime();
RegisteredStoreNew.Modify();
- // Persist the cooldown BEFORE attempting the refresh: RefreshAccessToken raises the reconnect
- // error on a terminal failure, which would otherwise roll back this Modify and let every
- // subsequent 401 in a bulk sync re-enter and re-hit the refresh endpoint. Committing here
- // keeps the throttle effective on the failing path.
+ // Persist the cooldown before the refresh so a terminal-failure rollback can't discard it.
Commit();
if RegisteredStoreNew.HasRefreshToken() then
@@ -447,11 +429,8 @@ codeunit 30199 "Shpfy Authentication Mgt."
Sleep(1000 * Attempt);
end;
- // Transient failures exhausted. The refresh token is still valid (it passed the expiry
- // check above and we never hit a 401), so this is a temporary Shopify/network problem, not
- // a reconnect situation. If the current access token is already expired the store cannot
- // make calls right now, so surface a non-actionable "try again later" error; otherwise keep
- // the still-valid token and let the caller proceed.
+ // Transient failures only (refresh token still valid, no 401): temporary Shopify/network
+ // problem, not a reconnect. Error only if the current token is already expired.
if TokenExpired(RegisteredStoreNew) then
Error(TokenRefreshTransientErr, Store);
Session.LogMessage('0000UJ1', TokenRefreshTransientTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
diff --git a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
index d640f8a391..792fa89063 100644
--- a/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
@@ -6,7 +6,6 @@
namespace Microsoft.Integration.Shopify;
using System.Threading;
-using System.Upgrade;
///
/// Codeunit Shpfy Token Refresh (ID 30431).
@@ -69,42 +68,18 @@ codeunit 30431 "Shpfy Token Refresh"
end;
///
- /// Ensures the recurring backstop job is scheduled at most once per company. An upgrade tag is
- /// used as a persistent marker so that, once scheduled, the job is never silently re-created -
- /// if an administrator deletes the Job Queue Entry, it stays deleted. Safe to call repeatedly
- /// (e.g. from the Shop Card open) since it becomes a no-op after the first successful schedule.
+ /// Creates the recurring backstop Job Queue Entry unless one already exists. Called from the
+ /// Shop Card, not install/upgrade (where enqueuing, which implicitly commits, is disallowed).
+ /// The existing-entry check lets an admin disable the backstop by setting the entry On Hold.
///
- internal procedure EnsureBackstopScheduled()
- var
- UpgradeTag: Codeunit "Upgrade Tag";
- begin
- if UpgradeTag.HasUpgradeTag(GetScheduledUpgradeTag()) then
- exit;
- // Only record the tag once the job is actually scheduled, so a transient enqueue failure is
- // retried on a later call rather than permanently suppressing the backstop.
- if ScheduleRefreshJob() then
- UpgradeTag.SetUpgradeTag(GetScheduledUpgradeTag());
- end;
-
- local procedure GetScheduledUpgradeTag(): Code[250]
- begin
- exit('MS-637954-ScheduleTokenRefreshJob-20260711');
- end;
-
- ///
- /// Creates the recurring Job Queue Entry that runs this codeunit, unless one already exists.
- /// Not called from install/upgrade triggers: enqueuing a Job Queue Entry implicitly commits,
- /// which is not allowed there. Returns true when the job exists after the call (already present
- /// or successfully enqueued), false when enqueuing failed.
- ///
- internal procedure ScheduleRefreshJob(): Boolean
+ internal procedure ScheduleRefreshJob()
var
JobQueueEntry: Record "Job Queue Entry";
begin
JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit);
JobQueueEntry.SetRange("Object ID to Run", Codeunit::"Shpfy Token Refresh");
if not JobQueueEntry.IsEmpty() then
- exit(true);
+ exit;
Clear(JobQueueEntry);
JobQueueEntry.Init();
@@ -122,10 +97,7 @@ codeunit 30431 "Shpfy Token Refresh"
JobQueueEntry."No. of Attempts to Run" := 5;
JobQueueEntry.Description := CopyStr(JobDescriptionTxt, 1, MaxStrLen(JobQueueEntry.Description));
JobQueueEntry."Job Queue Category Code" := JobQueueCategoryLbl;
- // An enqueue failure must not surface to the caller; it is logged and retried on a later call.
- if Codeunit.Run(Codeunit::"Job Queue - Enqueue", JobQueueEntry) then
- exit(true);
- LogScheduleFailure(GetLastErrorText());
- exit(false);
+ if not Codeunit.Run(Codeunit::"Job Queue - Enqueue", JobQueueEntry) then
+ LogScheduleFailure(GetLastErrorText());
end;
}
From b8aee1968d00c7e546e54928f08b5d881e7ad795 Mon Sep 17 00:00:00 2001
From: Onat Buyukakkus <55088871+onbuyuka@users.noreply.github.com>
Date: Mon, 20 Jul 2026 22:07:31 +0200
Subject: [PATCH 32/32] [Shopify] Correct backstop scheduling description in
docs
The Shpfy Token Refresh job is scheduled when an enabled Shopify Shop Card is
opened, not on install/upgrade; update business-logic.md to match.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fe0c18c-7f03-4f1f-880f-9adb9eb1dfcc
---
src/Apps/W1/Shopify/App/docs/business-logic.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Apps/W1/Shopify/App/docs/business-logic.md b/src/Apps/W1/Shopify/App/docs/business-logic.md
index 90731daaa6..d69ac4d82a 100644
--- a/src/Apps/W1/Shopify/App/docs/business-logic.md
+++ b/src/Apps/W1/Shopify/App/docs/business-logic.md
@@ -14,7 +14,7 @@ The connector authenticates to Shopify with **expiring offline access tokens** (
Refresh and migration are serialized with a table lock plus a double-checked re-read, because Shopify allows only one refreshable token per app and store -- this keeps concurrent sessions (or multiple BC companies sharing a shop) from thrashing the token. As a safety net, an unexpected 401 from a normal API call triggers a single forced refresh and retry.
-The `Shpfy Token Refresh` job (a recurring Job Queue entry registered on install and upgrade) is the proactive backstop. It iterates enabled shops and runs a per-shop worker so one shop's failure does not abort the run, keeping access tokens and 90-day refresh tokens alive for shops that are otherwise idle. When a refresh token has fully lapsed, the Shop Card shows a reconnect notification.
+The `Shpfy Token Refresh` job (a recurring Job Queue entry scheduled the first time an enabled Shopify Shop Card is opened) is the proactive backstop. It iterates enabled shops and runs a per-shop worker so one shop's failure does not abort the run, keeping access tokens and 90-day refresh tokens alive for shops that are otherwise idle. When a refresh token has fully lapsed, the Shop Card shows a reconnect notification.
*Updated: 2026-07-11 -- Expiring offline access token support (slice 637954)*