Read ERP order history through a bounded account-aware adapter

Use a project-owned order-history contract that derives account scope on the server, enforces bounded paging, and keeps ERP wire details outside the storefront.

Znode 9PatternAdvancedGCG engineering guide
In this guide
Authenticated actor Authorized account selection Bounded history API Project adapter ERP query Safe order summaries
A server-authorized account context reaches the ERP only through a bounded adapter that returns a customer-safe summary.

Define the reader's allowed history

Order history looks like a read-only feature, yet it can reveal prices, addresses, products, and account activity across an organization. Begin by naming the signed-in actor, their allowed buying accounts, the selected account, and any store or portal boundary. Derive those facts from server-side authorization. Never accept an account ID in a query parameter as proof that the caller may read it.

Decide whether the history is commerce-native, ERP-native, or a merged view. A merged view needs explicit language about which source is authoritative for status, invoice information, shipment data, and reorder eligibility. A row that is not found in the ERP is not automatically an authorization failure or a deleted commerce order.

  • Actor identity and permitted account set.
  • Selected account validated on every request.
  • Maximum date range and page size.
  • Allowed sort fields and filter operators.

Put a bounded contract in front of the ERP

Create a project-owned request DTO with a cursor or page token, small set of filters, and server-validated page size. Translate it in an adapter that speaks the ERP's wire model. The storefront should never need the ERP's endpoint, credentials, internal IDs, or error format. This seam also lets the team replace an ERP query without changing every browser component.

Return a normalized order summary that contains only information the current reader needs. Use a stable public order reference if one exists, but do not expose an internal ERP key simply because it is convenient. If a follow-up detail view exists, repeat authorization there rather than assuming a row listed earlier remains permitted.

Save this code as HistoryReader.cs in the project-owned service library. The caller constructs AuthorizedContext only after resolving the authenticated actor and authorizing the selected account and store. Never bind that class directly from request JSON. The query uses a half-open date interval, from inclusive to until exclusive, with a maximum span of 90 days, at most 50 rows, and a closed sort enum. These are deliberate sample limits that can be adjusted through an explicit policy.

Implement IErpHistoryGateway with the ERP client's supported filter and paging syntax. Apply account and store filters in the ERP request, order by date plus a stable public-reference tie breaker, cap downloaded response bytes, and honor the supplied cancellation token. The three-second cancellation budget is cooperative, so transport cancellation is part of that adapter contract. The reader validates returned scope, count, dates, and duplicate references before exposing a fresh summary. Scope violations throw rather than silently returning another account's data.

