@aglyn/shared-ui-email-campaigns 1.0.0-beta.143

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +73 -0
  3. package/package.json +39 -0
  4. package/src/index.d.ts +17 -0
  5. package/src/index.js +23 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/campaign-picker.component.d.ts +54 -0
  8. package/src/lib/components/campaign-picker.component.js +121 -0
  9. package/src/lib/components/campaign-picker.component.js.map +1 -0
  10. package/src/lib/components/report-figures.d.ts +51 -0
  11. package/src/lib/components/report-figures.js +120 -0
  12. package/src/lib/components/report-figures.js.map +1 -0
  13. package/src/lib/model/campaign-container.d.ts +359 -0
  14. package/src/lib/model/campaign-container.js +355 -0
  15. package/src/lib/model/campaign-container.js.map +1 -0
  16. package/src/lib/model/campaign-conversions.d.ts +286 -0
  17. package/src/lib/model/campaign-conversions.js +249 -0
  18. package/src/lib/model/campaign-conversions.js.map +1 -0
  19. package/src/lib/model/campaign-report.d.ts +304 -0
  20. package/src/lib/model/campaign-report.js +326 -0
  21. package/src/lib/model/campaign-report.js.map +1 -0
  22. package/src/lib/model/campaign-revenue.d.ts +327 -0
  23. package/src/lib/model/campaign-revenue.js +332 -0
  24. package/src/lib/model/campaign-revenue.js.map +1 -0
  25. package/src/lib/model/campaign-send-time.d.ts +74 -0
  26. package/src/lib/model/campaign-send-time.js +120 -0
  27. package/src/lib/model/campaign-send-time.js.map +1 -0
  28. package/src/lib/model/email-record.d.ts +175 -0
  29. package/src/lib/model/email-record.js +198 -0
  30. package/src/lib/model/email-record.js.map +1 -0
  31. package/src/lib/model/index.d.ts +53 -0
  32. package/src/lib/model/index.js +48 -0
  33. package/src/lib/model/index.js.map +1 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/shared/ui/email-campaigns/src/lib/model/campaign-report.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * CAMPAIGN REPORTING MATH — the only place a rate is computed.\n *\n * ## Why a pure module and not a component\n *\n * Every number on the report screen is a division, and a division is where\n * email reporting goes wrong. Putting the arithmetic in JSX means the\n * denominator is chosen by whoever writes the next card, in a file nobody\n * tests for arithmetic; putting it here means each rate is named once,\n * carries its own denominator as data, and is provable.\n *\n * ## The rule this module exists to enforce\n *\n * **A rate is a triple — numerator, denominator, and the NAME of the\n * denominator — or it is not reported.** An open rate over `sent` and an open\n * rate over `delivered` are different numbers with the same label, and the\n * gap between them is exactly the mail that bounced. The industry convention\n * is over `delivered`, and a report that quietly used `sent` would read\n * higher than the same campaign measured anywhere else.\n *\n * So {@link CampaignRate} carries `denominatorLabel`, and the screen is\n * required to render it. There is no overload that omits it.\n *\n * ## Why some rates are deliberately absent\n *\n * {@link campaignRate} answers `null`, not zero, when it cannot divide:\n *\n * - **A zero denominator.** 0 opens out of 0 delivered is not a 0% open\n * rate, it is no open rate. Rendering 0% invites the reader to compare it\n * with a campaign that really did fail.\n * - **An UNKNOWN denominator.** `delivered` is counted by the delivery\n * webhook, which was connected after some campaigns were sent. A campaign\n * with 400 sends and no delivery events has an unknown denominator, not a\n * denominator of zero — and dividing by `sent` instead is precisely the\n * flattering substitution above.\n *\n * ## The structural-zero window\n *\n * Click tracking rewrites links in the HTML part. Sends that carried no HTML\n * part were therefore untrackable, and every one of those campaigns reports 0\n * clicks whatever the recipients actually did — a real 0 and a structural 0\n * rendered identically. `send-email.ts` now synthesises an HTML part for a\n * text-only send, so every send after that carries one, and\n * `campaign-send.ts` records {@link CampaignStats.clickTracked} to say so.\n *\n * A campaign with no such marker predates the record. Its click COUNT is\n * still shown — it is a real count of real events — but no click RATE is\n * computed from it, because a rate presents the number as a measurement of\n * the audience and for those campaigns it is a measurement of the sender.\n */\n\n/**\n * The `stats` map on `hosts/{hostId}/campaigns/{campaignId}`.\n *\n * Every field is optional and every reader defaults it, because these are\n * written by three different writers at three different times — the send, the\n * delivery webhook, the unsubscribe handler — and a campaign is a legitimate,\n * readable document from the instant the first of them lands.\n */\nexport interface CampaignStats {\n /*========================================\n * WRITTEN BY THE SEND. Never recomputed.\n *\n * These are the truth of what happened, recorded once by the code that did\n * it. Re-deriving any of them at read time would produce a number that\n * disagrees with the send — the audience has moved on since, suppressions\n * have been added, consent has been recorded — and the recorded one is the\n * one that describes the campaign.\n *=======================================*/\n /** The whole audience the send was taken from, before the per-send cap. */\n audienceSize?: number\n /** `audienceSize` is a FLOOR: audience resolution hit its read ceiling. */\n audienceSizeTruncated?: boolean\n /** Addresses this send ADDRESSED — the audience after the per-send cap. */\n recipients?: number\n /** Messages the provider accepted. The `sent` in \"sent/recipients\". */\n sent?: number\n /** Of the audience, how many carry a recorded marketing consent basis. */\n consented?: number\n /** Of `consented`, how many hold a basis an operator asserted for them. */\n consentedByOperator?: number\n /** Of the audience, how many are reachable only because enforcement is\n * not retroactive — the population a strict consent policy removes. */\n grandfathered?: number\n /** Of the audience, how many the consent rule refused to mail. */\n consentWithheld?: number\n /** Of `recipients`, how many were already suppressed (unsubscribed,\n * bounced or complained on an earlier send). */\n suppressed?: number\n /** Of `recipients`, how many asked this site for mail less often than this\n * send would have arrived. Still subscribed; reached by a later campaign. */\n cadenceHeld?: number\n /** Recipients the hourly send governor refused mid-batch. */\n deferred?: number\n /**\n * This send carried an HTML part, so its links were trackable.\n *\n * Absent on every campaign sent before the field existed — see the\n * structural-zero note in this module's header. Absent is NOT false; it is\n * \"not recorded\", and the report says so rather than guessing.\n */\n clickTracked?: boolean\n /** Per-variant send counts for an A/B campaign. */\n variantSends?: Record<string, number>\n\n /*========================================\n * WRITTEN BY THE DELIVERY WEBHOOK, as increments.\n *=======================================*/\n /** Messages the receiving server accepted. The rate DENOMINATOR. */\n delivered?: number\n /** Open EVENTS. One reader opening four times counts four. */\n opens?: number\n /** Click EVENTS. One reader clicking three links counts three. */\n clicks?: number\n /** Messages whose FIRST open was seen — distinct readers who opened. */\n uniqueOpens?: number\n /** Messages whose FIRST click was seen — distinct readers who clicked. */\n uniqueClicks?: number\n /** Messages that bounced, permanent and transient together. */\n bounced?: number\n /** Recipients who pressed \"report spam\". */\n complained?: number\n\n /*========================================\n * WRITTEN BY THE UNSUBSCRIBE HANDLER.\n *=======================================*/\n /** Recipients who unsubscribed through THIS campaign's link. */\n unsubscribes?: number\n}\n\n/**\n * One rate, with the denominator it was taken over named as data.\n *\n * `denominatorLabel` is not a display nicety. It is the field that makes two\n * numbers called \"open rate\" distinguishable, and the screen renders it\n * beside the percentage for that reason.\n */\nexport interface CampaignRate {\n /** 0–1. Multiply for display; the model never formats. */\n value: number\n numerator: number\n denominator: number\n /** Reader-facing name of the denominator, e.g. `'delivered'`. */\n denominatorLabel: string\n}\n\n/**\n * A rate, or `null` when one cannot honestly be taken.\n *\n * `null` on a zero or unknown denominator — see the module header for why\n * that is not the same as 0%.\n */\nexport function campaignRate(\n numerator: number | undefined,\n denominator: number | undefined,\n denominatorLabel: string,\n): CampaignRate | null {\n const top = Number(numerator ?? 0)\n const bottom = Number(denominator ?? 0)\n if (!Number.isFinite(top) || !Number.isFinite(bottom)) return null\n if (bottom <= 0) return null\n return {\n value: top / bottom,\n numerator: top,\n denominator: bottom,\n denominatorLabel,\n }\n}\n\n/** Why a number the report would otherwise show is being withheld. */\nexport interface CampaignCaveat {\n /** Stable id, so a spec can assert on the caveat rather than its prose. */\n id:\n | 'delivery-unrecorded'\n | 'click-tracking-unrecorded'\n | 'audience-truncated'\n | 'send-deferred'\n /* Raised by `campaign-revenue.ts`, which reports through this shape so\n * the screen has one way of saying \"a number is being withheld and here\n * is why\" rather than one per section. */\n | 'revenue-denominator-unrecorded'\n | 'revenue-multi-currency'\n | 'revenue-mid-flight'\n | 'revenue-mixed-model'\n /* Raised by `campaign-conversions.ts`, through this shape for the same\n * reason: one way of saying \"a number is being withheld, or must not be\n * read the obvious way, and here is why\". The first two are the reasons\n * the four conversion kinds stand apart instead of totalling; the last\n * two qualify the uncredited figure rather than withholding it. */\n | 'conversions-kinds-overlap'\n | 'conversions-web-not-rolled-up'\n | 'conversions-unattributed-is-a-ceiling'\n | 'conversions-total-crosses-hosts'\n message: string\n}\n\n/** One population the send measured, for the audience breakdown. */\nexport interface CampaignPopulation {\n id: string\n label: string\n count: number\n /** What this count is a part OF, named. */\n ofLabel: string\n of: number\n}\n\n/** Everything the report screen renders, decided here rather than in JSX. */\nexport interface CampaignReport {\n sent: number\n recipients: number\n delivered: number | null\n opens: number\n clicks: number\n uniqueOpens: number | null\n uniqueClicks: number | null\n bounced: number\n complained: number\n unsubscribes: number\n /** Rates, each `null` when its denominator is zero or unrecorded. */\n rates: {\n /** Accepted by the receiving server, over what the provider accepted. */\n delivery: CampaignRate | null\n /** Distinct readers who opened, over delivered. */\n open: CampaignRate | null\n /** Distinct readers who clicked, over delivered. */\n click: CampaignRate | null\n /**\n * Distinct clickers over distinct OPENERS — a different question from\n * `click`, and the one the two get confused for. It answers \"of the\n * people who read it, how many acted\", not \"of the people who received\n * it\". Reported separately and labelled separately, never as \"click\n * rate\".\n */\n clickToOpen: CampaignRate | null\n /** Bounced over what the provider accepted. */\n bounce: CampaignRate | null\n /** Complaints over delivered — the number mailbox providers judge on. */\n complaint: CampaignRate | null\n /** Unsubscribes through this campaign's link, over delivered. */\n unsubscribe: CampaignRate | null\n }\n populations: CampaignPopulation[]\n caveats: CampaignCaveat[]\n}\n\n/**\n * Turns a stored `stats` map into the report.\n *\n * ## The denominator decisions, in one place\n *\n * - **`delivered`** carries the engagement rates — open, click, complaint,\n * unsubscribe. Mail that bounced was never in front of a human, so\n * including it in the denominator of an open rate depresses a number that\n * describes the audience with a fact about the address list. This is also\n * the convention every other tool reports, which matters: a merchant\n * comparing our figure with their previous ESP's must be comparing the same\n * quantity.\n * - **`sent`** carries the delivery and bounce rates, because those describe\n * what happened to what we handed the provider, and `delivered` is the\n * numerator of one of them — a rate cannot be over itself.\n * - **`uniqueOpens`** is the open-rate numerator, not `opens`. Open EVENTS\n * over recipients can exceed 100% the moment one person opens twice, and a\n * percentage above 100 is how a reader learns the number means something\n * other than what it says. Both are shown; only the distinct count is\n * divided.\n *\n * ## `delivered` unknown vs. zero\n *\n * A campaign predating the delivery webhook records no `delivered` at all.\n * That is reported as `null` and every rate over it is withheld, with a\n * caveat naming the reason — rather than substituting `sent`, which would\n * silently publish the flattered number this module exists to refuse.\n */\nexport function campaignReport(stats: CampaignStats | undefined): CampaignReport {\n const source = stats ?? {}\n const sent = Number(source.sent ?? 0)\n const recipients = Number(source.recipients ?? 0)\n const opens = Number(source.opens ?? 0)\n const clicks = Number(source.clicks ?? 0)\n const bounced = Number(source.bounced ?? 0)\n const complained = Number(source.complained ?? 0)\n const unsubscribes = Number(source.unsubscribes ?? 0)\n\n /*\n * ABSENT, not zero. `stats.delivered` is written only by the delivery\n * webhook, so `undefined` means \"no delivery event has ever been recorded\n * for this campaign\" — which for an old campaign means the webhook was not\n * connected, and for a campaign sent thirty seconds ago means the events\n * are still in flight. Neither is \"nothing was delivered\", and both are\n * ruined by `?? 0`, which would turn the unknown into a hard zero and make\n * every rate over it `null` for the RIGHT answer by the WRONG reasoning —\n * and would render \"0 delivered\" on screen beside \"500 sent\".\n */\n const delivered =\n source.delivered === undefined ? null : Number(source.delivered)\n const uniqueOpens =\n source.uniqueOpens === undefined ? null : Number(source.uniqueOpens)\n const uniqueClicks =\n source.uniqueClicks === undefined ? null : Number(source.uniqueClicks)\n\n /*\n * The click rate is withheld for a campaign that never recorded carrying an\n * HTML part, even when clicks are non-zero and `delivered` is known. See\n * the structural-zero note in the module header: for those sends 0 is the\n * only value the number could ever have taken, so a rate computed from it\n * measures our sending code rather than the recipients.\n */\n const clickTrackable = source.clickTracked === true\n\n const rates: CampaignReport['rates'] = {\n /*\n * `null` when `delivered` is UNRECORDED, and this is the one rate where\n * the numerator can be unknown rather than zero.\n *\n * Everywhere else an absent numerator is a genuine nought — a campaign\n * with delivery events and no opens really does have a 0% open rate, and\n * that is worth showing. Here the numerator IS the unrecorded quantity,\n * so `campaignRate(undefined, 1000, 'sent')` would divide a missing\n * measurement by a real one and publish \"0.0% delivery rate — 0 of 1,000\n * sent\" for a campaign whose delivery events were merely never recorded.\n * That is the flattering-substitution failure this module exists to\n * refuse, running in the other direction: not a rate that reads too high,\n * but a campaign that reads as a total delivery failure.\n */\n delivery:\n delivered === null ? null : campaignRate(delivered, sent, 'sent'),\n open: campaignRate(uniqueOpens ?? undefined, delivered ?? undefined, 'delivered'),\n click: clickTrackable\n ? campaignRate(uniqueClicks ?? undefined, delivered ?? undefined, 'delivered')\n : null,\n clickToOpen: clickTrackable\n ? campaignRate(uniqueClicks ?? undefined, uniqueOpens ?? undefined, 'unique openers')\n : null,\n bounce: campaignRate(bounced, sent, 'sent'),\n complaint: campaignRate(complained, delivered ?? undefined, 'delivered'),\n unsubscribe: campaignRate(unsubscribes, delivered ?? undefined, 'delivered'),\n }\n\n /*\n * The populations the SEND measured, reported as parts of a named whole\n * rather than as bare counts. \"412 withheld\" invites the question \"out of\n * what\"; the answer is the audience, and it is a different whole from the\n * one `suppressed` is measured against — consent is decided over the whole\n * audience and suppression over the capped recipient list, because that is\n * where each check runs. Netting them into one column would present two\n * different denominators as one.\n */\n const audienceSize = Number(source.audienceSize ?? 0)\n const populations: CampaignPopulation[] = []\n const addPopulation = (\n id: string,\n label: string,\n count: number | undefined,\n ofLabel: string,\n of: number,\n ) => {\n if (count === undefined) return\n populations.push({ id, label, count: Number(count), ofLabel, of })\n }\n addPopulation(\n 'consented',\n 'Had a consent basis',\n source.consented,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'consentedByOperator',\n 'Consent asserted by an operator',\n source.consentedByOperator,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'grandfathered',\n 'Reachable only because consent is not enforced retroactively',\n source.grandfathered,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'consentWithheld',\n 'Withheld by the consent rule',\n source.consentWithheld,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'suppressed',\n 'Already suppressed',\n source.suppressed,\n 'addressed',\n recipients,\n )\n addPopulation(\n 'cadenceHeld',\n 'Asked for mail less often than this',\n source.cadenceHeld,\n 'addressed',\n recipients,\n )\n\n const caveats: CampaignCaveat[] = []\n if (delivered === null) {\n caveats.push({\n id: 'delivery-unrecorded',\n message:\n 'No delivery events have been recorded for this campaign, so open, ' +\n 'click, complaint and unsubscribe rates cannot be computed — every ' +\n 'one of them is taken over delivered. Counts below are still real.',\n })\n }\n if (!clickTrackable) {\n caveats.push({\n id: 'click-tracking-unrecorded',\n message:\n 'This send did not record carrying an HTML part. Click tracking ' +\n 'rewrites links in the HTML, so a send without one reports zero ' +\n 'clicks whatever recipients did. The click count is shown; no click ' +\n 'rate is computed from it.',\n })\n }\n if (source.audienceSizeTruncated) {\n caveats.push({\n id: 'audience-truncated',\n message:\n 'Audience resolution stopped at its read ceiling, so the audience ' +\n 'size is a floor — the real audience is at least this large, and ' +\n 'every share taken over it is at most the figure shown.',\n })\n }\n if (Number(source.deferred ?? 0) > 0) {\n caveats.push({\n id: 'send-deferred',\n message:\n `${Number(source.deferred)} recipients were held back by the hourly ` +\n 'send governor and never received this campaign. They are counted in ' +\n 'addressed, not in sent.',\n })\n }\n\n return {\n sent,\n recipients,\n delivered,\n opens,\n clicks,\n uniqueOpens,\n uniqueClicks,\n bounced,\n complained,\n unsubscribes,\n rates,\n populations,\n caveats,\n }\n}\n\n/*==========================================\n * LINK-LEVEL CLICKS.\n *\n * Resend's `email.clicked` payload carries `data.click.link`, the destination\n * the recipient followed; `normalizeResendDeliveryEvents` already reads it\n * into `EmailDeliveryEvent.link`, and the per-recipient delivery log already\n * stores it. What did not exist was an aggregate — which is what \"link\n * clicks\" means, and it cannot be produced from the delivery log without\n * reading every recipient row for the campaign.\n *\n * So it is a WRITE-TIME rollup: one document per campaign,\n * `campaigns/{campaignId}/reports/links`, holding a bounded map. The report\n * reads exactly one document for the whole table.\n *=========================================*/\n\n/**\n * How many distinct destinations one campaign's rollup keeps.\n *\n * A CAP rather than a page size, and it exists because the map lives in a\n * single document with a 1 MiB ceiling. Clicks past the cap are counted in\n * {@link CampaignLinkRollup.overflowClicks} rather than dropped, so the\n * table's total still reconciles with `stats.clicks`.\n */\nexport const CAMPAIGN_LINK_ROLLUP_MAX = 50\n\n/**\n * Reduces a clicked URL to the key the rollup counts under.\n *\n * ## Why the query string is dropped\n *\n * Two reasons, and the second is the one that forces it:\n *\n * 1. **A campaign body goes through `resolveMergeTags` per recipient**, so a\n * link may carry a personalised query. Keying on the full URL would then\n * mint one rollup row per RECIPIENT — the aggregate degenerates into the\n * per-recipient log it exists to summarise, and it blows the cap on the\n * first campaign that does it.\n * 2. **A personalised query can carry the recipient's own address.** The\n * rollup is an aggregate read by everyone on the site's team; it must not\n * become a list of who clicked, and dropping the query is what guarantees\n * it cannot.\n *\n * ⚠️ The cost is real and is stated on the screen: two links to the same page\n * distinguished only by their UTM parameters count as ONE row. That is a\n * known limitation, not an oversight — see the note in the report card.\n *\n * @returns the normalized URL, or `null` for anything unparseable or not\n * http(s). A rollup key must be a URL a merchant recognises.\n */\nexport function campaignLinkKey(link: string | null | undefined): string | null {\n const raw = String(link ?? '').trim()\n if (!raw) return null\n try {\n const url = new URL(raw)\n if (url.protocol !== 'http:' && url.protocol !== 'https:') return null\n // Trailing slash normalised away so `/pricing` and `/pricing/` are one\n // row; the bare origin keeps its slash so the key is still a valid URL.\n const path = url.pathname.length > 1\n ? url.pathname.replace(/\\/+$/, '')\n : url.pathname\n return `${url.origin}${path}`\n } catch {\n return null\n }\n}\n\n/** One destination in the rollup. */\nexport interface CampaignLinkRow {\n url: string\n /** Click EVENTS on this destination. One reader clicking twice counts two. */\n clicks: number\n /** Share of the campaign's counted link clicks. */\n share: CampaignRate | null\n}\n\n/** The stored shape of `campaigns/{campaignId}/reports/links`. */\nexport interface CampaignLinkRollup {\n links?: Record<string, { url?: string; clicks?: number }>\n /** Clicks on destinations past {@link CAMPAIGN_LINK_ROLLUP_MAX}. */\n overflowClicks?: number\n /** Click events that arrived carrying no destination at all. */\n unattributedClicks?: number\n}\n\n/** What the link table renders. */\nexport interface CampaignLinkReport {\n rows: CampaignLinkRow[]\n /** Clicks counted against a named destination — the table's total. */\n attributedClicks: number\n overflowClicks: number\n unattributedClicks: number\n /** True once the cap bit, so the table says it is not the whole list. */\n truncated: boolean\n}\n\n/**\n * The link table, sorted by clicks descending.\n *\n * `share` is over ATTRIBUTED clicks — the clicks this table accounts for —\n * and not over `stats.clicks`. The two differ by the overflow and the\n * unattributed, and a share column that did not sum to 100% because of rows\n * that are not on screen is the kind of arithmetic a reader cannot check.\n * Both excluded figures are returned so the screen can state them.\n */\nexport function campaignLinkReport(\n rollup: CampaignLinkRollup | undefined,\n): CampaignLinkReport {\n const entries = Object.values(rollup?.links ?? {})\n .map((entry) => ({\n url: String(entry?.url ?? ''),\n clicks: Number(entry?.clicks ?? 0),\n }))\n .filter((entry) => entry.url && Number.isFinite(entry.clicks))\n const attributedClicks = entries.reduce((total, one) => total + one.clicks, 0)\n const rows = [...entries]\n .sort((a, b) => b.clicks - a.clicks || a.url.localeCompare(b.url))\n .map((entry) => ({\n ...entry,\n share: campaignRate(entry.clicks, attributedClicks, 'link clicks counted'),\n }))\n const overflowClicks = Number(rollup?.overflowClicks ?? 0)\n return {\n rows,\n attributedClicks,\n overflowClicks,\n unattributedClicks: Number(rollup?.unattributedClicks ?? 0),\n truncated: entries.length >= CAMPAIGN_LINK_ROLLUP_MAX || overflowClicks > 0,\n }\n}\n"],"names":["campaignRate","numerator","denominator","denominatorLabel","top","Number","bottom","isFinite","value","campaignReport","stats","source","sent","recipients","opens","clicks","bounced","complained","unsubscribes","delivered","undefined","uniqueOpens","uniqueClicks","clickTrackable","clickTracked","rates","delivery","open","click","clickToOpen","bounce","complaint","unsubscribe","audienceSize","populations","addPopulation","id","label","count","ofLabel","of","push","consented","consentedByOperator","grandfathered","consentWithheld","suppressed","cadenceHeld","caveats","message","audienceSizeTruncated","deferred","CAMPAIGN_LINK_ROLLUP_MAX","campaignLinkKey","link","raw","String","trim","url","URL","protocol","path","pathname","length","replace","origin","campaignLinkReport","rollup","entries","Object","values","links","map","entry","filter","attributedClicks","reduce","total","one","rows","sort","a","b","localeCompare","share","overflowClicks","unattributedClicks","truncated"],"mappings":"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"}
