diff --git a/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java b/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java index fab6647b2c..4b64629660 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java @@ -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 set (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 same 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; diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 1589cad8a7..49dfb66bc4 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -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" diff --git a/frontend/editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx index d7da10398b..e06c07ec94 100644 --- a/frontend/editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx +++ b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx @@ -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, + }, + }, +}; diff --git a/frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx index 794ac39ae5..d36da95dd3 100644 --- a/frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx +++ b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx @@ -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 ( @@ -100,7 +98,7 @@ export function PdfsProcessedCard({ - {hasActivity ? ( + {docs > 0 ? ( <>

{showAvgCost @@ -182,6 +180,18 @@ export function PdfsProcessedCard({

) : 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. +

+ {t( + "portal.billing.pdfsProcessed.unitsPending", + "{{units}} meter units pending sync from linked instances", + { count: pendingUnits, units: pendingUnits.toLocaleString() }, + )} +

) : (

{t( diff --git a/frontend/editor/src/proprietary/billing/types.ts b/frontend/editor/src/proprietary/billing/types.ts index df0caa5e39..46fd228f73 100644 --- a/frontend/editor/src/proprietary/billing/types.ts +++ b/frontend/editor/src/proprietary/billing/types.ts @@ -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 > input files). */ sizeMultiplierPdfsThisPeriod: number;