Skip to content

Engineering

Designing an audit log for pricing approvals: who approved what, under which rule

By Jahangir Alam · October 14, 2026 · 16 min read

Last verified
Shopify API
2026-10
Audience
Developers and agencies building or reviewing discount and pricing approvals on a Shopify quote layer
Scope
The audit record for pricing approvals beside Shopify at API 2026-10: what Shopify's activity log, timelines and Event objects don't hold, the record field by field (subject version and the whole-quote discount factor, rule revision and inputs, actor, time, outcome including skips and overrides), writing decisions in one state-guarded transaction, database-enforced append-only, what a hash chain proves with verification and anchoring, separating the audit log from the outbox and activity feed, event sourcing, retention and personal data

An approval is only evidence if, months later, you can show who decided, on which exact numbers, under which rule as it stood at the time, and what happened to every step - including the ones that were skipped. Shopify's logs don't hold that record: its activity log, timelines and API events carry no rule, threshold or version. So a quote layer that routes discounts for approval has to keep its own audit log, and the design questions are concrete: what the record contains, how it is written, how it stays append-only, and what a hash chain does and doesn't prove.

This page is that design, field by field, with simplified code. Approval workflows covers how to build an approval policy and the approval matrix what it should require; this page is about the record those decisions leave behind. Where QuotWay is the example, it is described as it works today, and the parts it doesn't do yet are said plainly - those are recommendations, labelled as such.

Shopify facts below were checked against Shopify's own pages on 14 October 2026 at API version 2026-10; sources are at the end.

The question the log has to answer

"Who approved this discount, and under which rule?" splits into five questions, and the record needs a part for each:

  1. On what? The exact proposal version and its numbers - including the effect of a whole-quote discount on each line.
  2. Under which rule? The policy that matched, the branch that matched, the inputs that made it match, and the policy as it stood then.
  3. Who? The person or system that decided, in which role, on whose behalf, and who else could have.
  4. When, and in what order? Server time and the order decisions were committed.
  5. With what outcome? Approve, reject - and the outcomes people forget: a skipped step and why, an override with its rationale, a chain superseded by a new version, a timeout.

What Shopify records, and what it doesn't

Shopify has three logs, and none of them is an approval record:

  • The store activity log shows "recent actions taken by store owners and users" - deleting a product, changing a setting, granting an app access. It is view-only, it "can't be exported or downloaded", and it "displays a maximum of 250 results".
  • Timelines on each order, draft order, customer and inventory transfer, plus a discount's own activity log on its page.
  • The user management activity log, which records "when a user or user role is created, edited or deleted", for eligible users.

Through the Admin API, a draft order's events return Event objects with an action, a message, the author, whether a user or an app caused it, and a timestamp. No field carries a policy, a threshold, a rationale or the version a decision was made on. And the auditTrail that arrived on contextual pricing at API 2026-10 explains how a catalog price was reached - it is not an approval record either. Knowing which Shopify staff member acted also needs restricted access (build vs buy has the scope rules), so a quote layer keeps its own approver identities.

The record, field by field

Part Fields The question it answers later What goes wrong without it
Subject Quote, proposal version id and number; the version's totals, whole-quote discount and its factor; each line's effective price "What exactly was approved?" A later version is mistaken for the approved one
Rule Policy id and revision; the matched branch; the computed inputs (amount and currency, blended and deepest line discount, deposit %, customer tags) "Under which threshold did this go through?" An edited policy makes the past unexplainable
Actor Actor id and type (staff, buyer, system); the approver set resolved when the chain started; delegation from and to; email for an external approver; role at the time "Was this person allowed to decide?" A role change rewrites who could have approved
Time Server time; the commit order "What happened first?" Concurrent decisions can't be ordered
Outcome Approve, reject, skip with its reason, override with rationale, superseded, cancelled, timed out "Why did step 2 never run?" A skipped step looks like a missing one