@@ -0,0 +1,327 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type CampaignCaveat } from './campaign-report';
18
+ /**
19
+ * WHAT A CAMPAIGN EARNED — the read half of the commerce↔email join.
20
+ *
21
+ * ## Why this is a join and not an attribution model
22
+ *
23
+ * Every compared ESP reconstructs campaign revenue probabilistically, because
24
+ * none of them owns the order. Klaviyo and Mailchimp watch someone else's
25
+ * store through an integration and a browser snippet, so their figure is a
26
+ * reconciliation against a foreign system and their window is the fudge
27
+ * factor that makes the reconciliation close. Commerce here is first-party:
28
+ * the click and the order are rows in one database, keyed the same way, so
29
+ * the "attribution" is a lookup.
30
+ *
31
+ * That does not make the MODEL choice go away — two campaigns can both have
32
+ * touched a buyer and only one can be credited — but it does mean the model
33
+ * is the only judgement in the number. There is no sampling, no identity
34
+ * resolution and no cookie.
35
+ *
36
+ * ## The model: LAST CLICK, inside a fixed 7-day window
37
+ *
38
+ * Stated in one sentence, which is the whole requirement: **an order is
39
+ * credited to the last campaign whose link the buyer clicked, if they clicked
40
+ * it within the {@link EMAIL_ATTRIBUTION_WINDOW_DAYS} days before they
41
+ * ordered.**
42
+ *
43
+ * Three decisions are inside that sentence.
44
+ *
45
+ * **Last touch, not multi-touch.** Multi-touch is the more honest description
46
+ * of how buying works and it is unpresentable: it splits one order across
47
+ * several campaigns by a rule the merchant did not choose, so no campaign's
48
+ * revenue is a number they can check against their own bank, and two
49
+ * campaigns' figures cannot be added or compared without knowing the split
50
+ * rule. HubSpot ships multi-touch and puts it behind an Enterprise plan and a
51
+ * consultant. A figure a merchant cannot explain to themselves is worse than
52
+ * no figure, and it is worse in the specific way that matters here: they will
53
+ * still make decisions with it.
54
+ *
55
+ * **A CLICK is the touch. An open is not.** An open is evidence about the
56
+ * recipient's mail client, not about the recipient — Apple's Mail Privacy
57
+ * Protection prefetches images, which inflated network-wide open rates by
58
+ * roughly 15% and means a large share of recorded opens had no human behind
59
+ * them. Crediting revenue to an open would therefore credit campaigns for
60
+ * orders from people who never saw them, and the error is not random: it
61
+ * concentrates on whichever campaign most recently reached an Apple Mail
62
+ * user. `email-delivery-log.ts` records the same preference for the same
63
+ * reason, and the audience rules already segment on clicks over opens.
64
+ *
65
+ * **Seven days, and not configurable.** Klaviyo's window is 1–30 days per
66
+ * channel; ActiveCampaign's is a fixed, unadjustable 7. Fixed is the better
67
+ * default here because a configurable window is a setting whose change
68
+ * silently rewrites history: yesterday's report and today's would disagree
69
+ * about a campaign that has not been touched since, with nothing on screen to
70
+ * say why. The stored record carries `windowDays` per order for exactly that
71
+ * reason — a future setting can be added without making the orders already
72
+ * attributed unreadable, because each one says which window it was judged
73
+ * under.
74
+ *
75
+ * ## GROSS and REFUNDED, never a decrement
76
+ *
77
+ * A refunded order must stop counting as revenue a campaign earned, and there
78
+ * are two ways to make it stop. Decrementing the gross figure makes a stored
79
+ * number mean one thing before a refund and another after, with nothing to
80
+ * distinguish them; recording the reversal beside it keeps both facts. This
81
+ * is the shape `contact-refund.ts` chose for `ltvCents`/`refundedCents` on the
82
+ * contact and the orders CSV chose for `amountUsd`/`refundedUsd`, and it is
83
+ * chosen again here so all three answer "what did this earn, net" the same
84
+ * way.
85
+ *
86
+ * So {@link campaignRevenueReport} reports gross, refunded and net, and NET
87
+ * is the figure the screen leads with. Net is clamped at zero for display
88
+ * only: a refund larger than the sale it reverses is arithmetically possible
89
+ * on an order attributed before a partial refund settled, and a negative
90
+ * campaign revenue is a sentence nobody can act on.
91
+ *
92
+ * ## Currencies are never summed
93
+ *
94
+ * Money is stored in minor units and no currency travels with it — every
95
+ * checkout door in this repo sets `currency: 'usd'` on the Stripe line items,
96
+ * so the amounts really are all USD, but that is a fact about the code rather
97
+ * than a field on the order. The rollup therefore buckets BY currency and
98
+ * this module never adds two buckets together. A campaign with two currencies
99
+ * renders two blocks and no total, and says so.
100
+ *
101
+ * The rule survives the merge across a container's emails.
102
+ * {@link campaignRevenueAcrossSends} keys its accumulator on the currency, so
103
+ * two rollups can only ever meet inside a bucket they already share — there
104
+ * is no code path in which a USD amount and a EUR amount reach the same
105
+ * addition, and no combined figure exists for a screen to print by accident.
106
+ */
107
+ /**
108
+ * Re-exported so a reader of the report has the window and the model name
109
+ * without reaching past this module for them — the screen prints both, and a
110
+ * window nobody can see is a window nobody can check.
111
+ */
112
+ export { EMAIL_ATTRIBUTION_MODEL, EMAIL_ATTRIBUTION_WINDOW_DAYS, EMAIL_ATTRIBUTION_WINDOW_MS, } from '@aglyn/shared-util-email';
113
+ /** One currency's totals as the rollup stores them. */
114
+ export interface CampaignRevenueCurrencyStored {
115
+ /** Minor units credited to this campaign, gross of refunds. */
116
+ grossCents?: number;
117
+ /** Minor units handed back on orders that had been credited. */
118
+ refundedCents?: number;
119
+ /** Orders credited to this campaign. */
120
+ orders?: number;
121
+ /** Of those, how many ended fully reversed. */
122
+ refundedOrders?: number;
123
+ }
124
+ /**
125
+ * The stored shape of `campaigns/{campaignId}/reports/revenue`.
126
+ *
127
+ * Its own document, for the reason the link rollup is its own document: the
128
+ * campaign document is read by the history list, the glance widget and the
129
+ * send path, and a map that grows with the campaign's sales would make every
130
+ * one of those reads larger. Split, it is read by the one screen that draws
131
+ * it.
132
+ */
133
+ export interface CampaignRevenueRollup {
134
+ byCurrency?: Record<string, CampaignRevenueCurrencyStored>;
135
+ /** The model the orders in this rollup were credited under. */
136
+ model?: string;
137
+ /** The window, in days, they were credited inside. */
138
+ windowDays?: number;
139
+ }
140
+ /**
141
+ * Money over a population, with the population named — {@link CampaignRate}'s
142
+ * rule applied to an average instead of a share.
143
+ *
144
+ * A percentage and an average go wrong the same way, so they carry the same
145
+ * guarantee: the denominator travels as data and the screen has to print it.
146
+ * "$0.42 per recipient" over an audience nobody named is the figure this
147
+ * whole reporting surface exists to refuse.
148
+ */
149
+ export interface CampaignMoneyPerMessage {
150
+ /** Minor units per message of the denominator. Fractional by nature. */
151
+ cents: number;
152
+ numeratorCents: number;
153
+ denominator: number;
154
+ denominatorLabel: string;
155
+ currency: string;
156
+ }
157
+ /** One currency's block on screen. */
158
+ export interface CampaignRevenueCurrencyReport {
159
+ /** Lowercase ISO code as the sale recorded it, e.g. `'usd'`. */
160
+ currency: string;
161
+ grossCents: number;
162
+ refundedCents: number;
163
+ /** `gross - refunded`, clamped at zero. */
164
+ netCents: number;
165
+ orders: number;
166
+ refundedOrders: number;
167
+ /**
168
+ * Net revenue per DELIVERED message, or `null` when it cannot be taken.
169
+ *
170
+ * Delivered is the denominator for the same reason every engagement rate on
171
+ * this report is taken over it: mail that bounced was never in front of a
172
+ * human, so counting it depresses a figure describing the audience with a
173
+ * fact about the address list.
174
+ *
175
+ * It is deliberately NOT taken over `audienceSize`. That figure is written
176
+ * by the FIRST batch only and is a floor when audience resolution hit its
177
+ * read ceiling, so a campaign delivered over six runs would divide six
178
+ * runs' revenue by one run's measure of the audience — the stale-population
179
+ * division this surface is built to make impossible.
180
+ */
181
+ netPerDelivered: CampaignMoneyPerMessage | null;
182
+ }
183
+ /** Everything the revenue section renders. */
184
+ export interface CampaignRevenueReport {
185
+ /** One block per currency, largest net first. Never summed together. */
186
+ currencies: CampaignRevenueCurrencyReport[];
187
+ /** Orders credited to this campaign, across every currency. */
188
+ attributedOrders: number;
189
+ /**
190
+ * Whether any attribution has ever been recorded for this campaign.
191
+ *
192
+ * `false` means the rollup document does not exist, which is NOT the same
193
+ * as "this campaign earned nothing" — it is also every campaign sent before
194
+ * the join existed, and every campaign on a site with no store. The screen
195
+ * renders the difference rather than printing a zero for both.
196
+ */
197
+ recorded: boolean;
198
+ /** More than one currency is present, so no total may be shown. */
199
+ multiCurrency: boolean;
200
+ /** The model these figures were credited under, as stored. */
201
+ model: string;
202
+ /** The window they were credited inside, as stored. */
203
+ windowDays: number;
204
+ caveats: CampaignCaveat[];
205
+ }
206
+ /**
207
+ * Why there is no total, in the words both reports use.
208
+ *
209
+ * One sentence, one definition. A send's report and its campaign's report
210
+ * make the same refusal for the same reason, and two copies of the sentence
211
+ * is how one of them comes to be softened into a promise of a total.
212
+ */
213
+ export declare const REVENUE_MULTI_CURRENCY_MESSAGE: string;
214
+ /**
215
+ * Money per message, or `null` when the division cannot honestly be taken.
216
+ *
217
+ * The three refusals are {@link campaignRate}'s, and this defers to it rather
218
+ * than restating them: a zero denominator, an unrecorded denominator, and a
219
+ * non-finite input all answer `null` there, so a second implementation of
220
+ * "when may we divide" cannot drift from the first.
221
+ */
222
+ export declare function campaignMoneyPerMessage(numeratorCents: number, denominator: number | undefined, denominatorLabel: string, currency: string): CampaignMoneyPerMessage | null;
223
+ /**
224
+ * Turns the stored rollup into the revenue section.
225
+ *
226
+ * `delivered` comes from the campaign's own `stats` and is passed in rather
227
+ * than re-read, so the numerator and the denominator on screen are taken from
228
+ * the same instant. It is `null` when no delivery event has ever been
229
+ * recorded — the campaign predates the delivery webhook, or the events are
230
+ * still in flight — and every figure over it is then withheld with a caveat,
231
+ * never substituted for `sent`.
232
+ */
233
+ export declare function campaignRevenueReport(options: {
234
+ rollup: CampaignRevenueRollup | undefined;
235
+ /** `stats.delivered`, or `null` when it was never recorded. */
236
+ delivered: number | null;
237
+ /** True while the send is still working through its audience. */
238
+ midFlight?: boolean;
239
+ }): CampaignRevenueReport;
240
+ /** One currency's totals, merged across a campaign's emails. */
241
+ export interface CampaignRevenueCurrencyAcrossSends {
242
+ /** Lowercase ISO code as the sales recorded it, e.g. `'usd'`. */
243
+ currency: string;
244
+ grossCents: number;
245
+ refundedCents: number;
246
+ /** `gross - refunded` over the whole campaign, clamped once at zero. */
247
+ netCents: number;
248
+ orders: number;
249
+ refundedOrders: number;
250
+ /** Emails of the campaign whose revenue record holds this currency. */
251
+ emails: number;
252
+ }
253
+ /** Everything a campaign container's revenue section renders. */
254
+ export interface CampaignRevenueAcrossSends {
255
+ /** One block per currency, largest net first. Never summed together. */
256
+ currencies: CampaignRevenueCurrencyAcrossSends[];
257
+ /**
258
+ * Orders credited across every currency.
259
+ *
260
+ * A COUNT, which is why it may cross currencies when the money may not: an
261
+ * order is one order whatever it was paid in, and counting two of them
262
+ * loses nothing. Adding their amounts loses the unit.
263
+ */
264
+ attributedOrders: number;
265
+ /** Emails whose revenue record was looked for. */
266
+ read: number;
267
+ /**
268
+ * Of those, how many have a revenue record at all.
269
+ *
270
+ * The rollup is created by the attribution writer on the first order it
271
+ * credits, so an email with no record has never been credited with one.
272
+ * Zero across the whole campaign is therefore the container's version of
273
+ * {@link CampaignRevenueReport.recorded} being `false`: it is not "this
274
+ * campaign earned nothing", it is also every campaign sent before the join
275
+ * existed and every campaign on a site with no store.
276
+ */
277
+ recorded: number;
278
+ /** More than one currency is present, so no total may be shown. */
279
+ multiCurrency: boolean;
280
+ /** The models these emails were credited under, distinct and sorted. */
281
+ models: string[];
282
+ /** The windows, in days, they were credited inside, distinct and sorted. */
283
+ windowDays: number[];
284
+ caveats: CampaignCaveat[];
285
+ }
286
+ /**
287
+ * MERGES A CAMPAIGN'S EMAILS INTO ONE REVENUE SECTION, per currency.
288
+ *
289
+ * `reports/revenue` is written per SEND, so a container's figure is one
290
+ * document per email and a merge. The merge is the whole risk: two emails of
291
+ * one campaign can have earned in different currencies, and adding their
292
+ * amounts produces a number that is wrong with nothing on screen to show it.
293
+ *
294
+ * ## The currency is the accumulator's KEY, not a field beside the amount
295
+ *
296
+ * That is the structural half of the guarantee. Amounts are added into a map
297
+ * keyed by currency, so two amounts can only reach the same addition when
298
+ * they already carry the same code — a USD figure and a EUR figure have no
299
+ * path to each other, whatever a later caller asks for. There is no combined
300
+ * field on the result, so a screen cannot print a cross-currency total by
301
+ * reading the wrong property, and
302
+ * {@link CampaignRevenueAcrossSends.multiCurrency} is what tells the screen
303
+ * to label the blocks.
304
+ *
305
+ * ## Clamped ONCE, over the campaign
306
+ *
307
+ * The single-send report clamps its net at zero because a partial refund can
308
+ * settle against an order credited at the full charge. Doing that per email
309
+ * and then summing would let one over-refunded email keep money a sibling
310
+ * email handed back — an email at -$50 clamped to $0 beside one at $200 would
311
+ * report $200 for a campaign holding $150. So gross and refunded are summed
312
+ * as they stand and the difference is clamped once, at the end.
313
+ *
314
+ * ## No per-message average, deliberately
315
+ *
316
+ * The send report divides net revenue by that send's own `delivered`, taken
317
+ * from the same document at the same instant. A container has no such pair.
318
+ * Its delivery total is summed over the emails that RECORDED a delivery count
319
+ * and its revenue over the emails that have a revenue record, and those are
320
+ * different subsets of the campaign — so the quotient would be an average
321
+ * over a population nobody named, which is the defect the whole reporting
322
+ * surface is built to refuse. The amounts are reported without one.
323
+ *
324
+ * @param rollups - one entry per email read, `undefined` where no record
325
+ * exists.
326
+ */
327
+ export declare function campaignRevenueAcrossSends(rollups: readonly (CampaignRevenueRollup | undefined)[]): CampaignRevenueAcrossSends;