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..d69ac4d82a 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. 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.
+
+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)*
+
## 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..b95002766b 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`, 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)*
+
```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/Codeunits/ShpfyCommunicationMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Base/Codeunits/ShpfyCommunicationMgt.Codeunit.al
index 84a0070bc3..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
@@ -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,9 +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);
+ 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);
@@ -240,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;
@@ -374,6 +383,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 +538,21 @@ 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);
+ exit(AuthenticationMgt.ForceTokenRefresh(Store));
+ end;
+
///
/// Description for SetShop.
///
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..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
@@ -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,8 @@ 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
FeatureTelemetry.LogUptake('0000HUU', 'Shopify', Enum::"Feature Uptake Status"::Discovered);
@@ -1244,6 +1248,11 @@ page 30101 "Shpfy Shop Card"
ApiVersionExpiryDate := DT2Date(ApiVersionExpiryDateTime);
Rec.CheckApiVersionExpiryDate(ApiVersion, ApiVersionExpiryDateTime);
+ // 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
Rec.RequestAccessToken();
@@ -1257,6 +1266,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/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.
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..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
@@ -31,6 +31,21 @@ 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';
+ 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;
+ 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;
+ 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
@@ -110,25 +125,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
+ Error(TokenExchangeFailedErr, Store);
+ if not ResponseHasAccessToken(ResponseBody) then
+ Error(TokenExchangeFailedErr, Store);
+
+ 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 +176,15 @@ 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)
+ [NonDebuggable]
+ local procedure SaveInstalledToken(Store: Text; ResponseBody: Text)
var
RegisteredStoreNew: Record "Shpfy Registered Store New";
begin
@@ -166,9 +195,292 @@ 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 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)
+ var
+ RegisteredStoreNew: Record "Shpfy Registered Store New";
+ begin
+ Store := Store.ToLower();
+ if not RegisteredStoreNew.Get(Store) 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
+ exit;
+
+ if RegisteredStoreNew.HasRefreshToken() then begin
+ if TokenNeedsRefresh(RegisteredStoreNew) then
+ RefreshAccessToken(Store, RegisteredStoreNew);
+ end else
+ TryMigrate(Store, RegisteredStoreNew);
+
+ Commit();
+ end;
+
+ ///
+ /// 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
+ var
+ RegisteredStoreNew: Record "Shpfy Registered Store New";
+ begin
+ Store := Store.ToLower();
+ if not RegisteredStoreNew.Get(Store) then
+ exit(false);
+
+ if not ShouldForceRefresh(RegisteredStoreNew) then
+ exit(false);
+
+ RegisteredStoreNew.LockTable();
+ if not RegisteredStoreNew.Get(Store) then
+ exit(false);
+ if not ShouldForceRefresh(RegisteredStoreNew) then
+ exit(false);
+
+ RegisteredStoreNew."Last Force Refresh At" := CurrentDateTime();
+ RegisteredStoreNew.Modify();
+ // Persist the cooldown before the refresh so a terminal-failure rollback can't discard it.
+ Commit();
+
+ if RegisteredStoreNew.HasRefreshToken() then
+ RefreshAccessToken(Store, RegisteredStoreNew)
+ else
+ 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")
+ 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
+ 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);
+ Session.LogMessage('0000UIW', TokenMigratedTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
+ end else
+ Session.LogMessage('0000UIX', TokenMigrationFailedTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
+ 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
+ Session.LogMessage('0000UIY', TokenRefreshExpiredTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
+ Error(CreateReconnectErrorInfo(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);
+ 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('0000UJ0', TokenRefreshExpiredTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
+ Error(CreateReconnectErrorInfo(Store));
+ end;
+
+ Sleep(1000 * Attempt);
+ end;
+
+ // 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);
+ 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;
+
+ [NonDebuggable]
+ 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;
[NonDebuggable]
@@ -181,6 +493,68 @@ 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;
+
+ local procedure CreateReconnectErrorInfo(Store: Text) ReconnectError: ErrorInfo
+ begin
+ 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;
+
+ 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;
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..792fa89063
--- /dev/null
+++ b/src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyTokenRefresh.Codeunit.al
@@ -0,0 +1,103 @@
+// ------------------------------------------------------------------------------------------------
+// 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;
+
+ 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;
+ JobQueueCategoryLbl: Label 'SHPFYAUTH', Locked = true;
+ JobDescriptionTxt: Label 'Shopify: refresh expiring access tokens';
+
+ local procedure RefreshAllShops()
+ var
+ 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.
+ 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
+ Dimensions: Dictionary of [Text, Text];
+ begin
+ // 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;
+
+ ///
+ /// 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 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;
+
+ 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;
+ if not Codeunit.Run(Codeunit::"Job Queue - Enqueue", JobQueueEntry) then
+ LogScheduleFailure(GetLastErrorText());
+ 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/Tables/ShpfyRegisteredStoreNew.Table.al b/src/Apps/W1/Shopify/App/src/Integration/Tables/ShpfyRegisteredStoreNew.Table.al
index 2339618a1f..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
@@ -41,6 +41,26 @@ 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;
+ }
+ field(8; "Last Migration Attempt"; DateTime)
+ {
+ Caption = 'Last Migration Attempt';
+ DataClassification = SystemMetadata;
+ }
+ field(9; "Last Force Refresh At"; DateTime)
+ {
+ Caption = 'Last Force Refresh At';
+ DataClassification = SystemMetadata;
+ }
}
keys
{
@@ -52,11 +72,34 @@ table 30138 "Shpfy Registered Store New"
internal procedure SetAccessToken(AccessToken: SecretText)
begin
- IsolatedStorage.Set('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
begin
if not IsolatedStorage.Get('AccessToken(' + Rec.SystemId + ')', DataScope::Module, Result) then;
end;
+
+ internal procedure SetRefreshToken(RefreshToken: SecretText)
+ begin
+ 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
+ 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..90878df7c8 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,
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..983a047fd5
--- /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 139627 "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): DateTime
+ var
+ Milliseconds: BigInteger;
+ Offset: Duration;
+ begin
+ Milliseconds := Days;
+ Milliseconds := Milliseconds * 24 * 60 * 60 * 1000;
+ Offset := Milliseconds;
+ exit(CurrentDateTime() + Offset);
+ end;
+}