What was decided on - and the order-discount trap

Point the record at an immutable proposal version, never at the quote: a sent proposal doesn't change, and a change is a new version that supersedes any approval chain still running on the old one (proposals and versions). But the version id alone isn't enough, because the numbers a rule saw were computed.

Here is the case that shows why - the same reason a buyer's total is never summed from the rows. Until 30 September 2026, QuotWay judged discount conditions on line prices only, so a proposal with every line at full price and a large discount on the whole quote triggered no discount approval. Since then, each line is judged at what the buyer actually pays - its price after its share of the whole-quote discount:

// Factor applied to every line: the buyer pays (subtotal - orderDiscount) / subtotal of each price.
function orderDiscountFactor(subtotal: Decimal, orderDiscount: Decimal): Decimal {
  if (subtotal.lte(0) || orderDiscount.lte(0)) return new Decimal(1);
  if (orderDiscount.gte(subtotal)) return new Decimal(0);
  return subtotal.minus(orderDiscount).div(subtotal);
}

function approvalInputs(version: VersionSnapshot) {
  const factor = orderDiscountFactor(version.subtotal, version.orderDiscount);
  let catalogTotal = new Decimal(0), paidTotal = new Decimal(0);
  let deepestLinePct: number | undefined;
  for (const line of version.lines) {
    const effective = line.finalPrice.times(factor);          // what the buyer actually pays
    catalogTotal = catalogTotal.plus(line.catalogPrice.times(line.quantity));
    paidTotal = paidTotal.plus(line.finalPrice.times(line.quantity));
    const base = line.baselinePrice ?? line.catalogPrice;
    if (base.gt(0)) {
      const pct = base.minus(effective).div(base).times(100).toNumber();
      deepestLinePct = Math.max(deepestLinePct ?? -Infinity, pct);
    }
  }
  const blendedPct = catalogTotal.isZero()
    ? undefined
    : catalogTotal.minus(paidTotal.times(factor)).div(catalogTotal).times(100).toNumber();
  return { factor, blendedPct, deepestLinePct };
}

A record that stored only the line prices would show a chain that "shouldn't have" run - or, before the change, a 25 percent order discount that went through with no approval and no visible reason. Store the computed inputs - the factor, the blended and deepest line discount - on the chain, next to the version id. That is a recommendation; QuotWay stores the version and a short reason today, not the inputs.

Which rule matched, and which version of it

A policy that is edited in place erases the answer to "under which threshold?". The fix is a revision: bump it on every edit, keep old revisions, and write the revision - or a snapshot of the matched rule - on the chain when it starts. A recommended shape, not QuotWay's current schema:

-- On the approval chain, written once when it starts:
policy_id         text   not null,
policy_revision   int    not null,   -- bumped on every policy edit; old revisions kept
matched_rule      jsonb  not null,   -- the scope and conditions as they were
inputs            jsonb  not null,   -- amount, currency, rate, blended %, deepest line %,
                                     -- order-discount factor, deposit %, customer tags
step_skips        jsonb  not null    -- [{ step, reason: "rule did not match" | "missing: customerTags" }]

QuotWay today edits policies in place and stores the policy id plus a short reason that names the amount and currency for value rules; recording the revision, the inputs and each skip's reason is the next step for it.

Who decided

Resolve the approvers to concrete people when the chain starts, so a role change afterwards can't change who could have decided - QuotWay does this. Record every decision with the actor's id and type: staff, a buyer approver (for an external approver, the email they acted under), or system for an automatic decision on a timeout. Record delegation as its own row with who it moved from and to. And store the approver's role at decision time, because the staff record will change (staff and roles).

The outcome, including the ones people forget

Approve and reject are easy. The record also needs: a step skipped because its condition didn't match, with the reason; an override, with the person and their rationale; a chain superseded because a new version was sent; a chain cancelled; and a decision made by a timeout, with the system as the actor and the reason. A skipped step without a reason looks the same as a step that never ran.