HistoryReader.cs csharp

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Gcg.OrderHistory
{
public sealed class AuthorizedContext
{
public int ActorId { get; private set; }
public int StoreId { get; private set; }
public int AccountId { get; private set; }
// Construct only after server authorization of actor, account and store.
public AuthorizedContext(int actorId, int storeId, int accountId)
{
if (actorId <= 0 || storeId <= 0 || accountId <= 0)
throw new ArgumentOutOfRangeException("Authorized identifiers");
ActorId = actorId; StoreId = storeId; AccountId = accountId;
}
}
public enum HistorySort { NewestFirst, OldestFirst }
public sealed class HistoryQuery
{
public DateTimeOffset From { get; private set; }
public DateTimeOffset Until { get; private set; }
public int Page { get; private set; }
public int PageSize { get; private set; }
public HistorySort Sort { get; private set; }
public HistoryQuery(DateTimeOffset from, DateTimeOffset until,
int page, int pageSize, HistorySort sort)
{
if (until <= from || until - from > TimeSpan.FromDays(90))
throw new ArgumentOutOfRangeException("Use a 1..90 day range");
if (page < 1 || page > 1000 || pageSize < 1 || pageSize > 50)
throw new ArgumentOutOfRangeException("Page bounds");
if (!Enum.IsDefined(typeof(HistorySort), sort))
throw new ArgumentOutOfRangeException("sort");
From = from; Until = until; Page = page; PageSize = pageSize; Sort = sort;
}
}
public sealed class ErpOrder
{
public int StoreId { get; set; }
public int AccountId { get; set; }
public string PublicReference { get; set; }
public DateTimeOffset PlacedAt { get; set; }
}
public sealed class OrderSummary
{
public string Reference { get; private set; }
public DateTimeOffset PlacedAt { get; private set; }
public OrderSummary(string reference, DateTimeOffset placedAt)
{ Reference = reference; PlacedAt = placedAt; }
}
// Project-owned transport seam. Apply account/store/date predicates in the ERP
// request, cap downloaded bytes, and honor cancellation in the HTTP client.
public interface IErpHistoryGateway
{
Task<IReadOnlyList<ErpOrder>> ReadAsync(AuthorizedContext context,
HistoryQuery query, CancellationToken cancellation);
}
public sealed class HistoryReader
{
private readonly IErpHistoryGateway gateway;
public HistoryReader(IErpHistoryGateway gateway)
{ if (gateway == null) throw new ArgumentNullException("gateway"); this.gateway = gateway; }
public async Task<IReadOnlyList<OrderSummary>> ReadAsync(
AuthorizedContext context, HistoryQuery query, CancellationToken cancellation)
{
if (context == null || query == null) throw new ArgumentNullException();
using (var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellation))
{
timeout.CancelAfter(TimeSpan.FromSeconds(3));
var rows = await gateway.ReadAsync(context, query, timeout.Token)
.ConfigureAwait(false);
timeout.Token.ThrowIfCancellationRequested();
if (rows == null || rows.Count > query.PageSize)
throw new InvalidOperationException("Invalid ERP page");
var seen = new HashSet<string>(StringComparer.Ordinal);
var result = new List<OrderSummary>();
foreach (var row in rows)
{
if (row == null || row.AccountId != context.AccountId ||
row.StoreId != context.StoreId || row.PlacedAt < query.From ||
row.PlacedAt >= query.Until ||
String.IsNullOrWhiteSpace(row.PublicReference) ||
row.PublicReference.Length > 80 || !seen.Add(row.PublicReference))
throw new InvalidOperationException("Invalid ERP row scope");
result.Add(new OrderSummary(row.PublicReference, row.PlacedAt));
}
return result.AsReadOnly();
}
}
}
}

Handle absence and ambiguity

External systems can be unavailable, return a partial page, or accept a request and later expose an updated status. Represent those states separately. A response can say that no matching orders were found, that a source is temporarily unavailable, or that the result is incomplete because an approved dependency failed. Do not replace every ERP exception with an empty list.

Use timeouts, response-size limits, and cancellation. Cache only when the cache key includes the reader's allowed account context and the freshness rule is acceptable. Avoid writing account or SKU identifiers into ordinary logs. A sanitized correlation ID is usually enough for support to trace an adapter call.

  • Test a user allowed for one account but requesting another.
  • Test empty result, expired cursor, invalid date range, and oversized page.
  • Test ERP timeout and partial response.
  • Test a status that changes between list and detail read.

Exercise the boundary with fictional pages

Implement a small IErpHistoryGateway fixture that returns a fictional account page, an empty page, and a cancelled request. Assert that changing the returned account or store causes the reader to reject the page. Also cover duplicate references, a row at the exclusive end timestamp, and a result count larger than PageSize.

Znode's account-order documentation describes an account-wide Admin view. Treat that behavior as a separate authorization use case from a shopper's ERP history. Resolve the shopper's allowed account scope explicitly, and exercise the real ERP adapter's filtering, response limits, cancellation, and stable pagination contract before enabling the feature.

Verification checklist

Verify that a shopper cannot enlarge the account scope through filters, cursors, or a direct request. Confirm that pagination is stable enough for the defined business use and that the UI makes source unavailability visible without disclosing details. Re-review access when account hierarchy or impersonation rules change.

References and further reading

Bring your next engineering question.

Need an account-aware integration design?

Explore how GCG can help

Independent guidance from GCG. Znode is a trademark of its owner. Examples use fictional data and are not official platform documentation. Suggest a correction.