Read ERP order history through a bounded account-aware adapter
Read a limited ERP order-history page for an authorized account, validate every returned row, and expose a small named order summary.
In this guide
- Authenticated actor
- Authorized account selection
- Bounded history API
- Project adapter
- ERP query
- Safe order summaries
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
Place HistoryReader.cs in the Znode 9 service library. AuthorizedContext carries ActorId, StoreId, and AccountId after server authorization. HistoryQuery carries the date interval, page, page size, and sort choice. The gateway returns ErpOrder rows, and the reader exposes only OrderSummary.Reference and PlacedAt.
The example allows a positive date interval of up to 90 days, pages 1 through 1000, and at most 50 rows per page. From is inclusive and Until is exclusive. These are application limits rather than universal ERP or Znode limits.
Implement IErpHistoryGateway with the ERP's supported filtering and paging. Apply Store, account, and date filters in that request, use a stable date-plus-reference sort, and cap response bytes. Honor the supplied cancellation token in the transport. The reader requests cancellation after three seconds, then verifies returned scope, dates, count, and unique references.
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();
}
}
}
}
For From September 1 at 00:00 UTC and Until September 2 at 00:00 UTC, an order placed at the first timestamp is included and one placed exactly at the second is rejected. A row for a different account also rejects the page. A successful empty page returns an empty summary list; a failed ERP request remains an error rather than becoming 'no orders'.
Handle absence and ambiguity
Treat no matching orders and a failed history source as different outcomes. The helper returns summaries only for a valid bounded page. If the ERP reports partial results, the gateway must represent that condition through a deliberate error or a separately designed partial-result contract, not silently return a complete-looking list.
Repeat authorization for any order-detail request and keep cached history scoped to the allowed account and Store. Use a support-safe correlation reference for diagnostics instead of logging response bodies or account activity.
- An authorized account read and a request for another account.
- An empty page, invalid date range, and an oversized result.
- ERP cancellation, timeout, and incomplete response.
- A changed order status between list and detail reads.
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?
Independent guidance from GCG. Znode is a trademark of its owner. Examples use fictional data and are not official platform documentation. Suggest a correction.