Writing it: one transaction, conditioned on state

Two approvers can press "approve" at the same moment. Write each decision as a new row, then move the step only if it is still active - so exactly one decision lands and the other gets a conflict - and do the chain's next step and the terminal event in the same transaction. This is how QuotWay writes a decision, simplified:

await db.$transaction(async (tx) => {
  const step = await tx.approvalSteps.findUnique({ where: { id: stepId } });
  if (step.state !== "ACTIVE") throw new StepNotActive();
  if (actor.type !== "SYSTEM" && !step.approvers.includes(actor.id)
      && !(actor.email && step.approvers.includes(actor.email))
      && step.delegateId !== actor.id) throw new NotAnApprover();

  // 1. The decision is a new row, never an update.
  await tx.approvalDecisions.create({ data: {
    chainId: step.chainId, stepId, decision, rationale,
    actorId: actor.id, actorType: actor.type, actorEmail: actor.email,
  }});

  // 2. Move the step only if nobody beat us to it - 0 rows means a concurrent decision.
  await tx.approvalSteps.update({
    where: { id: stepId, state: "ACTIVE" },
    data: { state: next(decision), resolvedAt: new Date() },
  });

  // 3. Advance the chain; on a final verdict, append the quote event - same transaction.
  const outcome = advance(await tx.approvalSteps.findMany({ where: { chainId: step.chainId } }));
  if (outcome.terminal) await appendEvent(tx, { quoteId, type: verdictEvent(outcome), chainId: step.chainId });
});
// After commit: notify the next approvers, send the released proposal, dispatch webhooks.

Side effects - emails, the released proposal, webhooks - happen after the commit, through an outbox written in the same transaction. The idempotent conversion post covers that pattern.

Append-only is a database property, not a convention

"The app never updates a decision" is a convention. Make it a property:

  • Revoke UPDATE and DELETE on the audit tables from the role the application connects as. In PostgreSQL the table owner "is always treated as holding all grant options", so the app must not connect as the owner. Or add a trigger that raises on update or delete.
  • No cascades into the audit tables. A foreign key with ON DELETE CASCADE from a policy or a step into the decisions is a delete the application never wrote. Deleting a policy, or removing a step from it, must not take past decisions with it - archive the policy instead.
  • Corrections are new rows. A wrong decision is answered by a compensating decision, never an edit.

QuotWay writes decisions as new rows and never edits them, but today its decision records can still be removed along with the policy or step they belong to. Stopping those deletes is the fix it needs; until then, it doesn't call the history append-only.

Hash chains: what they prove and what they don't

A hash chain stores in each row the hash of the row before it, so editing or deleting one row breaks every later hash. QuotWay writes its quote events this way, with the integration outbox row in the same transaction:

async function appendEvent(tx: Tx, e: EventInput) {
  const prev = await tx.auditEvents.findFirst({
    where: { quoteId: e.quoteId }, orderBy: { createdAt: "desc" }, select: { hash: true },
  });
  const createdAt = new Date();
  const hash = sha256((prev?.hash ?? "") + "|" + canonicalJson({ ...e, createdAt: createdAt.toISOString() }));
  const row = await tx.auditEvents.create({ data: { ...e, createdAt, prevHash: prev?.hash ?? null, hash } });
  await tx.outbox.create({ data: toPublicEvent(row) });   // commits or rolls back with the event
  return row;
}
// canonicalJson = JSON with object keys sorted recursively, so the hash is reproducible.

What it proves depends on three more things, and none of them is free. Someone has to verify the chain - recompute every hash in order. The latest hash has to be anchored somewhere the writer can't rewrite, because anyone who can rewrite the whole table can recompute the whole chain; NIST's log-management guide recommends storing digests securely, for example "on read-only media". And appends have to be serialised, or two writers can fork the chain. A recommended shape - not QuotWay code:

