PAYG usage card: review follow-ups (avg/PDF, empty-state, unique wording) (#6967)

Small follow-up to #6957 addressing the three non-blocker findings from
its review. **Draft / stacked on #6957** — the diff shows #6957's
changes until it merges, then auto-narrows to just these 5 files. Mark
ready + rebase onto `main` once #6957 lands.

### 1. avg-per-PDF no longer blends unsynced units over synced-only docs
`avgCostMinor` now divides **synced** units (`spendUnitsThisPeriod`) by
synced docs, so numerator and denominator cover the same population.
Combined-billing `pendingUnits` (units-only, no doc count) previously
inflated the average for linked-instance teams. The "meter units" figure
still shows synced+pending (total current usage) — only the *average* is
synced-only.

### 2. Empty-state: unsynced-only reads cleanly
When `docs == 0` but there are pending meter units (combined-billing,
nothing synced yet), the card showed a bare **"0 PDFs"** headline with a
count-less summary and no split. It now shows a **"{n} meter units
pending sync from linked instances"** note instead. New `unitsPending`
i18n key + a `UnsyncedOnly` story. (Only reachable on the
combined-billing path; pure-SaaS teams are unaffected.)

### 3. uniquePdfs wording is now accurate
`document_fingerprint` is a hash of a charge's whole **input set**, so
the same file reused across *different* groupings (standalone, then
later in a merge `{A,B}`) counts per grouping — a close approximation of
"unique PDFs", exact for the single-input common case. Softened the FE
type doc + the `WalletLedgerEntry.document_fingerprint` javadoc to say
so (no behaviour change; counting model unchanged).

### Verification
FE typecheck / test / lint / format all clean; `:saas:compileJava`
green. No behaviour change beyond #1 (avg) and #2 (empty-state copy); #3
is doc-only.
This commit is contained in:
ConnorYoh
2026-07-20 13:51:55 +00:00
committed by GitHub
parent 1954d20910
commit c19d56de22
5 changed files with 58 additions and 13 deletions
@@ -87,9 +87,13 @@ public class WalletLedgerEntry implements Serializable {
private Integer docCount = 1;
/**
* SHA-256 of this entry's input file set; {@code COUNT(DISTINCT ...)} over a period gives
* unique PDFs processed. {@code null} for aggregate/system entries (grants, linked-instance
* sync) that don't map to a single document set.
* SHA-256 of this entry's input file <em>set</em> (the charge's whole input list, sorted).
* {@code COUNT(DISTINCT ...)} over a period approximates unique PDFs processed. Exact within a
* run (a file's chain/split steps share the run's charge, so one fingerprint), and for the
* single-input common case; but the <em>same</em> file reused across different groupings — e.g.
* standalone, then later merged as {A,B} — yields different set-fingerprints and is counted
* once per grouping. {@code null} for aggregate/system entries (grants, linked-instance sync)
* that don't map to a single document set.
*/
@Column(name = "document_fingerprint", length = 64)
private String documentFingerprint;
@@ -6266,6 +6266,8 @@ sizeMultiplier = "{{formatted}} PDFs used a size multiplier"
summary = "{{unique}} unique · {{units}} meter units · {{avg}} avg per PDF"
summaryNoRate = "{{unique}} unique · {{units}} meter units"
unit = "PDFs"
unitsPending_one = "{{units}} meter unit pending sync from linked instances"
unitsPending_other = "{{units}} meter units pending sync from linked instances"
[portal.billing.spendLimit]
adjustLimit = "Adjust limit"
@@ -43,3 +43,26 @@ export const Empty: Story = {
},
},
};
/** Combined-billing: zero synced PDFs but unsynced instance units → "pending sync" note (no 0-PDF summary). */
export const UnsyncedOnly: Story = {
args: {
wallet: {
...subscribedWallet,
billableUsed: 0,
spendUnitsThisPeriod: 0,
categoryBreakdown: { api: 0, ai: 0, automation: 0 },
categoryDocs: { api: 0, ai: 0, automation: 0 },
docsProcessedThisPeriod: 0,
uniquePdfsThisPeriod: 0,
sizeMultiplierPdfsThisPeriod: 0,
},
unsynced: {
periodStart: subscribedWallet.billingPeriodStart,
apiUnsyncedUnits: 8,
aiUnsyncedUnits: 0,
automationUnsyncedUnits: 4,
totalUnsyncedUnits: 12,
},
},
};
@@ -73,17 +73,15 @@ export function PdfsProcessedCard({
const perDocs: WalletCategoryBreakdown = wallet.categoryDocs;
const totalDocs = perDocs.api + perDocs.ai + perDocs.automation;
// Average cost per PDF in minor currency units — meter units × the per-unit rate,
// spread over the input files processed. Shown only when the rate is known (free-tier
// and unknown-price snapshots omit the term rather than imply $0.00).
// Average cost per PDF in minor currency units — SYNCED units × the per-unit rate,
// spread over the (synced) input files. Deliberately excludes combined-billing
// pendingUnits: those are units-only (no doc count), so dividing them over synced docs
// would inflate the average. Numerator and denominator therefore cover the same
// population. Shown only when the rate is known (free-tier / unknown-price omit it).
const rate = wallet.pricePerDocMinor;
const showAvgCost = docs > 0 && rate != null;
const avgCostMinor =
rate != null && docs > 0 ? (meterUnits / docs) * rate : 0;
// Something ran once there are either counted PDFs or metered units (instance-local
// unsynced usage is units-only, so it keeps the card out of the empty state).
const hasActivity = docs > 0 || meterUnits > 0;
rate != null && docs > 0 ? (wallet.spendUnitsThisPeriod / docs) * rate : 0;
return (
<Card padding="loose">
@@ -100,7 +98,7 @@ export function PdfsProcessedCard({
</span>
</div>
{hasActivity ? (
{docs > 0 ? (
<>
<p className="portal-billing__section-sub">
{showAvgCost
@@ -182,6 +180,18 @@ export function PdfsProcessedCard({
</p>
) : null}
</>
) : pendingUnits > 0 ? (
// docs == 0 but instance-local units have accrued that SaaS hasn't billed yet
// (combined-billing, units-only, no PDF count). Surface the pending figure directly
// instead of a bare "0 PDFs" headline with a count-less summary + no split. Gated on
// pendingUnits (not meterUnits) so the "pending sync" wording is always exact.
<p className="portal-billing__section-sub">
{t(
"portal.billing.pdfsProcessed.unitsPending",
"{{units}} meter units pending sync from linked instances",
{ count: pendingUnits, units: pendingUnits.toLocaleString() },
)}
</p>
) : (
<p className="portal-billing__section-sub">
{t(
@@ -66,7 +66,13 @@ export interface Wallet {
categoryDocs: WalletCategoryBreakdown;
/** Total input files processed this period (Σ doc_count). */
docsProcessedThisPeriod: number;
/** Distinct input documents this period — a file hit by N operations counts once. */
/**
* Distinct input document-sets this period — `COUNT(DISTINCT document_fingerprint)`, where the
* fingerprint is the hash of a charge's whole input set. A file processed repeatedly within one
* run (chain/split) counts once; the same file reused across *different* groupings (e.g.
* standalone, then later in a merge {A,B}) has different fingerprints and so counts per grouping.
* A close approximation of "unique PDFs", exact for the single-input common case.
*/
uniquePdfsThisPeriod: number;
/** Input files on charges where the size multiplier applied (units billed &gt; input files). */
sizeMultiplierPdfsThisPeriod: number;