Compare commits

...
Author SHA1 Message Date
Connor Yoh afe11a85a1 PAYG usage card: review nits on the pending-sync note
- Gate the unsynced-only note on pendingUnits (not meterUnits) and interpolate
  pendingUnits directly, so the "pending sync from linked instances" wording is
  always exact — robust to a hypothetical zero-doc entry carrying synced units.
- Pluralise the key (unitsPending_one/_other + count) so it reads "1 meter unit"
  not "1 meter units", matching the project's _one/_other convention.

(Headline still shows the synced PDF count — "0 PDFs" in the unsynced-only state
is by design: count dimension vs meter units, with the note explaining the gap.)
2026-07-13 09:48:54 +01:00
Connor Yoh 4648cb8b41 PAYG usage card: address review follow-ups (avg/PDF, empty-state, unique wording)
Follow-up to #6957, from the review:

1. avg-per-PDF no longer blends unsynced units over synced-only docs. It now
   divides SYNCED units (spendUnitsThisPeriod) by synced docs, so numerator and
   denominator cover the same population; combined-billing pendingUnits (units-
   only, no doc count) no longer inflates the average. The "meter units" figure
   still shows synced+pending (total current usage).

2. Empty-state: docs==0 with pending meter units (combined-billing, unsynced-
   only) now shows a "{n} meter units pending sync from linked instances" note
   instead of a bare "0 PDFs" headline with a count-less summary and no split.
   New i18n key + a UnsyncedOnly story variant.

3. uniquePdfsThisPeriod wording softened to be accurate: the fingerprint is over
   a charge's whole input SET, so the same file reused across different groupings
   (standalone vs a later merge) counts per grouping — a close approximation,
   exact for the single-input common case. Updated the FE type doc + the
   WalletLedgerEntry.document_fingerprint javadoc.
2026-07-10 15:35:42 +01:00
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;
@@ -6330,6 +6330,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;