// Serialise appends per quote so two writers can't fork the chain:
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${quoteId}))`;

// Verify: recompute every hash in order; the first mismatch is where the record changed.
function verify(rows: AuditRow[]): { ok: true } | { ok: false; at: string } {
  let prev = "";
  for (const r of rows) {                 // ordered by a per-quote sequence, not by timestamp
    if ((r.prevHash ?? "") !== prev) return { ok: false, at: r.id };
    if (sha256(prev + "|" + canonicalJson(fieldsOf(r))) !== r.hash) return { ok: false, at: r.id };
    prev = r.hash;
  }
  return { ok: true };
}
// Anchor: copy the latest hash per quote to storage the app can't rewrite (a write-once bucket).

QuotWay's events carry the hash of the event before them; verifying and anchoring the chain is not built yet, so it doesn't call the record tamper-proof. OWASP's logging guidance says the same thing in one line: "Build in tamper detection so you know if a record has been modified or deleted" - detection needs a verifier.

A second, narrower seal is worth having: QuotWay stores an acceptance hash over the accepted lines - the version id and each line's id, quantity, unit price and total - and shows it on the conversion screen, the Shopify order card and the confirmation PDF, so the order can be traced to exactly what the buyer accepted.

Three logs, three jobs

Audit log Integration outbox Activity feed
Reader An auditor, a dispute, you in a year Other systems (ERP, CRM, webhooks) People on a dashboard
Contents Every decision, complete Outcomes, for delivery A curated selection
Personal data Only what the decision needs None Display names
Retention As long as the quote it authorises Until delivered, then short Short
Mutability Append-only Written once, delivered, expired A projection - rebuilt freely

OWASP's advice is to keep audit and transaction trails separate from other logging, and the table is why: one store can't serve three readers with three retentions. The quote architecture puts the audit trail in the quote layer for the same reason. In QuotWay, the outbox carries approval outcomes only and no personal data, and the dashboard's activity feed is a curated list that includes no approval events.

Event sourcing or an audit log?

Event sourcing stores "the full series of actions taken on an object in an append-only store" and rebuilds state from it. Microsoft's guidance calls it "a complex pattern that introduces significant trade-offs" and notes that "for most systems... traditional data management is sufficient"; Martin Fowler's original description says an audit log is one by-product you "could also do... with more regular logging mechanisms". For "who approved this, under which rule", an append-only decision table next to ordinary tables is enough. A quote layer beside Shopify is also a poor fit for full replay: replaying events would touch Shopify again, and external systems "don't know the difference between real processing and replays".

Retention and personal data

Minimise at write time: an append-only row can't be cleaned later. OWASP lists session identifiers, access tokens and sensitive personal data among what not to log, and NIST says to "avoid recording unneeded sensitive data". Keep people by id and their contact details elsewhere - Microsoft's guidance names that, or encrypting personal data with a per-person key you can destroy, as the answer to erasure requests in an append-only store. Keep the decisions as long as the quote they authorise; the period is set by your own obligations, and we name none. The security review checklist has the questions to ask a vendor about logs and personal data.

Answering the question months later

"Who approved the 18 percent on quote QW-1042 version 3, and under which rule?" With the record above:

  1. Find the chain on version 3 - not on the quote, which may have later versions.
  2. Read the chain's rule: the policy and its revision, and the inputs it matched - a blended discount of 18.4 percent including a whole-quote discount factor of 0.95, against a 15 percent threshold.
  3. Read the decisions in commit order: step 1 approved by a sales manager at 10:02; step 2 skipped because its condition (deposit below 20 percent) didn't match; step 3 approved by the finance approver it was delegated to.
  4. Check the acceptance hash on the order matches version 3's accepted lines.

Now change one thing: the policy's threshold was raised to 20 percent last month. Without the revision on the chain, step 2 reads as "would not trigger today" and nobody can say why it ran then. With it, the answer is a line.

A checklist for your own build

  • Every decision is a new row; nothing updates or deletes one.
  • The app's database role can't UPDATE or DELETE the audit tables, and nothing cascades into them.
  • Each chain points at an immutable proposal version, not at the quote.
  • Each chain stores the policy revision (or a rule snapshot) and the computed inputs - including the whole-quote discount factor.
  • Approvers are resolved when the chain starts; the role at decision time is stored.
  • Delegation is a row with from and to; external approvers are recorded by the identity they acted under.
  • Skips carry their reason; overrides carry the person and the rationale; timeouts are recorded as the system.
  • Two simultaneous decisions: exactly one lands.
  • Side effects run after commit, through an outbox in the same transaction.
  • If you hash-chain: appends are serialised, a verifier runs, and the latest hash is anchored out of the writer's reach.
  • The audit log, the outbox and the activity feed are separate stores with separate retention.
  • No tokens, session ids or personal data the decision doesn't need.

Evaluating an app instead of building? The same list works as questions; the evaluation checklist has the audit-log test, and build vs buy counts what this part costs to build.

Where this lives in QuotWay

Merchant approval workflows and buyer-side approval chains are on Professional and above, and they apply to quotes; parallel and conditional steps, timeouts, escalation and delegation are on Enterprise (approval workflows, plans). A chain belongs to one proposal version and a new version supersedes it. Approvers are resolved when the chain starts; every decision is written as a new row with the actor, their type and the rationale, and of two simultaneous decisions one lands. Quote events carry the hash of the event before them, and the acceptance hash is shown on the Shopify order card. Quote records, including their events and acceptance hashes, are kept for 7 years by default. How approval enforcement works covers what an approval blocks. Since 30 September 2026, discount conditions judge each line at its price after its share of the whole-quote discount. Approval webhooks on the Enterprise API carry chain outcomes only, with no personal data (the approvals feature).

FAQ

What should an audit log for pricing approvals record?

The proposal version and its numbers, the rule and its revision with the inputs that made it match, who decided and on whose behalf, when, and the outcome - including skipped steps with their reason and overrides with their rationale.

Does Shopify have an audit log for discount approvals?

No. Shopify has a store activity log, timelines on orders and draft orders, a discount's own activity log and a user-management log. None of them stores a rule, a threshold or the version a decision was made on.

Should an audit log be immutable?

Append-only: corrections are new rows. Enforce it in the database - revoke update and delete from the application's role, or use a trigger - and keep cascading deletes out of the audit tables.

Is a hash chain enough to make an audit log tamper-proof?

Not on its own. It shows tampering only if someone verifies the chain and the latest hash is stored where the writer can't change it; otherwise whoever can rewrite the table can recompute every hash.

Event sourcing or an audit log?

An audit log, unless you need to rebuild state from the history. Event sourcing adds significant trade-offs, and replaying events doesn't mix with external systems such as Shopify.

Does a whole-quote discount count toward discount approval?

It should: judge each line at its price after its share of the whole-quote discount, and store the factor you used. QuotWay has done this since 30 September 2026.

What happens to the record if the approval policy changes later?

If the chain stored only the policy id, you can no longer tell which threshold applied. Store the policy's revision, or a snapshot of the matched rule, when the chain starts.

How long should approval records be kept?

As long as the quote they authorise, set by your own obligations. We don't name a statutory period.

What's the difference between an audit log and an activity feed?

The activity feed is a curated, readable summary for a dashboard. The audit log is the complete record kept as evidence. They have different readers, contents and retention, so keep them in separate stores.

Sources

Read on 14 October 2026:

QuotWay behaviour is described from the app's source, read on 14 October 2026; code is simplified, with internal names removed.

Related articles

See how QuotWay handles this on your store.

We’d like to set analytics cookies to understand how the site is used. They’re not required — declining changes nothing about how the site works, and you can change your mind any time on our privacy page.