@aglyn/plugins-marketing 1.0.0-beta.221 → 1.0.0-beta.222
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.
- package/README.md +4 -1
- package/package.json +15 -15
- package/src/lib/components/campaign-conversions-card.js +53 -64
- package/src/lib/components/campaign-conversions-card.js.map +1 -1
- package/src/lib/components/campaign-detail-card.js +2 -1
- package/src/lib/components/campaign-detail-card.js.map +1 -1
- package/src/lib/components/campaign-edit-drawer.js +1 -1
- package/src/lib/components/campaign-edit-drawer.js.map +1 -1
- package/src/lib/components/campaign-queries.js +1 -1
- package/src/lib/components/campaign-queries.js.map +1 -1
- package/src/lib/components/campaign-reach-sections.js +1 -1
- package/src/lib/components/campaign-reach-sections.js.map +1 -1
- package/src/lib/components/campaign-report-card.js +5 -4
- package/src/lib/components/campaign-report-card.js.map +1 -1
- package/src/lib/components/campaigns-card.js +1 -1
- package/src/lib/components/campaigns-card.js.map +1 -1
- package/src/lib/components/email-compose-card.js +1 -1
- package/src/lib/components/email-compose-card.js.map +1 -1
- package/src/lib/components/email-detail.js +5 -4
- package/src/lib/components/email-detail.js.map +1 -1
- package/src/lib/components/email-template-report-card.d.ts +53 -0
- package/src/lib/components/email-template-report-card.js +469 -0
- package/src/lib/components/email-template-report-card.js.map +1 -0
- package/src/lib/components/emails-list-card.js +2 -2
- package/src/lib/components/emails-list-card.js.map +1 -1
- package/src/lib/components/host-experiments-card.component.js +3 -2
- package/src/lib/components/host-experiments-card.component.js.map +1 -1
- package/src/lib/components/site-runtime.js +3 -1
- package/src/lib/components/site-runtime.js.map +1 -1
- package/src/lib/declarations.server.d.ts +9 -0
- package/src/lib/declarations.server.js +62 -0
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/model/campaign-container.d.ts +503 -0
- package/src/lib/model/campaign-container.js +498 -0
- package/src/lib/model/campaign-container.js.map +1 -0
- package/src/lib/model/campaign-conversions.d.ts +8 -1
- package/src/lib/model/campaign-conversions.js +11 -0
- package/src/lib/model/campaign-conversions.js.map +1 -1
- package/src/lib/model/campaign-report.d.ts +158 -0
- package/src/lib/model/campaign-report.js +268 -0
- package/src/lib/model/campaign-report.js.map +1 -0
- package/src/lib/model/campaign-revenue.d.ts +3 -3
- package/src/lib/model/campaign-revenue.js +3 -3
- package/src/lib/model/campaign-revenue.js.map +1 -1
- package/src/lib/model/campaign-send-time.js +2 -2
- package/src/lib/model/campaign-send-time.js.map +1 -1
- package/src/lib/model/compile-client-automations.d.ts +2 -2
- package/src/lib/model/compile-client-automations.js +3 -3
- package/src/lib/model/compile-client-automations.js.map +1 -1
- package/src/lib/model/email-record.d.ts +175 -0
- package/src/lib/model/email-record.js +206 -0
- package/src/lib/model/email-record.js.map +1 -0
- package/src/lib/model/site-contract.d.ts +2 -2
- package/src/lib/model/site-contract.js.map +1 -1
- package/src/lib/model/template-report.d.ts +158 -0
- package/src/lib/model/template-report.js +249 -0
- package/src/lib/model/template-report.js.map +1 -0
- package/src/lib/plugin.js +16 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/preview-props.js.map +1 -1
- package/src/lib/server/campaign-attribution-store.d.ts +55 -0
- package/src/lib/server/campaign-attribution-store.js +98 -0
- package/src/lib/server/campaign-attribution-store.js.map +1 -0
- package/src/lib/server/campaign-conversion-attribution.d.ts +283 -0
- package/src/lib/server/campaign-conversion-attribution.js +447 -0
- package/src/lib/server/campaign-conversion-attribution.js.map +1 -0
- package/src/lib/server/campaign-manage.js +1 -1
- package/src/lib/server/campaign-manage.js.map +1 -1
- package/src/lib/server/campaign-org-refs.js +1 -1
- package/src/lib/server/campaign-org-refs.js.map +1 -1
- package/src/lib/server/campaign-recipients.js +7 -7
- package/src/lib/server/campaign-recipients.js.map +1 -1
- package/src/lib/server/campaign-send.js +25 -19
- package/src/lib/server/campaign-send.js.map +1 -1
- package/src/lib/server/conversion-creditor.d.ts +18 -0
- package/src/lib/server/conversion-creditor.js +131 -0
- package/src/lib/server/conversion-creditor.js.map +1 -0
- package/src/lib/server/email-campaign-touch.d.ts +81 -0
- package/src/lib/server/email-campaign-touch.js +227 -0
- package/src/lib/server/email-campaign-touch.js.map +1 -0
- package/src/lib/server/email-events.js +8 -6
- package/src/lib/server/email-events.js.map +1 -1
- package/src/lib/server/email-revenue-attribution.d.ts +186 -0
- package/src/lib/server/email-revenue-attribution.js +322 -0
- package/src/lib/server/email-revenue-attribution.js.map +1 -0
- package/src/lib/server/email-send-record-index.d.ts +34 -0
- package/src/lib/server/email-send-record-index.js +90 -0
- package/src/lib/server/email-send-record-index.js.map +1 -0
- package/src/lib/server/get-client-automations.js.map +1 -1
- package/src/lib/server/marketing-figures.js +3 -3
- package/src/lib/server/marketing-figures.js.map +1 -1
- package/src/lib/server/send-tally.d.ts +32 -0
- package/src/lib/server/send-tally.js +54 -0
- package/src/lib/server/send-tally.js.map +1 -0
- package/src/lib/testing/stand-in-person-records.d.ts +35 -0
- package/src/lib/testing/stand-in-person-records.js +67 -0
- package/src/lib/testing/stand-in-person-records.js.map +1 -0
- package/src/lib/testing/stand-in-record-counts.d.ts +26 -0
- package/src/lib/testing/stand-in-record-counts.js +70 -0
- package/src/lib/testing/stand-in-record-counts.js.map +1 -0
- package/src/lib/transfer/campaigns-package.d.ts +72 -0
- package/src/lib/transfer/campaigns-package.js +145 -0
- package/src/lib/transfer/campaigns-package.js.map +1 -0
- package/src/lib/transfer/campaigns-package.server.d.ts +33 -0
- package/src/lib/transfer/campaigns-package.server.js +305 -0
- package/src/lib/transfer/campaigns-package.server.js.map +1 -0
|
@@ -20,7 +20,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
20
20
|
* feature plugin, and a window defined on both sides of the join would drift
|
|
21
21
|
* into a number credited under one rule and printed under another.
|
|
22
22
|
*/ import { EMAIL_ATTRIBUTION_MODEL, EMAIL_ATTRIBUTION_WINDOW_DAYS } from "@aglyn/shared-util-email";
|
|
23
|
-
import {
|
|
23
|
+
import { sendRate } from "@aglyn/shared-ui-email-campaigns/model/send-report";
|
|
24
24
|
/**
|
|
25
25
|
* WHAT A CAMPAIGN EARNED — the read half of the commerce↔email join.
|
|
26
26
|
*
|
|
@@ -128,12 +128,12 @@ import { campaignRate } from "@aglyn/shared-ui-email-campaigns/model/campaign-re
|
|
|
128
128
|
/**
|
|
129
129
|
* Money per message, or `null` when the division cannot honestly be taken.
|
|
130
130
|
*
|
|
131
|
-
* The three refusals are {@link
|
|
131
|
+
* The three refusals are {@link sendRate}'s, and this defers to it rather
|
|
132
132
|
* than restating them: a zero denominator, an unrecorded denominator, and a
|
|
133
133
|
* non-finite input all answer `null` there, so a second implementation of
|
|
134
134
|
* "when may we divide" cannot drift from the first.
|
|
135
135
|
*/ export function campaignMoneyPerMessage(numeratorCents, denominator, denominatorLabel, currency) {
|
|
136
|
-
const divisible =
|
|
136
|
+
const divisible = sendRate(numeratorCents, denominator, denominatorLabel);
|
|
137
137
|
if (!divisible) return null;
|
|
138
138
|
return {
|
|
139
139
|
cents: divisible.value,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/campaign-revenue.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 * The window and the model name come from `@aglyn/shared-util-email`, not\n * from here. The writer is in `tenant-data-admin`, which may not import a\n * feature plugin, and a window defined on both sides of the join would drift\n * into a number credited under one rule and printed under another.\n */\nimport {\n EMAIL_ATTRIBUTION_MODEL,\n EMAIL_ATTRIBUTION_WINDOW_DAYS,\n} from '@aglyn/shared-util-email'\nimport { campaignRate, type CampaignCaveat } from '@aglyn/shared-ui-email-campaigns/model/campaign-report'\n\n/**\n * WHAT A CAMPAIGN EARNED — the read half of the commerce↔email join.\n *\n * ## Why this is a join and not an attribution model\n *\n * Every compared ESP reconstructs campaign revenue probabilistically, because\n * none of them owns the order. Klaviyo and Mailchimp watch someone else's\n * store through an integration and a browser snippet, so their figure is a\n * reconciliation against a foreign system and their window is the fudge\n * factor that makes the reconciliation close. Commerce here is first-party:\n * the click and the order are rows in one database, keyed the same way, so\n * the \"attribution\" is a lookup.\n *\n * That does not make the MODEL choice go away — two campaigns can both have\n * touched a buyer and only one can be credited — but it does mean the model\n * is the only judgement in the number. There is no sampling, no identity\n * resolution and no cookie.\n *\n * ## The model: LAST CLICK, inside a fixed 7-day window\n *\n * Stated in one sentence, which is the whole requirement: **an order is\n * credited to the last campaign whose link the buyer clicked, if they clicked\n * it within the {@link EMAIL_ATTRIBUTION_WINDOW_DAYS} days before they\n * ordered.**\n *\n * Three decisions are inside that sentence.\n *\n * **Last touch, not multi-touch.** Multi-touch is the more honest description\n * of how buying works and it is unpresentable: it splits one order across\n * several campaigns by a rule the merchant did not choose, so no campaign's\n * revenue is a number they can check against their own bank, and two\n * campaigns' figures cannot be added or compared without knowing the split\n * rule. HubSpot ships multi-touch and puts it behind an Enterprise plan and a\n * consultant. A figure a merchant cannot explain to themselves is worse than\n * no figure, and it is worse in the specific way that matters here: they will\n * still make decisions with it.\n *\n * **A CLICK is the touch. An open is not.** An open is evidence about the\n * recipient's mail client, not about the recipient — Apple's Mail Privacy\n * Protection prefetches images, which inflated network-wide open rates by\n * roughly 15% and means a large share of recorded opens had no human behind\n * them. Crediting revenue to an open would therefore credit campaigns for\n * orders from people who never saw them, and the error is not random: it\n * concentrates on whichever campaign most recently reached an Apple Mail\n * user. `email-delivery-log.ts` records the same preference for the same\n * reason, and the audience rules already segment on clicks over opens.\n *\n * **Seven days, and not configurable.** Klaviyo's window is 1–30 days per\n * channel; ActiveCampaign's is a fixed, unadjustable 7. Fixed is the better\n * default here because a configurable window is a setting whose change\n * silently rewrites history: yesterday's report and today's would disagree\n * about a campaign that has not been touched since, with nothing on screen to\n * say why. The stored record carries `windowDays` per order for exactly that\n * reason — a future setting can be added without making the orders already\n * attributed unreadable, because each one says which window it was judged\n * under.\n *\n * ## GROSS and REFUNDED, never a decrement\n *\n * A refunded order must stop counting as revenue a campaign earned, and there\n * are two ways to make it stop. Decrementing the gross figure makes a stored\n * number mean one thing before a refund and another after, with nothing to\n * distinguish them; recording the reversal beside it keeps both facts. This\n * is the shape `contact-refund.ts` chose for `ltvCents`/`refundedCents` on the\n * contact and the orders CSV chose for `amountUsd`/`refundedUsd`, and it is\n * chosen again here so all three answer \"what did this earn, net\" the same\n * way.\n *\n * So {@link campaignRevenueReport} reports gross, refunded and net, and NET\n * is the figure the screen leads with. Net is clamped at zero for display\n * only: a refund larger than the sale it reverses is arithmetically possible\n * on an order attributed before a partial refund settled, and a negative\n * campaign revenue is a sentence nobody can act on.\n *\n * ## Currencies are never summed\n *\n * Money is stored in minor units and no currency travels with it — every\n * checkout door in this repo sets `currency: 'usd'` on the Stripe line items,\n * so the amounts really are all USD, but that is a fact about the code rather\n * than a field on the order. The rollup therefore buckets BY currency and\n * this module never adds two buckets together. A campaign with two currencies\n * renders two blocks and no total, and says so.\n *\n * The rule survives the merge across a container's emails.\n * {@link campaignRevenueAcrossSends} keys its accumulator on the currency, so\n * two rollups can only ever meet inside a bucket they already share — there\n * is no code path in which a USD amount and a EUR amount reach the same\n * addition, and no combined figure exists for a screen to print by accident.\n */\n\n/**\n * Re-exported so a reader of the report has the window and the model name\n * without reaching past this module for them — the screen prints both, and a\n * window nobody can see is a window nobody can check.\n */\nexport {\n EMAIL_ATTRIBUTION_MODEL,\n EMAIL_ATTRIBUTION_WINDOW_DAYS,\n EMAIL_ATTRIBUTION_WINDOW_MS,\n} from '@aglyn/shared-util-email'\n\n/** One currency's totals as the rollup stores them. */\nexport interface CampaignRevenueCurrencyStored {\n /** Minor units credited to this campaign, gross of refunds. */\n grossCents?: number\n /** Minor units handed back on orders that had been credited. */\n refundedCents?: number\n /** Orders credited to this campaign. */\n orders?: number\n /** Of those, how many ended fully reversed. */\n refundedOrders?: number\n}\n\n/**\n * The stored shape of `campaigns/{campaignId}/reports/revenue`.\n *\n * Its own document, for the reason the link rollup is its own document: the\n * campaign document is read by the history list, the glance widget and the\n * send path, and a map that grows with the campaign's sales would make every\n * one of those reads larger. Split, it is read by the one screen that draws\n * it.\n */\nexport interface CampaignRevenueRollup {\n byCurrency?: Record<string, CampaignRevenueCurrencyStored>\n /** The model the orders in this rollup were credited under. */\n model?: string\n /** The window, in days, they were credited inside. */\n windowDays?: number\n}\n\n/**\n * Money over a population, with the population named — {@link CampaignRate}'s\n * rule applied to an average instead of a share.\n *\n * A percentage and an average go wrong the same way, so they carry the same\n * guarantee: the denominator travels as data and the screen has to print it.\n * \"$0.42 per recipient\" over an audience nobody named is the figure this\n * whole reporting surface exists to refuse.\n */\nexport interface CampaignMoneyPerMessage {\n /** Minor units per message of the denominator. Fractional by nature. */\n cents: number\n numeratorCents: number\n denominator: number\n denominatorLabel: string\n currency: string\n}\n\n/** One currency's block on screen. */\nexport interface CampaignRevenueCurrencyReport {\n /** Lowercase ISO code as the sale recorded it, e.g. `'usd'`. */\n currency: string\n grossCents: number\n refundedCents: number\n /** `gross - refunded`, clamped at zero. */\n netCents: number\n orders: number\n refundedOrders: number\n /**\n * Net revenue per DELIVERED message, or `null` when it cannot be taken.\n *\n * Delivered is the denominator for the same reason every engagement rate on\n * this report is taken over it: mail that bounced was never in front of a\n * human, so counting it depresses a figure describing the audience with a\n * fact about the address list.\n *\n * It is deliberately NOT taken over `audienceSize`. That figure is written\n * by the FIRST batch only and is a floor when audience resolution hit its\n * read ceiling, so a campaign delivered over six runs would divide six\n * runs' revenue by one run's measure of the audience — the stale-population\n * division this surface is built to make impossible.\n */\n netPerDelivered: CampaignMoneyPerMessage | null\n}\n\n/** Everything the revenue section renders. */\nexport interface CampaignRevenueReport {\n /** One block per currency, largest net first. Never summed together. */\n currencies: CampaignRevenueCurrencyReport[]\n /** Orders credited to this campaign, across every currency. */\n attributedOrders: number\n /**\n * Whether any attribution has ever been recorded for this campaign.\n *\n * `false` means the rollup document does not exist, which is NOT the same\n * as \"this campaign earned nothing\" — it is also every campaign sent before\n * the join existed, and every campaign on a site with no store. The screen\n * renders the difference rather than printing a zero for both.\n */\n recorded: boolean\n /** More than one currency is present, so no total may be shown. */\n multiCurrency: boolean\n /** The model these figures were credited under, as stored. */\n model: string\n /** The window they were credited inside, as stored. */\n windowDays: number\n caveats: CampaignCaveat[]\n}\n\n/**\n * Why there is no total, in the words both reports use.\n *\n * One sentence, one definition. A send's report and its campaign's report\n * make the same refusal for the same reason, and two copies of the sentence\n * is how one of them comes to be softened into a promise of a total.\n */\nexport const REVENUE_MULTI_CURRENCY_MESSAGE =\n 'This campaign earned in more than one currency. Each is reported on its ' +\n 'own — nothing here converts between them, so there is deliberately no ' +\n 'combined total.'\n\n/** A stored count as a non-negative integer. */\nfunction count(raw: unknown): number {\n const value = Math.floor(Number(raw ?? 0))\n return Number.isFinite(value) && value > 0 ? value : 0\n}\n\n/**\n * Money per message, or `null` when the division cannot honestly be taken.\n *\n * The three refusals are {@link campaignRate}'s, and this defers to it rather\n * than restating them: a zero denominator, an unrecorded denominator, and a\n * non-finite input all answer `null` there, so a second implementation of\n * \"when may we divide\" cannot drift from the first.\n */\nexport function campaignMoneyPerMessage(\n numeratorCents: number,\n denominator: number | undefined,\n denominatorLabel: string,\n currency: string,\n): CampaignMoneyPerMessage | null {\n const divisible = campaignRate(numeratorCents, denominator, denominatorLabel)\n if (!divisible) return null\n return {\n cents: divisible.value,\n numeratorCents: divisible.numerator,\n denominator: divisible.denominator,\n denominatorLabel,\n currency,\n }\n}\n\n/**\n * Turns the stored rollup into the revenue section.\n *\n * `delivered` comes from the campaign's own `stats` and is passed in rather\n * than re-read, so the numerator and the denominator on screen are taken from\n * the same instant. It is `null` when no delivery event has ever been\n * recorded — the campaign predates the delivery webhook, or the events are\n * still in flight — and every figure over it is then withheld with a caveat,\n * never substituted for `sent`.\n */\nexport function campaignRevenueReport(options: {\n rollup: CampaignRevenueRollup | undefined\n /** `stats.delivered`, or `null` when it was never recorded. */\n delivered: number | null\n /** True while the send is still working through its audience. */\n midFlight?: boolean\n}): CampaignRevenueReport {\n const { rollup, delivered, midFlight } = options\n const stored = rollup?.byCurrency ?? {}\n const caveats: CampaignCaveat[] = []\n\n const currencies: CampaignRevenueCurrencyReport[] = Object.entries(stored)\n .map(([currency, totals]) => {\n const grossCents = count(totals?.grossCents)\n const refundedCents = count(totals?.refundedCents)\n /*\n * CLAMPED, and only here at the point of display.\n *\n * Both stored figures are monotonic counters of money that really\n * moved in one direction, so neither can be negative; their DIFFERENCE\n * can be, for one reason — an order credited to a campaign and then\n * refunded by more than the amount that was credited, which happens\n * when a partial refund settles against an order whose attributed\n * amount was the charge at the time. Clamping at write time would\n * erase the evidence; clamping at read time keeps the stored pair\n * intact and stops the screen printing a campaign with negative\n * earnings, which is not a sentence anybody can act on.\n */\n const netCents = Math.max(0, grossCents - refundedCents)\n return {\n currency,\n grossCents,\n refundedCents,\n netCents,\n orders: count(totals?.orders),\n refundedOrders: count(totals?.refundedOrders),\n netPerDelivered: campaignMoneyPerMessage(\n netCents,\n delivered ?? undefined,\n 'delivered',\n currency,\n ),\n }\n })\n .filter((entry) => entry.orders > 0 || entry.grossCents > 0)\n .sort(\n (a, b) => b.netCents - a.netCents || a.currency.localeCompare(b.currency),\n )\n\n const attributedOrders = currencies.reduce(\n (total, entry) => total + entry.orders,\n 0,\n )\n const multiCurrency = currencies.length > 1\n\n if (delivered === null && currencies.length) {\n caveats.push({\n id: 'revenue-denominator-unrecorded',\n message:\n 'No delivery events have been recorded for this campaign, so revenue ' +\n 'per delivered message cannot be computed. The amounts below are ' +\n 'still real.',\n })\n }\n if (multiCurrency) {\n caveats.push({\n id: 'revenue-multi-currency',\n message: REVENUE_MULTI_CURRENCY_MESSAGE,\n })\n }\n if (midFlight && currencies.length) {\n caveats.push({\n id: 'revenue-mid-flight',\n message:\n 'This campaign is still going out. Revenue and delivered messages ' +\n 'are both still rising, so every figure below is a running total ' +\n 'rather than a final one.',\n })\n }\n\n return {\n currencies,\n attributedOrders,\n recorded: rollup !== undefined,\n multiCurrency,\n model: String(rollup?.model ?? EMAIL_ATTRIBUTION_MODEL),\n windowDays: count(rollup?.windowDays) || EMAIL_ATTRIBUTION_WINDOW_DAYS,\n caveats,\n }\n}\n\n/** One currency's totals, merged across a campaign's emails. */\nexport interface CampaignRevenueCurrencyAcrossSends {\n /** Lowercase ISO code as the sales recorded it, e.g. `'usd'`. */\n currency: string\n grossCents: number\n refundedCents: number\n /** `gross - refunded` over the whole campaign, clamped once at zero. */\n netCents: number\n orders: number\n refundedOrders: number\n /** Emails of the campaign whose revenue record holds this currency. */\n emails: number\n}\n\n/** Everything a campaign container's revenue section renders. */\nexport interface CampaignRevenueAcrossSends {\n /** One block per currency, largest net first. Never summed together. */\n currencies: CampaignRevenueCurrencyAcrossSends[]\n /**\n * Orders credited across every currency.\n *\n * A COUNT, which is why it may cross currencies when the money may not: an\n * order is one order whatever it was paid in, and counting two of them\n * loses nothing. Adding their amounts loses the unit.\n */\n attributedOrders: number\n /** Emails whose revenue record was looked for. */\n read: number\n /**\n * Of those, how many have a revenue record at all.\n *\n * The rollup is created by the attribution writer on the first order it\n * credits, so an email with no record has never been credited with one.\n * Zero across the whole campaign is therefore the container's version of\n * {@link CampaignRevenueReport.recorded} being `false`: it is not \"this\n * campaign earned nothing\", it is also every campaign sent before the join\n * existed and every campaign on a site with no store.\n */\n recorded: number\n /** More than one currency is present, so no total may be shown. */\n multiCurrency: boolean\n /** The models these emails were credited under, distinct and sorted. */\n models: string[]\n /** The windows, in days, they were credited inside, distinct and sorted. */\n windowDays: number[]\n caveats: CampaignCaveat[]\n}\n\n/**\n * MERGES A CAMPAIGN'S EMAILS INTO ONE REVENUE SECTION, per currency.\n *\n * `reports/revenue` is written per SEND, so a container's figure is one\n * document per email and a merge. The merge is the whole risk: two emails of\n * one campaign can have earned in different currencies, and adding their\n * amounts produces a number that is wrong with nothing on screen to show it.\n *\n * ## The currency is the accumulator's KEY, not a field beside the amount\n *\n * That is the structural half of the guarantee. Amounts are added into a map\n * keyed by currency, so two amounts can only reach the same addition when\n * they already carry the same code — a USD figure and a EUR figure have no\n * path to each other, whatever a later caller asks for. There is no combined\n * field on the result, so a screen cannot print a cross-currency total by\n * reading the wrong property, and\n * {@link CampaignRevenueAcrossSends.multiCurrency} is what tells the screen\n * to label the blocks.\n *\n * ## Clamped ONCE, over the campaign\n *\n * The single-send report clamps its net at zero because a partial refund can\n * settle against an order credited at the full charge. Doing that per email\n * and then summing would let one over-refunded email keep money a sibling\n * email handed back — an email at -$50 clamped to $0 beside one at $200 would\n * report $200 for a campaign holding $150. So gross and refunded are summed\n * as they stand and the difference is clamped once, at the end.\n *\n * ## No per-message average, deliberately\n *\n * The send report divides net revenue by that send's own `delivered`, taken\n * from the same document at the same instant. A container has no such pair.\n * Its delivery total is summed over the emails that RECORDED a delivery count\n * and its revenue over the emails that have a revenue record, and those are\n * different subsets of the campaign — so the quotient would be an average\n * over a population nobody named, which is the defect the whole reporting\n * surface is built to refuse. The amounts are reported without one.\n *\n * @param rollups - one entry per email read, `undefined` where no record\n * exists.\n */\nexport function campaignRevenueAcrossSends(\n rollups: readonly (CampaignRevenueRollup | undefined)[],\n): CampaignRevenueAcrossSends {\n const byCurrency = new Map<string, CampaignRevenueCurrencyAcrossSends>()\n const models = new Set<string>()\n const windows = new Set<number>()\n let recorded = 0\n\n for (const rollup of rollups) {\n if (!rollup) continue\n recorded += 1\n models.add(String(rollup.model ?? EMAIL_ATTRIBUTION_MODEL))\n windows.add(count(rollup.windowDays) || EMAIL_ATTRIBUTION_WINDOW_DAYS)\n for (const [code, stored] of Object.entries(rollup.byCurrency ?? {})) {\n const currency = String(code).trim().toLowerCase()\n if (!currency) continue\n const entry = byCurrency.get(currency) ?? {\n currency,\n grossCents: 0,\n refundedCents: 0,\n netCents: 0,\n orders: 0,\n refundedOrders: 0,\n emails: 0,\n }\n entry.grossCents += count(stored?.grossCents)\n entry.refundedCents += count(stored?.refundedCents)\n entry.orders += count(stored?.orders)\n entry.refundedOrders += count(stored?.refundedOrders)\n // One rollup holds at most one bucket per currency, so this counts\n // EMAILS that earned in it rather than orders.\n entry.emails += 1\n byCurrency.set(currency, entry)\n }\n }\n\n const currencies = [...byCurrency.values()]\n .map((entry) => ({\n ...entry,\n netCents: Math.max(0, entry.grossCents - entry.refundedCents),\n }))\n .filter((entry) => entry.orders > 0 || entry.grossCents > 0)\n .sort(\n (a, b) => b.netCents - a.netCents || a.currency.localeCompare(b.currency),\n )\n\n const multiCurrency = currencies.length > 1\n const caveats: CampaignCaveat[] = []\n if (multiCurrency) {\n caveats.push({\n id: 'revenue-multi-currency',\n message: REVENUE_MULTI_CURRENCY_MESSAGE,\n })\n }\n /*\n * Two rules met in one figure, which is the currency problem in another\n * dimension: a campaign whose older emails were credited under a different\n * model or a different window holds amounts that were judged by different\n * tests. They are still money in one unit, so unlike two currencies they\n * add — the total stands, and the reader is told the rule behind it is not\n * single.\n */\n if (models.size > 1 || windows.size > 1) {\n caveats.push({\n id: 'revenue-mixed-model',\n message:\n 'This campaign’s emails were not all credited under the same rule. ' +\n 'The amounts are real, but they were judged by different attribution ' +\n 'models or windows, so they are not strictly comparable with each ' +\n 'other.',\n })\n }\n\n return {\n currencies,\n attributedOrders: currencies.reduce(\n (total, entry) => total + entry.orders,\n 0,\n ),\n read: rollups.length,\n recorded,\n multiCurrency,\n models: [...models].sort(),\n windowDays: [...windows].sort((a, b) => a - b),\n caveats,\n }\n}\n"],"names":["EMAIL_ATTRIBUTION_MODEL","EMAIL_ATTRIBUTION_WINDOW_DAYS","campaignRate","EMAIL_ATTRIBUTION_WINDOW_MS","REVENUE_MULTI_CURRENCY_MESSAGE","count","raw","value","Math","floor","Number","isFinite","campaignMoneyPerMessage","numeratorCents","denominator","denominatorLabel","currency","divisible","cents","numerator","campaignRevenueReport","options","rollup","delivered","midFlight","stored","byCurrency","caveats","currencies","Object","entries","map","totals","grossCents","refundedCents","netCents","max","orders","refundedOrders","netPerDelivered","undefined","filter","entry","sort","a","b","localeCompare","attributedOrders","reduce","total","multiCurrency","length","push","id","message","recorded","model","String","windowDays","campaignRevenueAcrossSends","rollups","Map","models","Set","windows","add","code","trim","toLowerCase","get","emails","set","values","size","read"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/campaign-revenue.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 * The window and the model name come from `@aglyn/shared-util-email`, not\n * from here. The writer is in `tenant-data-admin`, which may not import a\n * feature plugin, and a window defined on both sides of the join would drift\n * into a number credited under one rule and printed under another.\n */\nimport {\n EMAIL_ATTRIBUTION_MODEL,\n EMAIL_ATTRIBUTION_WINDOW_DAYS,\n} from '@aglyn/shared-util-email'\nimport { sendRate } from '@aglyn/shared-ui-email-campaigns/model/send-report'\nimport { type CampaignCaveat } from './campaign-report'\n\n/**\n * WHAT A CAMPAIGN EARNED — the read half of the commerce↔email join.\n *\n * ## Why this is a join and not an attribution model\n *\n * Every compared ESP reconstructs campaign revenue probabilistically, because\n * none of them owns the order. Klaviyo and Mailchimp watch someone else's\n * store through an integration and a browser snippet, so their figure is a\n * reconciliation against a foreign system and their window is the fudge\n * factor that makes the reconciliation close. Commerce here is first-party:\n * the click and the order are rows in one database, keyed the same way, so\n * the \"attribution\" is a lookup.\n *\n * That does not make the MODEL choice go away — two campaigns can both have\n * touched a buyer and only one can be credited — but it does mean the model\n * is the only judgement in the number. There is no sampling, no identity\n * resolution and no cookie.\n *\n * ## The model: LAST CLICK, inside a fixed 7-day window\n *\n * Stated in one sentence, which is the whole requirement: **an order is\n * credited to the last campaign whose link the buyer clicked, if they clicked\n * it within the {@link EMAIL_ATTRIBUTION_WINDOW_DAYS} days before they\n * ordered.**\n *\n * Three decisions are inside that sentence.\n *\n * **Last touch, not multi-touch.** Multi-touch is the more honest description\n * of how buying works and it is unpresentable: it splits one order across\n * several campaigns by a rule the merchant did not choose, so no campaign's\n * revenue is a number they can check against their own bank, and two\n * campaigns' figures cannot be added or compared without knowing the split\n * rule. HubSpot ships multi-touch and puts it behind an Enterprise plan and a\n * consultant. A figure a merchant cannot explain to themselves is worse than\n * no figure, and it is worse in the specific way that matters here: they will\n * still make decisions with it.\n *\n * **A CLICK is the touch. An open is not.** An open is evidence about the\n * recipient's mail client, not about the recipient — Apple's Mail Privacy\n * Protection prefetches images, which inflated network-wide open rates by\n * roughly 15% and means a large share of recorded opens had no human behind\n * them. Crediting revenue to an open would therefore credit campaigns for\n * orders from people who never saw them, and the error is not random: it\n * concentrates on whichever campaign most recently reached an Apple Mail\n * user. `email-delivery-log.ts` records the same preference for the same\n * reason, and the audience rules already segment on clicks over opens.\n *\n * **Seven days, and not configurable.** Klaviyo's window is 1–30 days per\n * channel; ActiveCampaign's is a fixed, unadjustable 7. Fixed is the better\n * default here because a configurable window is a setting whose change\n * silently rewrites history: yesterday's report and today's would disagree\n * about a campaign that has not been touched since, with nothing on screen to\n * say why. The stored record carries `windowDays` per order for exactly that\n * reason — a future setting can be added without making the orders already\n * attributed unreadable, because each one says which window it was judged\n * under.\n *\n * ## GROSS and REFUNDED, never a decrement\n *\n * A refunded order must stop counting as revenue a campaign earned, and there\n * are two ways to make it stop. Decrementing the gross figure makes a stored\n * number mean one thing before a refund and another after, with nothing to\n * distinguish them; recording the reversal beside it keeps both facts. This\n * is the shape `contact-refund.ts` chose for `ltvCents`/`refundedCents` on the\n * contact and the orders CSV chose for `amountUsd`/`refundedUsd`, and it is\n * chosen again here so all three answer \"what did this earn, net\" the same\n * way.\n *\n * So {@link campaignRevenueReport} reports gross, refunded and net, and NET\n * is the figure the screen leads with. Net is clamped at zero for display\n * only: a refund larger than the sale it reverses is arithmetically possible\n * on an order attributed before a partial refund settled, and a negative\n * campaign revenue is a sentence nobody can act on.\n *\n * ## Currencies are never summed\n *\n * Money is stored in minor units and no currency travels with it — every\n * checkout door in this repo sets `currency: 'usd'` on the Stripe line items,\n * so the amounts really are all USD, but that is a fact about the code rather\n * than a field on the order. The rollup therefore buckets BY currency and\n * this module never adds two buckets together. A campaign with two currencies\n * renders two blocks and no total, and says so.\n *\n * The rule survives the merge across a container's emails.\n * {@link campaignRevenueAcrossSends} keys its accumulator on the currency, so\n * two rollups can only ever meet inside a bucket they already share — there\n * is no code path in which a USD amount and a EUR amount reach the same\n * addition, and no combined figure exists for a screen to print by accident.\n */\n\n/**\n * Re-exported so a reader of the report has the window and the model name\n * without reaching past this module for them — the screen prints both, and a\n * window nobody can see is a window nobody can check.\n */\nexport {\n EMAIL_ATTRIBUTION_MODEL,\n EMAIL_ATTRIBUTION_WINDOW_DAYS,\n EMAIL_ATTRIBUTION_WINDOW_MS,\n} from '@aglyn/shared-util-email'\n\n/** One currency's totals as the rollup stores them. */\nexport interface CampaignRevenueCurrencyStored {\n /** Minor units credited to this campaign, gross of refunds. */\n grossCents?: number\n /** Minor units handed back on orders that had been credited. */\n refundedCents?: number\n /** Orders credited to this campaign. */\n orders?: number\n /** Of those, how many ended fully reversed. */\n refundedOrders?: number\n}\n\n/**\n * The stored shape of `campaigns/{campaignId}/reports/revenue`.\n *\n * Its own document, for the reason the link rollup is its own document: the\n * campaign document is read by the history list, the glance widget and the\n * send path, and a map that grows with the campaign's sales would make every\n * one of those reads larger. Split, it is read by the one screen that draws\n * it.\n */\nexport interface CampaignRevenueRollup {\n byCurrency?: Record<string, CampaignRevenueCurrencyStored>\n /** The model the orders in this rollup were credited under. */\n model?: string\n /** The window, in days, they were credited inside. */\n windowDays?: number\n}\n\n/**\n * Money over a population, with the population named — {@link SendRate}'s\n * rule applied to an average instead of a share.\n *\n * A percentage and an average go wrong the same way, so they carry the same\n * guarantee: the denominator travels as data and the screen has to print it.\n * \"$0.42 per recipient\" over an audience nobody named is the figure this\n * whole reporting surface exists to refuse.\n */\nexport interface CampaignMoneyPerMessage {\n /** Minor units per message of the denominator. Fractional by nature. */\n cents: number\n numeratorCents: number\n denominator: number\n denominatorLabel: string\n currency: string\n}\n\n/** One currency's block on screen. */\nexport interface CampaignRevenueCurrencyReport {\n /** Lowercase ISO code as the sale recorded it, e.g. `'usd'`. */\n currency: string\n grossCents: number\n refundedCents: number\n /** `gross - refunded`, clamped at zero. */\n netCents: number\n orders: number\n refundedOrders: number\n /**\n * Net revenue per DELIVERED message, or `null` when it cannot be taken.\n *\n * Delivered is the denominator for the same reason every engagement rate on\n * this report is taken over it: mail that bounced was never in front of a\n * human, so counting it depresses a figure describing the audience with a\n * fact about the address list.\n *\n * It is deliberately NOT taken over `audienceSize`. That figure is written\n * by the FIRST batch only and is a floor when audience resolution hit its\n * read ceiling, so a campaign delivered over six runs would divide six\n * runs' revenue by one run's measure of the audience — the stale-population\n * division this surface is built to make impossible.\n */\n netPerDelivered: CampaignMoneyPerMessage | null\n}\n\n/** Everything the revenue section renders. */\nexport interface CampaignRevenueReport {\n /** One block per currency, largest net first. Never summed together. */\n currencies: CampaignRevenueCurrencyReport[]\n /** Orders credited to this campaign, across every currency. */\n attributedOrders: number\n /**\n * Whether any attribution has ever been recorded for this campaign.\n *\n * `false` means the rollup document does not exist, which is NOT the same\n * as \"this campaign earned nothing\" — it is also every campaign sent before\n * the join existed, and every campaign on a site with no store. The screen\n * renders the difference rather than printing a zero for both.\n */\n recorded: boolean\n /** More than one currency is present, so no total may be shown. */\n multiCurrency: boolean\n /** The model these figures were credited under, as stored. */\n model: string\n /** The window they were credited inside, as stored. */\n windowDays: number\n caveats: CampaignCaveat[]\n}\n\n/**\n * Why there is no total, in the words both reports use.\n *\n * One sentence, one definition. A send's report and its campaign's report\n * make the same refusal for the same reason, and two copies of the sentence\n * is how one of them comes to be softened into a promise of a total.\n */\nexport const REVENUE_MULTI_CURRENCY_MESSAGE =\n 'This campaign earned in more than one currency. Each is reported on its ' +\n 'own — nothing here converts between them, so there is deliberately no ' +\n 'combined total.'\n\n/** A stored count as a non-negative integer. */\nfunction count(raw: unknown): number {\n const value = Math.floor(Number(raw ?? 0))\n return Number.isFinite(value) && value > 0 ? value : 0\n}\n\n/**\n * Money per message, or `null` when the division cannot honestly be taken.\n *\n * The three refusals are {@link sendRate}'s, and this defers to it rather\n * than restating them: a zero denominator, an unrecorded denominator, and a\n * non-finite input all answer `null` there, so a second implementation of\n * \"when may we divide\" cannot drift from the first.\n */\nexport function campaignMoneyPerMessage(\n numeratorCents: number,\n denominator: number | undefined,\n denominatorLabel: string,\n currency: string,\n): CampaignMoneyPerMessage | null {\n const divisible = sendRate(numeratorCents, denominator, denominatorLabel)\n if (!divisible) return null\n return {\n cents: divisible.value,\n numeratorCents: divisible.numerator,\n denominator: divisible.denominator,\n denominatorLabel,\n currency,\n }\n}\n\n/**\n * Turns the stored rollup into the revenue section.\n *\n * `delivered` comes from the campaign's own `stats` and is passed in rather\n * than re-read, so the numerator and the denominator on screen are taken from\n * the same instant. It is `null` when no delivery event has ever been\n * recorded — the campaign predates the delivery webhook, or the events are\n * still in flight — and every figure over it is then withheld with a caveat,\n * never substituted for `sent`.\n */\nexport function campaignRevenueReport(options: {\n rollup: CampaignRevenueRollup | undefined\n /** `stats.delivered`, or `null` when it was never recorded. */\n delivered: number | null\n /** True while the send is still working through its audience. */\n midFlight?: boolean\n}): CampaignRevenueReport {\n const { rollup, delivered, midFlight } = options\n const stored = rollup?.byCurrency ?? {}\n const caveats: CampaignCaveat[] = []\n\n const currencies: CampaignRevenueCurrencyReport[] = Object.entries(stored)\n .map(([currency, totals]) => {\n const grossCents = count(totals?.grossCents)\n const refundedCents = count(totals?.refundedCents)\n /*\n * CLAMPED, and only here at the point of display.\n *\n * Both stored figures are monotonic counters of money that really\n * moved in one direction, so neither can be negative; their DIFFERENCE\n * can be, for one reason — an order credited to a campaign and then\n * refunded by more than the amount that was credited, which happens\n * when a partial refund settles against an order whose attributed\n * amount was the charge at the time. Clamping at write time would\n * erase the evidence; clamping at read time keeps the stored pair\n * intact and stops the screen printing a campaign with negative\n * earnings, which is not a sentence anybody can act on.\n */\n const netCents = Math.max(0, grossCents - refundedCents)\n return {\n currency,\n grossCents,\n refundedCents,\n netCents,\n orders: count(totals?.orders),\n refundedOrders: count(totals?.refundedOrders),\n netPerDelivered: campaignMoneyPerMessage(\n netCents,\n delivered ?? undefined,\n 'delivered',\n currency,\n ),\n }\n })\n .filter((entry) => entry.orders > 0 || entry.grossCents > 0)\n .sort(\n (a, b) => b.netCents - a.netCents || a.currency.localeCompare(b.currency),\n )\n\n const attributedOrders = currencies.reduce(\n (total, entry) => total + entry.orders,\n 0,\n )\n const multiCurrency = currencies.length > 1\n\n if (delivered === null && currencies.length) {\n caveats.push({\n id: 'revenue-denominator-unrecorded',\n message:\n 'No delivery events have been recorded for this campaign, so revenue ' +\n 'per delivered message cannot be computed. The amounts below are ' +\n 'still real.',\n })\n }\n if (multiCurrency) {\n caveats.push({\n id: 'revenue-multi-currency',\n message: REVENUE_MULTI_CURRENCY_MESSAGE,\n })\n }\n if (midFlight && currencies.length) {\n caveats.push({\n id: 'revenue-mid-flight',\n message:\n 'This campaign is still going out. Revenue and delivered messages ' +\n 'are both still rising, so every figure below is a running total ' +\n 'rather than a final one.',\n })\n }\n\n return {\n currencies,\n attributedOrders,\n recorded: rollup !== undefined,\n multiCurrency,\n model: String(rollup?.model ?? EMAIL_ATTRIBUTION_MODEL),\n windowDays: count(rollup?.windowDays) || EMAIL_ATTRIBUTION_WINDOW_DAYS,\n caveats,\n }\n}\n\n/** One currency's totals, merged across a campaign's emails. */\nexport interface CampaignRevenueCurrencyAcrossSends {\n /** Lowercase ISO code as the sales recorded it, e.g. `'usd'`. */\n currency: string\n grossCents: number\n refundedCents: number\n /** `gross - refunded` over the whole campaign, clamped once at zero. */\n netCents: number\n orders: number\n refundedOrders: number\n /** Emails of the campaign whose revenue record holds this currency. */\n emails: number\n}\n\n/** Everything a campaign container's revenue section renders. */\nexport interface CampaignRevenueAcrossSends {\n /** One block per currency, largest net first. Never summed together. */\n currencies: CampaignRevenueCurrencyAcrossSends[]\n /**\n * Orders credited across every currency.\n *\n * A COUNT, which is why it may cross currencies when the money may not: an\n * order is one order whatever it was paid in, and counting two of them\n * loses nothing. Adding their amounts loses the unit.\n */\n attributedOrders: number\n /** Emails whose revenue record was looked for. */\n read: number\n /**\n * Of those, how many have a revenue record at all.\n *\n * The rollup is created by the attribution writer on the first order it\n * credits, so an email with no record has never been credited with one.\n * Zero across the whole campaign is therefore the container's version of\n * {@link CampaignRevenueReport.recorded} being `false`: it is not \"this\n * campaign earned nothing\", it is also every campaign sent before the join\n * existed and every campaign on a site with no store.\n */\n recorded: number\n /** More than one currency is present, so no total may be shown. */\n multiCurrency: boolean\n /** The models these emails were credited under, distinct and sorted. */\n models: string[]\n /** The windows, in days, they were credited inside, distinct and sorted. */\n windowDays: number[]\n caveats: CampaignCaveat[]\n}\n\n/**\n * MERGES A CAMPAIGN'S EMAILS INTO ONE REVENUE SECTION, per currency.\n *\n * `reports/revenue` is written per SEND, so a container's figure is one\n * document per email and a merge. The merge is the whole risk: two emails of\n * one campaign can have earned in different currencies, and adding their\n * amounts produces a number that is wrong with nothing on screen to show it.\n *\n * ## The currency is the accumulator's KEY, not a field beside the amount\n *\n * That is the structural half of the guarantee. Amounts are added into a map\n * keyed by currency, so two amounts can only reach the same addition when\n * they already carry the same code — a USD figure and a EUR figure have no\n * path to each other, whatever a later caller asks for. There is no combined\n * field on the result, so a screen cannot print a cross-currency total by\n * reading the wrong property, and\n * {@link CampaignRevenueAcrossSends.multiCurrency} is what tells the screen\n * to label the blocks.\n *\n * ## Clamped ONCE, over the campaign\n *\n * The single-send report clamps its net at zero because a partial refund can\n * settle against an order credited at the full charge. Doing that per email\n * and then summing would let one over-refunded email keep money a sibling\n * email handed back — an email at -$50 clamped to $0 beside one at $200 would\n * report $200 for a campaign holding $150. So gross and refunded are summed\n * as they stand and the difference is clamped once, at the end.\n *\n * ## No per-message average, deliberately\n *\n * The send report divides net revenue by that send's own `delivered`, taken\n * from the same document at the same instant. A container has no such pair.\n * Its delivery total is summed over the emails that RECORDED a delivery count\n * and its revenue over the emails that have a revenue record, and those are\n * different subsets of the campaign — so the quotient would be an average\n * over a population nobody named, which is the defect the whole reporting\n * surface is built to refuse. The amounts are reported without one.\n *\n * @param rollups - one entry per email read, `undefined` where no record\n * exists.\n */\nexport function campaignRevenueAcrossSends(\n rollups: readonly (CampaignRevenueRollup | undefined)[],\n): CampaignRevenueAcrossSends {\n const byCurrency = new Map<string, CampaignRevenueCurrencyAcrossSends>()\n const models = new Set<string>()\n const windows = new Set<number>()\n let recorded = 0\n\n for (const rollup of rollups) {\n if (!rollup) continue\n recorded += 1\n models.add(String(rollup.model ?? EMAIL_ATTRIBUTION_MODEL))\n windows.add(count(rollup.windowDays) || EMAIL_ATTRIBUTION_WINDOW_DAYS)\n for (const [code, stored] of Object.entries(rollup.byCurrency ?? {})) {\n const currency = String(code).trim().toLowerCase()\n if (!currency) continue\n const entry = byCurrency.get(currency) ?? {\n currency,\n grossCents: 0,\n refundedCents: 0,\n netCents: 0,\n orders: 0,\n refundedOrders: 0,\n emails: 0,\n }\n entry.grossCents += count(stored?.grossCents)\n entry.refundedCents += count(stored?.refundedCents)\n entry.orders += count(stored?.orders)\n entry.refundedOrders += count(stored?.refundedOrders)\n // One rollup holds at most one bucket per currency, so this counts\n // EMAILS that earned in it rather than orders.\n entry.emails += 1\n byCurrency.set(currency, entry)\n }\n }\n\n const currencies = [...byCurrency.values()]\n .map((entry) => ({\n ...entry,\n netCents: Math.max(0, entry.grossCents - entry.refundedCents),\n }))\n .filter((entry) => entry.orders > 0 || entry.grossCents > 0)\n .sort(\n (a, b) => b.netCents - a.netCents || a.currency.localeCompare(b.currency),\n )\n\n const multiCurrency = currencies.length > 1\n const caveats: CampaignCaveat[] = []\n if (multiCurrency) {\n caveats.push({\n id: 'revenue-multi-currency',\n message: REVENUE_MULTI_CURRENCY_MESSAGE,\n })\n }\n /*\n * Two rules met in one figure, which is the currency problem in another\n * dimension: a campaign whose older emails were credited under a different\n * model or a different window holds amounts that were judged by different\n * tests. They are still money in one unit, so unlike two currencies they\n * add — the total stands, and the reader is told the rule behind it is not\n * single.\n */\n if (models.size > 1 || windows.size > 1) {\n caveats.push({\n id: 'revenue-mixed-model',\n message:\n 'This campaign’s emails were not all credited under the same rule. ' +\n 'The amounts are real, but they were judged by different attribution ' +\n 'models or windows, so they are not strictly comparable with each ' +\n 'other.',\n })\n }\n\n return {\n currencies,\n attributedOrders: currencies.reduce(\n (total, entry) => total + entry.orders,\n 0,\n ),\n read: rollups.length,\n recorded,\n multiCurrency,\n models: [...models].sort(),\n windowDays: [...windows].sort((a, b) => a - b),\n caveats,\n }\n}\n"],"names":["EMAIL_ATTRIBUTION_MODEL","EMAIL_ATTRIBUTION_WINDOW_DAYS","sendRate","EMAIL_ATTRIBUTION_WINDOW_MS","REVENUE_MULTI_CURRENCY_MESSAGE","count","raw","value","Math","floor","Number","isFinite","campaignMoneyPerMessage","numeratorCents","denominator","denominatorLabel","currency","divisible","cents","numerator","campaignRevenueReport","options","rollup","delivered","midFlight","stored","byCurrency","caveats","currencies","Object","entries","map","totals","grossCents","refundedCents","netCents","max","orders","refundedOrders","netPerDelivered","undefined","filter","entry","sort","a","b","localeCompare","attributedOrders","reduce","total","multiCurrency","length","push","id","message","recorded","model","String","windowDays","campaignRevenueAcrossSends","rollups","Map","models","Set","windows","add","code","trim","toLowerCase","get","emails","set","values","size","read"],"mappings":"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"}
|
|
@@ -14,7 +14,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
14
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
|
-
*/ import {
|
|
17
|
+
*/ import { sendRate } from "@aglyn/shared-ui-email-campaigns/model/send-report";
|
|
18
18
|
/** The fewest measured sends a suggestion is taken from. */ export const CAMPAIGN_SEND_TIME_MIN_SENDS = 3;
|
|
19
19
|
/** The fewest delivered messages that make a send measurable. */ export const CAMPAIGN_SEND_TIME_MIN_DELIVERED = 20;
|
|
20
20
|
const WEEKDAYS = {
|
|
@@ -96,7 +96,7 @@ function slotFormatter(timeZone) {
|
|
|
96
96
|
if (measured < CAMPAIGN_SEND_TIME_MIN_SENDS) return null;
|
|
97
97
|
let best = null;
|
|
98
98
|
for (const slot of slots.values()){
|
|
99
|
-
const rate =
|
|
99
|
+
const rate = sendRate(slot.opens, slot.delivered, 'delivered');
|
|
100
100
|
if (!rate) continue;
|
|
101
101
|
const candidate = {
|
|
102
102
|
weekday: slot.weekday,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/campaign-send-time.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\nimport { campaignRate } from '@aglyn/shared-ui-email-campaigns/model/campaign-report'\n\n/**\n * WHEN A LIST READS ITS MAIL: a suggested send time, taken from the sends that\n * already went to it.\n *\n * A rule and not a guess. Each past send is put in the slot it went out in —\n * its weekday and hour, in one named time zone — and the slot whose sends were\n * opened by the largest share of the people they reached is the suggestion.\n * The share is POOLED across a slot's sends (every unique open over every\n * delivered message) rather than averaged, so one small send that happened to\n * be opened by all nine of its recipients does not outrank a slot that reached\n * thousands.\n *\n * Only sends that can be measured are counted: a send with no delivered count\n * large enough to say anything is left out, and so is one whose opens exceed\n * its deliveries, which is a record that disagrees with itself. With fewer\n * measured sends than the minimum there is no suggestion at all — \"not enough\n * history\" is an answer a composer can say, and a slot picked from one send is\n * not.\n *\n * Pure, with the reads left to the caller that holds the credentials.\n */\n\n/** One past send to the list, as the suggestion needs it. */\nexport interface CampaignSendTimeSample {\n /** When the send went out. */\n sentAtMs: number\n /** Messages the provider delivered. */\n delivered: number\n /** Distinct recipients who opened it. */\n uniqueOpens: number\n}\n\nexport interface CampaignSendTimeSuggestion {\n /** 0 for Sunday through 6 for Saturday, in `timeZone`. */\n weekday: number\n /** 0 to 23, in `timeZone`. */\n hour: number\n /** The IANA zone the slot is stated in. */\n timeZone: string\n /** The pooled unique-open rate of the sends in the slot, 0 to 1. */\n openRate: number\n /** Measured sends that went out in the slot. */\n sends: number\n /** Measured sends the slot was chosen among. */\n measured: number\n}\n\n/** The fewest measured sends a suggestion is taken from. */\nexport const CAMPAIGN_SEND_TIME_MIN_SENDS = 3\n\n/** The fewest delivered messages that make a send measurable. */\nexport const CAMPAIGN_SEND_TIME_MIN_DELIVERED = 20\n\nconst WEEKDAYS: Record<string, number> = {\n Sun: 0,\n Mon: 1,\n Tue: 2,\n Wed: 3,\n Thu: 4,\n Fri: 5,\n Sat: 6,\n}\n\nconst WEEKDAY_NAMES = [\n 'Sunday',\n 'Monday',\n 'Tuesday',\n 'Wednesday',\n 'Thursday',\n 'Friday',\n 'Saturday',\n]\n\nfunction slotFormatter(timeZone: string): Intl.DateTimeFormat | null {\n try {\n return new Intl.DateTimeFormat('en-US', {\n timeZone,\n weekday: 'short',\n hour: 'numeric',\n hourCycle: 'h23',\n })\n } catch {\n return null\n }\n}\n\n/** The weekday and hour an instant falls in, in a zone. */\nfunction slotOf(\n formatter: Intl.DateTimeFormat,\n atMs: number,\n): { weekday: number; hour: number } | null {\n let weekday: number | undefined\n let hour: number | undefined\n for (const part of formatter.formatToParts(new Date(atMs))) {\n if (part.type === 'weekday') weekday = WEEKDAYS[part.value]\n if (part.type === 'hour') hour = Number(part.value) % 24\n }\n return weekday === undefined || hour === undefined || !Number.isFinite(hour)\n ? null\n : { weekday, hour }\n}\n\n/**\n * The slot the list's past sends were opened most in, or `null` when too few\n * sends can be measured. An unknown time zone is read as UTC, and the answer\n * says so.\n */\nexport function suggestCampaignSendTime(\n samples: ReadonlyArray<CampaignSendTimeSample>,\n options: { timeZone?: string } = {},\n): CampaignSendTimeSuggestion | null {\n const requested = options.timeZone?.trim() || 'UTC'\n const formatter = slotFormatter(requested) ?? slotFormatter('UTC')\n const timeZone = slotFormatter(requested) ? requested : 'UTC'\n if (!formatter) return null\n const slots = new Map<string, { weekday: number; hour: number; delivered: number; opens: number; sends: number }>()\n let measured = 0\n for (const sample of samples) {\n const delivered = Number(sample.delivered)\n const opens = Number(sample.uniqueOpens)\n if (!Number.isFinite(sample.sentAtMs) || sample.sentAtMs <= 0) continue\n if (!Number.isFinite(delivered) || delivered < CAMPAIGN_SEND_TIME_MIN_DELIVERED) continue\n if (!Number.isFinite(opens) || opens < 0 || opens > delivered) continue\n const slot = slotOf(formatter, sample.sentAtMs)\n if (!slot) continue\n measured += 1\n const key = `${slot.weekday}:${slot.hour}`\n const held = slots.get(key) ?? { ...slot, delivered: 0, opens: 0, sends: 0 }\n held.delivered += delivered\n held.opens += opens\n held.sends += 1\n slots.set(key, held)\n }\n if (measured < CAMPAIGN_SEND_TIME_MIN_SENDS) return null\n let best: CampaignSendTimeSuggestion | null = null\n for (const slot of slots.values()) {\n const rate = campaignRate(slot.opens, slot.delivered, 'delivered')\n if (!rate) continue\n const candidate: CampaignSendTimeSuggestion = {\n weekday: slot.weekday,\n hour: slot.hour,\n timeZone,\n openRate: rate.value,\n sends: slot.sends,\n measured,\n }\n const better =\n !best ||\n candidate.openRate > best.openRate ||\n (candidate.openRate === best.openRate &&\n (candidate.sends > best.sends ||\n (candidate.sends === best.sends &&\n candidate.weekday * 24 + candidate.hour < best.weekday * 24 + best.hour)))\n if (better) best = candidate\n }\n return best\n}\n\n/** The slot as a person reads it: \"Tuesdays around 9 AM (UTC)\". */\nexport function campaignSendTimeLabel(\n suggestion: Pick<CampaignSendTimeSuggestion, 'weekday' | 'hour' | 'timeZone'>,\n): string {\n const hour12 = suggestion.hour % 12 === 0 ? 12 : suggestion.hour % 12\n const meridiem = suggestion.hour < 12 ? 'AM' : 'PM'\n return `${WEEKDAY_NAMES[suggestion.weekday]}s around ${hour12} ${meridiem} (${suggestion.timeZone})`\n}\n"],"names":["campaignRate","CAMPAIGN_SEND_TIME_MIN_SENDS","CAMPAIGN_SEND_TIME_MIN_DELIVERED","WEEKDAYS","Sun","Mon","Tue","Wed","Thu","Fri","Sat","WEEKDAY_NAMES","slotFormatter","timeZone","Intl","DateTimeFormat","weekday","hour","hourCycle","slotOf","formatter","atMs","part","formatToParts","Date","type","value","Number","undefined","isFinite","suggestCampaignSendTime","samples","options","requested","trim","slots","Map","measured","sample","delivered","opens","uniqueOpens","sentAtMs","slot","key","held","get","sends","set","best","values","rate","candidate","openRate","better","campaignSendTimeLabel","suggestion","hour12","meridiem"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/campaign-send-time.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\nimport { sendRate } from '@aglyn/shared-ui-email-campaigns/model/send-report'\n\n/**\n * WHEN A LIST READS ITS MAIL: a suggested send time, taken from the sends that\n * already went to it.\n *\n * A rule and not a guess. Each past send is put in the slot it went out in —\n * its weekday and hour, in one named time zone — and the slot whose sends were\n * opened by the largest share of the people they reached is the suggestion.\n * The share is POOLED across a slot's sends (every unique open over every\n * delivered message) rather than averaged, so one small send that happened to\n * be opened by all nine of its recipients does not outrank a slot that reached\n * thousands.\n *\n * Only sends that can be measured are counted: a send with no delivered count\n * large enough to say anything is left out, and so is one whose opens exceed\n * its deliveries, which is a record that disagrees with itself. With fewer\n * measured sends than the minimum there is no suggestion at all — \"not enough\n * history\" is an answer a composer can say, and a slot picked from one send is\n * not.\n *\n * Pure, with the reads left to the caller that holds the credentials.\n */\n\n/** One past send to the list, as the suggestion needs it. */\nexport interface CampaignSendTimeSample {\n /** When the send went out. */\n sentAtMs: number\n /** Messages the provider delivered. */\n delivered: number\n /** Distinct recipients who opened it. */\n uniqueOpens: number\n}\n\nexport interface CampaignSendTimeSuggestion {\n /** 0 for Sunday through 6 for Saturday, in `timeZone`. */\n weekday: number\n /** 0 to 23, in `timeZone`. */\n hour: number\n /** The IANA zone the slot is stated in. */\n timeZone: string\n /** The pooled unique-open rate of the sends in the slot, 0 to 1. */\n openRate: number\n /** Measured sends that went out in the slot. */\n sends: number\n /** Measured sends the slot was chosen among. */\n measured: number\n}\n\n/** The fewest measured sends a suggestion is taken from. */\nexport const CAMPAIGN_SEND_TIME_MIN_SENDS = 3\n\n/** The fewest delivered messages that make a send measurable. */\nexport const CAMPAIGN_SEND_TIME_MIN_DELIVERED = 20\n\nconst WEEKDAYS: Record<string, number> = {\n Sun: 0,\n Mon: 1,\n Tue: 2,\n Wed: 3,\n Thu: 4,\n Fri: 5,\n Sat: 6,\n}\n\nconst WEEKDAY_NAMES = [\n 'Sunday',\n 'Monday',\n 'Tuesday',\n 'Wednesday',\n 'Thursday',\n 'Friday',\n 'Saturday',\n]\n\nfunction slotFormatter(timeZone: string): Intl.DateTimeFormat | null {\n try {\n return new Intl.DateTimeFormat('en-US', {\n timeZone,\n weekday: 'short',\n hour: 'numeric',\n hourCycle: 'h23',\n })\n } catch {\n return null\n }\n}\n\n/** The weekday and hour an instant falls in, in a zone. */\nfunction slotOf(\n formatter: Intl.DateTimeFormat,\n atMs: number,\n): { weekday: number; hour: number } | null {\n let weekday: number | undefined\n let hour: number | undefined\n for (const part of formatter.formatToParts(new Date(atMs))) {\n if (part.type === 'weekday') weekday = WEEKDAYS[part.value]\n if (part.type === 'hour') hour = Number(part.value) % 24\n }\n return weekday === undefined || hour === undefined || !Number.isFinite(hour)\n ? null\n : { weekday, hour }\n}\n\n/**\n * The slot the list's past sends were opened most in, or `null` when too few\n * sends can be measured. An unknown time zone is read as UTC, and the answer\n * says so.\n */\nexport function suggestCampaignSendTime(\n samples: ReadonlyArray<CampaignSendTimeSample>,\n options: { timeZone?: string } = {},\n): CampaignSendTimeSuggestion | null {\n const requested = options.timeZone?.trim() || 'UTC'\n const formatter = slotFormatter(requested) ?? slotFormatter('UTC')\n const timeZone = slotFormatter(requested) ? requested : 'UTC'\n if (!formatter) return null\n const slots = new Map<string, { weekday: number; hour: number; delivered: number; opens: number; sends: number }>()\n let measured = 0\n for (const sample of samples) {\n const delivered = Number(sample.delivered)\n const opens = Number(sample.uniqueOpens)\n if (!Number.isFinite(sample.sentAtMs) || sample.sentAtMs <= 0) continue\n if (!Number.isFinite(delivered) || delivered < CAMPAIGN_SEND_TIME_MIN_DELIVERED) continue\n if (!Number.isFinite(opens) || opens < 0 || opens > delivered) continue\n const slot = slotOf(formatter, sample.sentAtMs)\n if (!slot) continue\n measured += 1\n const key = `${slot.weekday}:${slot.hour}`\n const held = slots.get(key) ?? { ...slot, delivered: 0, opens: 0, sends: 0 }\n held.delivered += delivered\n held.opens += opens\n held.sends += 1\n slots.set(key, held)\n }\n if (measured < CAMPAIGN_SEND_TIME_MIN_SENDS) return null\n let best: CampaignSendTimeSuggestion | null = null\n for (const slot of slots.values()) {\n const rate = sendRate(slot.opens, slot.delivered, 'delivered')\n if (!rate) continue\n const candidate: CampaignSendTimeSuggestion = {\n weekday: slot.weekday,\n hour: slot.hour,\n timeZone,\n openRate: rate.value,\n sends: slot.sends,\n measured,\n }\n const better =\n !best ||\n candidate.openRate > best.openRate ||\n (candidate.openRate === best.openRate &&\n (candidate.sends > best.sends ||\n (candidate.sends === best.sends &&\n candidate.weekday * 24 + candidate.hour < best.weekday * 24 + best.hour)))\n if (better) best = candidate\n }\n return best\n}\n\n/** The slot as a person reads it: \"Tuesdays around 9 AM (UTC)\". */\nexport function campaignSendTimeLabel(\n suggestion: Pick<CampaignSendTimeSuggestion, 'weekday' | 'hour' | 'timeZone'>,\n): string {\n const hour12 = suggestion.hour % 12 === 0 ? 12 : suggestion.hour % 12\n const meridiem = suggestion.hour < 12 ? 'AM' : 'PM'\n return `${WEEKDAY_NAMES[suggestion.weekday]}s around ${hour12} ${meridiem} (${suggestion.timeZone})`\n}\n"],"names":["sendRate","CAMPAIGN_SEND_TIME_MIN_SENDS","CAMPAIGN_SEND_TIME_MIN_DELIVERED","WEEKDAYS","Sun","Mon","Tue","Wed","Thu","Fri","Sat","WEEKDAY_NAMES","slotFormatter","timeZone","Intl","DateTimeFormat","weekday","hour","hourCycle","slotOf","formatter","atMs","part","formatToParts","Date","type","value","Number","undefined","isFinite","suggestCampaignSendTime","samples","options","requested","trim","slots","Map","measured","sample","delivered","opens","uniqueOpens","sentAtMs","slot","key","held","get","sends","set","best","values","rate","candidate","openRate","better","campaignSendTimeLabel","suggestion","hour12","meridiem"],"mappings":"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"}
|
|
@@ -14,12 +14,12 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
-
import { type
|
|
17
|
+
import { type InteractionStepBase, type SiteInteraction } from '@aglyn/aglyn/app-utils/site-interactions';
|
|
18
18
|
import type { ClientAutomation } from './site-contract';
|
|
19
19
|
/** A raw `hosts/{hostId}/actions` doc paired with its id. */
|
|
20
20
|
export interface RawHostAction {
|
|
21
21
|
id: string;
|
|
22
|
-
action:
|
|
22
|
+
action: SiteInteraction<InteractionStepBase>;
|
|
23
23
|
}
|
|
24
24
|
export interface CompileClientAutomationsOptions {
|
|
25
25
|
/** Leading-slash page path for pathPattern targeting. */
|
|
@@ -14,11 +14,11 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
14
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
|
-
*/ // Import the entitlement/event predicates from the context-free
|
|
17
|
+
*/ // Import the entitlement/event predicates from the context-free interactions
|
|
18
18
|
// subpath, NOT the `@aglyn/aglyn` barrel: this file is in the marketing model,
|
|
19
19
|
// which the plugin's SERVER graph re-exports, and the full barrel pulls client
|
|
20
20
|
// React contexts (createContext) that break the RSC server build (AGL-830).
|
|
21
|
-
import {
|
|
21
|
+
import { interactionStepsForClient, isClientActionStep, isClientStepEntitled, isSiteEventType } from "@aglyn/aglyn/app-utils/site-interactions";
|
|
22
22
|
import { overlayMatchesPath } from "./overlays.js";
|
|
23
23
|
/**
|
|
24
24
|
* Pure action→client-automation mapping (AGL-256/577, extracted AGL-830):
|
|
@@ -55,7 +55,7 @@ import { overlayMatchesPath } from "./overlays.js";
|
|
|
55
55
|
* delay would look correct on the server, and the two halves of one
|
|
56
56
|
* authored flow would disagree about when they happened. Everything past
|
|
57
57
|
* the first wait belongs to a run the job beat has not started yet.
|
|
58
|
-
*/ const reachableSteps =
|
|
58
|
+
*/ const reachableSteps = interactionStepsForClient(action.steps);
|
|
59
59
|
// Step-level entitlement tiering (AGL-577): basic presentational steps
|
|
60
60
|
// load on every plan; advanced client steps need `actions` and runJs
|
|
61
61
|
// needs `webhooks` — see isClientStepEntitled.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/compile-client-automations.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// Import the entitlement/event predicates from the context-free
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/compile-client-automations.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// Import the entitlement/event predicates from the context-free interactions\n// subpath, NOT the `@aglyn/aglyn` barrel: this file is in the marketing model,\n// which the plugin's SERVER graph re-exports, and the full barrel pulls client\n// React contexts (createContext) that break the RSC server build (AGL-830).\nimport {\n type ClientInteractionStep,\n type InteractionStepBase,\n interactionStepsForClient,\n isClientActionStep,\n isClientStepEntitled,\n isSiteEventType,\n type SiteInteraction,\n} from '@aglyn/aglyn/app-utils/site-interactions'\nimport { overlayMatchesPath } from './overlays'\nimport type { ClientAutomation } from './site-contract'\n\n/** A raw `hosts/{hostId}/actions` doc paired with its id. */\nexport interface RawHostAction {\n id: string\n action: SiteInteraction<InteractionStepBase>\n}\n\nexport interface CompileClientAutomationsOptions {\n /** Leading-slash page path for pathPattern targeting. */\n path: string\n /**\n * `actions` entitlement (AGL-577). Basic presentational steps\n * (menu/drawer/show-hide/class/nav/alert) always load; advanced client\n * steps (overlay/showHtml/analytics) and server steps drop when false.\n */\n actionsEntitled: boolean\n /** Business-tier gate for `runJs` steps (dropped when false). */\n allowJs: boolean\n /**\n * Ignore each action's `pathPattern` (AGL-830). The besigner Preview shows\n * a single composed screen and scopes relevance by selector match, so it\n * skips path filtering rather than resolve the composed path.\n */\n matchAllPaths?: boolean\n /**\n * Drop every automation that names a `pathPattern` (AGL-2511), for a\n * surface whose own path is unknown — the designed 404 body, served from a\n * per-host cache that no request path belongs to.\n *\n * The opposite of `matchAllPaths` and deliberately so: Preview knows the\n * page and only lacks its composed address, so it keeps path-scoped\n * automations; this caller cannot know the page at all, and running an\n * automation scoped to `/pricing` on whatever URL 404'd would be a guess\n * with a side effect. Interactions authored on nodes carry no pattern, so\n * a nav's hover choreography — the reason this surface is enriched — is\n * unaffected.\n */\n dropPathScoped?: boolean\n}\n\n/**\n * Pure action→client-automation mapping (AGL-256/577, extracted AGL-830):\n * the single place the trigger config + entitlement-trimmed client steps are\n * shaped. Both the server page-enricher (admin fetch) and the editor Preview\n * (client fetch) feed their loaded action docs through this, so live and\n * preview stay byte-for-byte consistent. No I/O, no firebase — safe on the\n * client. Malformed entries are skipped, never thrown.\n */\nexport function compileClientAutomations(\n actions: RawHostAction[],\n options: CompileClientAutomationsOptions,\n): ClientAutomation[] {\n const automations: ClientAutomation[] = []\n for (const { id, action } of actions) {\n if (\n (action as { deletedAt?: unknown }).deletedAt ||\n action.enabled === false\n ) {\n continue\n }\n const event = String(action.trigger?.event ?? '')\n if (!isSiteEventType(event)) continue\n const pathPattern = action.trigger?.pathPattern?.trim()\n if (pathPattern && options.dropPathScoped) continue\n if (\n !options.matchAllPaths &&\n pathPattern &&\n !overlayMatchesPath({ pathPatterns: [pathPattern] }, options.path)\n ) {\n continue\n }\n /*\n * TRUNCATED AT THE FIRST WAIT, before anything else is decided.\n *\n * The browser runs its slice of the list the moment the trigger fires, so\n * a client step sitting after a `wait` would execute at once — the popup\n * an author scheduled for three days later would appear immediately, the\n * delay would look correct on the server, and the two halves of one\n * authored flow would disagree about when they happened. Everything past\n * the first wait belongs to a run the job beat has not started yet.\n */\n const reachableSteps = interactionStepsForClient(action.steps)\n // Step-level entitlement tiering (AGL-577): basic presentational steps\n // load on every plan; advanced client steps need `actions` and runJs\n // needs `webhooks` — see isClientStepEntitled.\n const clientSteps = reachableSteps.filter((step): step is ClientInteractionStep =>\n isClientStepEntitled(step, {\n actionsEntitled: options.actionsEntitled,\n allowJs: options.allowJs,\n }),\n )\n // Server steps only dispatch (and are only re-authorized) when the org is\n // actions-entitled — never advertise them otherwise, so basic-tier pages\n // don't POST to /api/events/dispatch for a no-op.\n const hasServerSteps =\n options.actionsEntitled &&\n (action.steps ?? []).some((step) => !isClientActionStep(step))\n if (!clientSteps.length && !hasServerSteps) continue\n automations.push({\n id,\n event,\n ...(action.trigger?.selector\n ? { selector: action.trigger.selector }\n : {}),\n ...(Number(action.trigger?.threshold) > 0\n ? { threshold: Number(action.trigger.threshold) }\n : {}),\n ...(action.trigger?.oncePerVisitor === true\n ? { oncePerVisitor: true }\n : {}),\n ...(action.trigger?.oncePerSession === true\n ? { oncePerSession: true }\n : {}),\n ...(Number(action.trigger?.cooldownMinutes) >= 1\n ? { cooldownMinutes: Number(action.trigger?.cooldownMinutes) }\n : {}),\n // Repeatable UI choreography (AGL-562): menu/drawer interactions fire on\n // every occurrence instead of once per pageview.\n ...(action.trigger?.everyTime === true ? { everyTime: true } : {}),\n steps: clientSteps,\n hasServerSteps,\n })\n }\n return automations\n}\n\nexport default compileClientAutomations\n"],"names":["interactionStepsForClient","isClientActionStep","isClientStepEntitled","isSiteEventType","overlayMatchesPath","compileClientAutomations","actions","options","automations","id","action","deletedAt","enabled","event","String","trigger","pathPattern","trim","dropPathScoped","matchAllPaths","pathPatterns","path","reachableSteps","steps","clientSteps","filter","step","actionsEntitled","allowJs","hasServerSteps","some","length","push","selector","Number","threshold","oncePerVisitor","oncePerSession","cooldownMinutes","everyTime"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,6EAA6E;AAC7E,+EAA+E;AAC/E,+EAA+E;AAC/E,4EAA4E;AAC5E,SAGEA,yBAAyB,EACzBC,kBAAkB,EAClBC,oBAAoB,EACpBC,eAAe,QAEV,2CAA0C;AACjD,SAASC,kBAAkB,QAAQ,gBAAY;AA0C/C;;;;;;;CAOC,GACD,OAAO,SAASC,yBACdC,OAAwB,EACxBC,OAAwC;IAExC,MAAMC,cAAkC,EAAE;IAC1C,KAAK,MAAM,EAAEC,EAAE,EAAEC,MAAM,EAAE,IAAIJ,QAAS;kBA2CjCI;YApCkBA,iBAEDA,6BAAAA,kBAuCdA,kBAGOA,kBAGPA,kBAGAA,kBAGOA,kBACmBA,kBAI1BA;QAhEN,IACE,AAACA,OAAmCC,SAAS,IAC7CD,OAAOE,OAAO,KAAK,OACnB;YACA;QACF;QACA,MAAMC,QAAQC,gBAAOJ,kBAAAA,OAAOK,OAAO,qBAAdL,gBAAgBG,KAAK,mBAAI;QAC9C,IAAI,CAACV,gBAAgBU,QAAQ;QAC7B,MAAMG,eAAcN,mBAAAA,OAAOK,OAAO,sBAAdL,8BAAAA,iBAAgBM,WAAW,qBAA3BN,4BAA6BO,IAAI;QACrD,IAAID,eAAeT,QAAQW,cAAc,EAAE;QAC3C,IACE,CAACX,QAAQY,aAAa,IACtBH,eACA,CAACZ,mBAAmB;YAAEgB,cAAc;gBAACJ;aAAY;QAAC,GAAGT,QAAQc,IAAI,GACjE;YACA;QACF;QACA;;;;;;;;;KASC,GACD,MAAMC,iBAAiBtB,0BAA0BU,OAAOa,KAAK;QAC7D,uEAAuE;QACvE,qEAAqE;QACrE,+CAA+C;QAC/C,MAAMC,cAAcF,eAAeG,MAAM,CAAC,CAACC,OACzCxB,qBAAqBwB,MAAM;gBACzBC,iBAAiBpB,QAAQoB,eAAe;gBACxCC,SAASrB,QAAQqB,OAAO;YAC1B;QAEF,0EAA0E;QAC1E,yEAAyE;QACzE,kDAAkD;QAClD,MAAMC,iBACJtB,QAAQoB,eAAe,IACvB,EAACjB,gBAAAA,OAAOa,KAAK,YAAZb,gBAAgB,EAAE,EAAEoB,IAAI,CAAC,CAACJ,OAAS,CAACzB,mBAAmByB;QAC1D,IAAI,CAACF,YAAYO,MAAM,IAAI,CAACF,gBAAgB;QAC5CrB,YAAYwB,IAAI,CAAC;YACfvB;YACAI;WACIH,EAAAA,mBAAAA,OAAOK,OAAO,qBAAdL,iBAAgBuB,QAAQ,IACxB;YAAEA,UAAUvB,OAAOK,OAAO,CAACkB,QAAQ;QAAC,IACpC,CAAC,GACDC,QAAOxB,mBAAAA,OAAOK,OAAO,qBAAdL,iBAAgByB,SAAS,IAAI,IACpC;YAAEA,WAAWD,OAAOxB,OAAOK,OAAO,CAACoB,SAAS;QAAE,IAC9C,CAAC,GACDzB,EAAAA,mBAAAA,OAAOK,OAAO,qBAAdL,iBAAgB0B,cAAc,MAAK,OACnC;YAAEA,gBAAgB;QAAK,IACvB,CAAC,GACD1B,EAAAA,mBAAAA,OAAOK,OAAO,qBAAdL,iBAAgB2B,cAAc,MAAK,OACnC;YAAEA,gBAAgB;QAAK,IACvB,CAAC,GACDH,QAAOxB,mBAAAA,OAAOK,OAAO,qBAAdL,iBAAgB4B,eAAe,KAAK,IAC3C;YAAEA,iBAAiBJ,QAAOxB,mBAAAA,OAAOK,OAAO,qBAAdL,iBAAgB4B,eAAe;QAAE,IAC3D,CAAC,GAGD5B,EAAAA,mBAAAA,OAAOK,OAAO,qBAAdL,iBAAgB6B,SAAS,MAAK,OAAO;YAAEA,WAAW;QAAK,IAAI,CAAC;YAChEhB,OAAOC;YACPK;;IAEJ;IACA,OAAOrB;AACT;AAEA,eAAeH,yBAAwB"}
|
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
/**
|
|
18
|
+
* READING ONE MESSAGE RECORD.
|
|
19
|
+
*
|
|
20
|
+
* A message — an email that was or will be sent — is a document under
|
|
21
|
+
* `hosts/{hostId}/campaigns`. Two facts about that document are awkward
|
|
22
|
+
* enough that every surface reading it gets them wrong the same way, so they
|
|
23
|
+
* are decided here once.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* When a message went out, or is due to.
|
|
27
|
+
*
|
|
28
|
+
* **There is no one SEND date field.** The send path writes `{status:'sent',
|
|
29
|
+
* sentAt}` from one branch and `{status:'scheduled', sendAtMs}` from another.
|
|
30
|
+
* So a list ordered on either field in Firestore does not mis-sort —
|
|
31
|
+
* `orderBy` DROPS every document missing the ordered field — and half the
|
|
32
|
+
* messages simply vanish. Sorting on this instead keeps both kinds in one
|
|
33
|
+
* ordered list.
|
|
34
|
+
*
|
|
35
|
+
* `sentAt` is a Firestore `Timestamp`, which the client SDK gives as an
|
|
36
|
+
* object with `seconds` and `toMillis()`; a document read from cache while a
|
|
37
|
+
* write is pending can carry a sentinel with neither, which reads as 0 and
|
|
38
|
+
* sorts last rather than throwing.
|
|
39
|
+
*
|
|
40
|
+
* A DRAFT correctly answers 0 here: it has no send time, and inventing one
|
|
41
|
+
* from its creation would put an unsent message on the timeline of mail that
|
|
42
|
+
* went out. Ordering a list is {@link emailListTimeMs}'s job.
|
|
43
|
+
*
|
|
44
|
+
* @returns epoch ms, or 0 when the message has no time at all.
|
|
45
|
+
*/
|
|
46
|
+
export declare function emailSendTimeMs(record: Record<string, any> | null | undefined): number;
|
|
47
|
+
/**
|
|
48
|
+
* The field every writer of a message stamps when it MINTS the document.
|
|
49
|
+
*
|
|
50
|
+
* The one date on every message, which is what neither `sentAt` nor
|
|
51
|
+
* `sendAtMs` is — each is written by one branch of the send path and neither
|
|
52
|
+
* is on both kinds. A field carried by every record is the precondition for
|
|
53
|
+
* ordering this collection in Firestore rather than in the browser, because
|
|
54
|
+
* `orderBy` drops documents that lack the ordered field instead of
|
|
55
|
+
* mis-sorting them.
|
|
56
|
+
*
|
|
57
|
+
* Epoch milliseconds rather than a `Timestamp`, matching `sendAtMs` beside
|
|
58
|
+
* it: the two are compared against each other on every list that draws
|
|
59
|
+
* drafts and sends together, and a comparison that has to unwrap one side
|
|
60
|
+
* first is a comparison somebody writes wrong.
|
|
61
|
+
*/
|
|
62
|
+
export declare const EMAIL_CREATED_AT_FIELD = "createdAtMs";
|
|
63
|
+
/**
|
|
64
|
+
* When a message was created, for the records that carry it.
|
|
65
|
+
*
|
|
66
|
+
* `null` — not 0 — for a record carrying no {@link EMAIL_CREATED_AT_FIELD},
|
|
67
|
+
* because 0 is a real instant at the far end of the sort and "we do not know"
|
|
68
|
+
* is not "1970". A null orders by the record's send time instead, so the list
|
|
69
|
+
* is correct for a record from any era.
|
|
70
|
+
*/
|
|
71
|
+
export declare function emailCreatedAtMs(record: Record<string, any> | null | undefined): number | null;
|
|
72
|
+
/**
|
|
73
|
+
* WHERE A MESSAGE SITS IN A LIST OF MESSAGES.
|
|
74
|
+
*
|
|
75
|
+
* Its send time where it has one, and its creation time where it does not.
|
|
76
|
+
*
|
|
77
|
+
* A draft has neither `sentAt` nor `sendAtMs`, so ordering a list on the send
|
|
78
|
+
* time alone gives every draft the key 0 and files it below mail sent years
|
|
79
|
+
* ago — the newest thing a merchant did, at the bottom of the page, behind
|
|
80
|
+
* whatever paging the list has. Falling back to creation puts it where the
|
|
81
|
+
* merchant left it.
|
|
82
|
+
*
|
|
83
|
+
* The fallback is deliberately not the other way round. A SENT message orders
|
|
84
|
+
* by when it went out, never by when it was drafted: the list is a record of
|
|
85
|
+
* what happened, and a message drafted in March and sent in June belongs in
|
|
86
|
+
* June.
|
|
87
|
+
*
|
|
88
|
+
* @returns epoch ms, or 0 for a record with no time of any kind — a draft
|
|
89
|
+
* written before the created stamp existed, which sorts last exactly as it
|
|
90
|
+
* did before.
|
|
91
|
+
*/
|
|
92
|
+
export declare function emailListTimeMs(record: Record<string, any> | null | undefined): number;
|
|
93
|
+
/**
|
|
94
|
+
* Reader-facing text for a message's state.
|
|
95
|
+
*
|
|
96
|
+
* The KEYS are persisted values written by the send path and read by the
|
|
97
|
+
* scheduled-campaign processor — nothing here may change them, including
|
|
98
|
+
* `canceled`, which is already the American spelling and is a stored enum in
|
|
99
|
+
* either case. Only the right-hand side is display text, and an unrecognized
|
|
100
|
+
* state falls through as itself rather than being flattened into one of
|
|
101
|
+
* these: a state this list cannot name is worth seeing.
|
|
102
|
+
*
|
|
103
|
+
* `draft` is an email that has been created and not sent. `sending` is the
|
|
104
|
+
* claim the scheduled processor and the send-now route both take before they
|
|
105
|
+
* mail, and it is named here because a merchant who reloads mid-send would
|
|
106
|
+
* otherwise be shown the raw token.
|
|
107
|
+
*
|
|
108
|
+
* ⚠️ **Not what a console row should draw.** This names the STORED value, and
|
|
109
|
+
* an email delivering an audience larger than one batch is stored as
|
|
110
|
+
* `scheduled` between runs — so a row rendering this says "Scheduled" about a
|
|
111
|
+
* send that has already put five hundred messages in five hundred inboxes.
|
|
112
|
+
* `campaignSendDisplay` in `campaign-container.ts` reads the counters beside
|
|
113
|
+
* the status and is what every surface here draws. This remains the honest
|
|
114
|
+
* answer to the narrower question of what one stored token means.
|
|
115
|
+
*/
|
|
116
|
+
export declare function emailStateLabel(status: unknown): string;
|
|
117
|
+
/**
|
|
118
|
+
* Whether an email has yet been mailed to anybody.
|
|
119
|
+
*
|
|
120
|
+
* The surfaces that report on a send need this because an unsent email has no
|
|
121
|
+
* `stats` at all, and a report that divides into an absent denominator
|
|
122
|
+
* presents "not sent yet" as a delivery rate of 0% — the same class of fault
|
|
123
|
+
* as a rate rendered over a denominator it does not name.
|
|
124
|
+
*/
|
|
125
|
+
export declare function emailIsUnsent(record: Record<string, any> | null | undefined): boolean;
|
|
126
|
+
/**
|
|
127
|
+
* Reader-facing names for the built-in audience kinds.
|
|
128
|
+
*
|
|
129
|
+
* The KEYS are the persisted `audience` values written on every message since
|
|
130
|
+
* the composer shipped; only the values on the right are display text. A
|
|
131
|
+
* `list` audience is deliberately absent — it is not a kind of audience, it
|
|
132
|
+
* is a NAMED one, and naming it is {@link emailAudienceLabel}'s job.
|
|
133
|
+
*/
|
|
134
|
+
export declare const EMAIL_AUDIENCE_LABELS: Record<string, string>;
|
|
135
|
+
/**
|
|
136
|
+
* Which audience a message went to, named for a reader.
|
|
137
|
+
*
|
|
138
|
+
* A list send is named by the name the SEND recorded, never by the name the
|
|
139
|
+
* list carries today: resolving it now would let a rename rewrite the history
|
|
140
|
+
* of a message that went out months ago, and a deletion erase it. A send that
|
|
141
|
+
* recorded no name says so instead of printing a document id, which is not
|
|
142
|
+
* something a merchant recognises as a list.
|
|
143
|
+
*/
|
|
144
|
+
export declare function emailAudienceLabel(record: Record<string, any> | null | undefined): string;
|
|
145
|
+
/**
|
|
146
|
+
* The sender a message actually left with, as this record has it.
|
|
147
|
+
*
|
|
148
|
+
* `recorded` is the whole point of the shape. The address a send goes out on
|
|
149
|
+
* is stamped by the send — see `sentAsStamp` — and every message sent before
|
|
150
|
+
* that stamp existed carries nothing, so a surface has to be able to tell an
|
|
151
|
+
* unrecorded sender apart from one it could compose. Composing it is the
|
|
152
|
+
* thing that must not happen: a site's sending identity is a setting, and
|
|
153
|
+
* resolving it now would answer "what would this send as today" on a report
|
|
154
|
+
* whose subject is a message that went out months ago, under whatever address
|
|
155
|
+
* the site held then.
|
|
156
|
+
*
|
|
157
|
+
* The same rule {@link emailAudienceLabel} follows for a list name, and for
|
|
158
|
+
* the same reason: the report says what happened, not what would happen now.
|
|
159
|
+
*
|
|
160
|
+
* `replyTo` is null on a message that set none, which is not missing
|
|
161
|
+
* information — a message with no `Reply-To:` takes replies at its `from`,
|
|
162
|
+
* and that is a fact the record states by omission rather than one it lost.
|
|
163
|
+
* The difference only reads correctly when `recorded` is true, which is why
|
|
164
|
+
* a caller must consult that first.
|
|
165
|
+
*/
|
|
166
|
+
export interface EmailSentAs {
|
|
167
|
+
/** False on a message sent before the send began stamping its sender. */
|
|
168
|
+
recorded: boolean;
|
|
169
|
+
from: string | null;
|
|
170
|
+
/** The display name recipients saw, or null when the address stood alone. */
|
|
171
|
+
fromName: string | null;
|
|
172
|
+
/** Where replies were pointed, or null when they go to `from`. */
|
|
173
|
+
replyTo: string | null;
|
|
174
|
+
}
|
|
175
|
+
export declare function emailSentAs(record: Record<string, any> | null | undefined): EmailSentAs;
|