@aglyn/plugins-marketing 1.0.0-beta.219 → 1.0.0-beta.220

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +3 -1
  2. package/package.json +16 -16
  3. package/src/lib/components/besigner-section-experiments.d.ts +20 -0
  4. package/src/lib/components/besigner-section-experiments.js +128 -0
  5. package/src/lib/components/besigner-section-experiments.js.map +1 -0
  6. package/src/lib/components/campaign-conversions-card.js +1 -1
  7. package/src/lib/components/campaign-conversions-card.js.map +1 -1
  8. package/src/lib/components/campaign-members-section.js +4 -3
  9. package/src/lib/components/campaign-members-section.js.map +1 -1
  10. package/src/lib/components/campaign-reach-sections.js +4 -1
  11. package/src/lib/components/campaign-reach-sections.js.map +1 -1
  12. package/src/lib/components/campaign-report-card.js +2 -2
  13. package/src/lib/components/campaign-report-card.js.map +1 -1
  14. package/src/lib/components/conversion-attribution.component.d.ts +1 -1
  15. package/src/lib/components/conversion-attribution.component.js +1 -1
  16. package/src/lib/components/conversion-attribution.component.js.map +1 -1
  17. package/src/lib/components/record-attribution-widget.js +1 -1
  18. package/src/lib/components/record-attribution-widget.js.map +1 -1
  19. package/src/lib/components/site-runtime.js +2 -2
  20. package/src/lib/components/site-runtime.js.map +1 -1
  21. package/src/lib/constants/operator-alerts.js +4 -1
  22. package/src/lib/constants/operator-alerts.js.map +1 -1
  23. package/src/lib/declarations.server.d.ts +3 -1
  24. package/src/lib/declarations.server.js +17 -1
  25. package/src/lib/declarations.server.js.map +1 -1
  26. package/src/lib/model/campaign-conversions.d.ts +301 -0
  27. package/src/lib/model/campaign-conversions.js +252 -0
  28. package/src/lib/model/campaign-conversions.js.map +1 -0
  29. package/src/lib/model/campaign-kind.d.ts +50 -0
  30. package/src/lib/model/campaign-kind.js +52 -0
  31. package/src/lib/model/campaign-kind.js.map +1 -0
  32. package/src/lib/model/campaign-members-query.js +1 -1
  33. package/src/lib/model/campaign-members-query.js.map +1 -1
  34. package/src/lib/model/campaign-revenue.d.ts +327 -0
  35. package/src/lib/model/campaign-revenue.js +332 -0
  36. package/src/lib/model/campaign-revenue.js.map +1 -0
  37. package/src/lib/model/index.d.ts +3 -0
  38. package/src/lib/model/index.js +4 -1
  39. package/src/lib/model/index.js.map +1 -1
  40. package/src/lib/model/overlay-record-list.d.ts +33 -0
  41. package/src/lib/model/overlay-record-list.js +53 -0
  42. package/src/lib/model/overlay-record-list.js.map +1 -0
  43. package/src/lib/plugin.js +12 -0
  44. package/src/lib/plugin.js.map +1 -1
  45. package/src/lib/server/campaign-manage.js +19 -60
  46. package/src/lib/server/campaign-manage.js.map +1 -1
  47. package/src/lib/server/campaign-send.d.ts +1 -1
  48. package/src/lib/server/campaign-send.js +23 -7
  49. package/src/lib/server/campaign-send.js.map +1 -1
  50. package/src/lib/server/email-events.js +9 -10
  51. package/src/lib/server/email-events.js.map +1 -1
  52. package/src/lib/server/overlay-beacon.d.ts +46 -0
  53. package/src/lib/server/overlay-beacon.js +76 -0
  54. package/src/lib/server/overlay-beacon.js.map +1 -0
@@ -0,0 +1,52 @@
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
+ */ import { containerMembershipField } from "@aglyn/aglyn/app-utils/container-membership";
17
+ /**
18
+ * A CAMPAIGN, AS A CONTAINER KIND.
19
+ *
20
+ * This plugin keeps campaigns and declares the kind in `plugins.config.json`
21
+ * (`containers`): the containers are `orgs/{orgId}/emailCampaigns`, and a
22
+ * form, a screen, a lead or a contact is filed under one by carrying its id
23
+ * in `campaignIds` on its own document. The other plugins that file records
24
+ * under a campaign name the kind, never this module; the core derives the
25
+ * field from it (`app-utils/container-membership.ts`).
26
+ */ export const CAMPAIGN_KIND = 'campaign';
27
+ /**
28
+ * The field a member holds its campaigns in. Persisted on every filed form,
29
+ * screen and lead, and inside each holder's facet on a contact.
30
+ */ export const CAMPAIGN_MEMBERSHIP_FIELD = containerMembershipField(CAMPAIGN_KIND);
31
+ /**
32
+ * The host subcollections whose documents may name a campaign and that this
33
+ * plugin clears when a campaign is deleted: a form's and a screen's, filed
34
+ * from their own pages.
35
+ *
36
+ * The list a campaign's deletion walks, and the list the assignment surfaces
37
+ * spec reads — so a collection that grows a picker without growing the
38
+ * detach fails the build rather than shipping a campaign whose removal
39
+ * leaves that collection pointing at nothing.
40
+ *
41
+ * Another plugin's members are NOT here. A lead and a contact are the CRM's
42
+ * — a lead carries the field at the top of its document, on the org and on
43
+ * the site rows the lead migration has not reached, and a contact inside a
44
+ * per-holder facet — and a sequence and its enrollments are Outreach's. Each
45
+ * of those plugins registers a detacher
46
+ * (`plugin-manager/plugin-membership-detach.ts`) and the deletion runs it.
47
+ */ export const CAMPAIGN_MEMBER_HOST_COLLECTIONS = [
48
+ 'forms',
49
+ 'screens'
50
+ ];
51
+
52
+ //# sourceMappingURL=campaign-kind.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/campaign-kind.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 { containerMembershipField } from '@aglyn/aglyn/app-utils/container-membership'\n\n/**\n * A CAMPAIGN, AS A CONTAINER KIND.\n *\n * This plugin keeps campaigns and declares the kind in `plugins.config.json`\n * (`containers`): the containers are `orgs/{orgId}/emailCampaigns`, and a\n * form, a screen, a lead or a contact is filed under one by carrying its id\n * in `campaignIds` on its own document. The other plugins that file records\n * under a campaign name the kind, never this module; the core derives the\n * field from it (`app-utils/container-membership.ts`).\n */\nexport const CAMPAIGN_KIND = 'campaign'\n\n/**\n * The field a member holds its campaigns in. Persisted on every filed form,\n * screen and lead, and inside each holder's facet on a contact.\n */\nexport const CAMPAIGN_MEMBERSHIP_FIELD = containerMembershipField(CAMPAIGN_KIND)\n\n/**\n * The host subcollections whose documents may name a campaign and that this\n * plugin clears when a campaign is deleted: a form's and a screen's, filed\n * from their own pages.\n *\n * The list a campaign's deletion walks, and the list the assignment surfaces\n * spec reads — so a collection that grows a picker without growing the\n * detach fails the build rather than shipping a campaign whose removal\n * leaves that collection pointing at nothing.\n *\n * Another plugin's members are NOT here. A lead and a contact are the CRM's\n * — a lead carries the field at the top of its document, on the org and on\n * the site rows the lead migration has not reached, and a contact inside a\n * per-holder facet — and a sequence and its enrollments are Outreach's. Each\n * of those plugins registers a detacher\n * (`plugin-manager/plugin-membership-detach.ts`) and the deletion runs it.\n */\nexport const CAMPAIGN_MEMBER_HOST_COLLECTIONS = ['forms', 'screens'] as const\n"],"names":["containerMembershipField","CAMPAIGN_KIND","CAMPAIGN_MEMBERSHIP_FIELD","CAMPAIGN_MEMBER_HOST_COLLECTIONS"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,wBAAwB,QAAQ,8CAA6C;AAEtF;;;;;;;;;CASC,GACD,OAAO,MAAMC,gBAAgB,WAAU;AAEvC;;;CAGC,GACD,OAAO,MAAMC,4BAA4BF,yBAAyBC,eAAc;AAEhF;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,MAAME,mCAAmC;IAAC;IAAS;CAAU,CAAS"}
@@ -13,7 +13,7 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ import { CAMPAIGN_MEMBERSHIP_FIELD } from "@aglyn/aglyn/app-utils/campaign-membership";
16
+ */ import { CAMPAIGN_MEMBERSHIP_FIELD } from "./campaign-kind.js";
17
17
  import { LIST_QUERY_ID_PATH } from "@aglyn/shared-ui-jsx/const/list-query-plan";
18
18
  /*
19
19
  * WHAT A CAMPAIGN'S SCREENS AND FORMS LISTS ASK FIRESTORE (AGL-3321).
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/campaign-members-query.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 { CAMPAIGN_MEMBERSHIP_FIELD } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport type { ListFilterClause } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport {\n LIST_QUERY_ID_PATH,\n type ListQueryDeclaration,\n type ListQueryFilter,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\n\n/*\n * WHAT A CAMPAIGN'S SCREENS AND FORMS LISTS ASK FIRESTORE (AGL-3321).\n *\n * A campaign's page lists the screens and the forms filed under it, each a\n * table paged by its own query over `hosts/{hostId}/screens` or `/forms`.\n * The search box and the soft delete are predicates on that query, so a\n * member on the fourth page is found and a deleted one is never a row.\n *\n * ## The base: the membership, and (for screens) not deleted\n *\n * `campaignIds array-contains {campaign}` is the join — the member's own\n * field, read from the other end — and it is the query's ONE array clause.\n *\n * A screen is soft-deleted: a delete stamps `deletedAt` and leaves the\n * document, with its campaigns, in place. So the screens query also asks\n * `deletedAt == null`, which matches only a document that HOLDS the field:\n * every screen create stamps `deletedAt: null` (`artifactCreateListKeys`),\n * and `tools/scripts/backfill-artifacts-list-keys.mjs` stamps the screens\n * written before it. A form has no soft delete — it is deleted outright —\n * so its base is the membership alone, and a retired form stays a member.\n *\n * Unsearched, both lists walk the document name, which every record has:\n * the equality and the array clause merge on Firestore's single-field\n * indexes, so the default read needs no composite.\n *\n * ## The search is a PREFIX RANGE on `nameLower`\n *\n * A word search is an `array-contains` on `nameTokens`, and the membership\n * has already spent the query's one array clause. So the search matches the\n * START of the name — `nameLower` from the typed text to the text followed\n * by the highest character — which is a range, orders the page by name while\n * it applies, and is said above the table (`CAMPAIGN_MEMBERS_SEARCH_NOTICE`),\n * as a site's campaigns list says it.\n *\n * ## The composites: two\n *\n * A range beside equalities needs a composite. Under index merging this\n * shape would take one `(predicate, nameLower)` pair per predicate — three\n * across the two collections — so each collection instead carries the ONE\n * composite that serves its whole search shape:\n *\n * screens campaignIds CONTAINS, deletedAt ASC, nameLower ASC\n * forms campaignIds CONTAINS, nameLower ASC\n *\n * Pinned by `list-query-indexes.spec.ts`. Nothing else is offered: a column\n * filter would be one more predicate beside the range, and so one more\n * composite per collection, for lists that hold a campaign's handful of\n * pages and forms.\n */\n\n/** The name field the search ranges over, as the planner reads it. */\nconst MEMBER_NAME: ListFilterField = {\n column: 'name',\n kind: 'text',\n path: 'displayName',\n lowerPath: 'nameLower',\n operators: ['startsWith'],\n}\n\n/** A campaign's screens or forms: the name's start, walked by document name. */\nexport const CAMPAIGN_MEMBERS_QUERY: ListQueryDeclaration = {\n fields: [MEMBER_NAME],\n sorts: [{ path: LIST_QUERY_ID_PATH, direction: 'asc' }],\n}\n\n/** The collections a campaign's members are read from. */\nexport type CampaignMemberCollection = 'screens' | 'forms'\n\n/** A screen that has not been deleted: `deletedAt` stored as `null`. */\nexport const SCREEN_NOT_DELETED: ListQueryFilter = {\n path: 'deletedAt',\n op: '==',\n value: null,\n}\n\n/**\n * The predicates every read of a campaign's members carries: the\n * membership, and for screens the soft delete.\n */\nexport function campaignMembersBase(\n collection: CampaignMemberCollection,\n campaignId: string,\n): ListQueryFilter[] {\n return [\n { path: CAMPAIGN_MEMBERSHIP_FIELD, op: 'array-contains', value: campaignId },\n ...(collection === 'screens' ? [SCREEN_NOT_DELETED] : []),\n ]\n}\n\n/** Said above a member table while its search applies. */\nexport const CAMPAIGN_MEMBERS_SEARCH_NOTICE =\n 'Search here matches the start of a name.'\n\n/**\n * The search box's words as the clause the query serves: \"Name starts\n * with\", or null when nothing was typed. The table passes it among its\n * clauses and no search words — see the file header for why.\n */\nexport function campaignMembersSearchClause(\n words: readonly string[],\n): ListFilterClause | null {\n const typed = words\n .map((word) => word.trim())\n .filter(Boolean)\n .join(' ')\n return typed ? { field: 'name', op: 'startsWith', value: typed } : null\n}\n"],"names":["CAMPAIGN_MEMBERSHIP_FIELD","LIST_QUERY_ID_PATH","MEMBER_NAME","column","kind","path","lowerPath","operators","CAMPAIGN_MEMBERS_QUERY","fields","sorts","direction","SCREEN_NOT_DELETED","op","value","campaignMembersBase","collection","campaignId","CAMPAIGN_MEMBERS_SEARCH_NOTICE","campaignMembersSearchClause","words","typed","map","word","trim","filter","Boolean","join","field"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,yBAAyB,QAAQ,6CAA4C;AAGtF,SACEC,kBAAkB,QAGb,6CAA4C;AAEnD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgDC,GAED,oEAAoE,GACpE,MAAMC,cAA+B;IACnCC,QAAQ;IACRC,MAAM;IACNC,MAAM;IACNC,WAAW;IACXC,WAAW;QAAC;KAAa;AAC3B;AAEA,8EAA8E,GAC9E,OAAO,MAAMC,yBAA+C;IAC1DC,QAAQ;QAACP;KAAY;IACrBQ,OAAO;QAAC;YAAEL,MAAMJ;YAAoBU,WAAW;QAAM;KAAE;AACzD,EAAC;AAKD,sEAAsE,GACtE,OAAO,MAAMC,qBAAsC;IACjDP,MAAM;IACNQ,IAAI;IACJC,OAAO;AACT,EAAC;AAED;;;CAGC,GACD,OAAO,SAASC,oBACdC,UAAoC,EACpCC,UAAkB;IAElB,OAAO;QACL;YAAEZ,MAAML;YAA2Ba,IAAI;YAAkBC,OAAOG;QAAW;WACvED,eAAe,YAAY;YAACJ;SAAmB,GAAG,EAAE;KACzD;AACH;AAEA,wDAAwD,GACxD,OAAO,MAAMM,iCACX,2CAA0C;AAE5C;;;;CAIC,GACD,OAAO,SAASC,4BACdC,KAAwB;IAExB,MAAMC,QAAQD,MACXE,GAAG,CAAC,CAACC,OAASA,KAAKC,IAAI,IACvBC,MAAM,CAACC,SACPC,IAAI,CAAC;IACR,OAAON,QAAQ;QAAEO,OAAO;QAAQf,IAAI;QAAcC,OAAOO;IAAM,IAAI;AACrE"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/campaign-members-query.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 { CAMPAIGN_MEMBERSHIP_FIELD } from './campaign-kind'\nimport type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport type { ListFilterClause } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport {\n LIST_QUERY_ID_PATH,\n type ListQueryDeclaration,\n type ListQueryFilter,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\n\n/*\n * WHAT A CAMPAIGN'S SCREENS AND FORMS LISTS ASK FIRESTORE (AGL-3321).\n *\n * A campaign's page lists the screens and the forms filed under it, each a\n * table paged by its own query over `hosts/{hostId}/screens` or `/forms`.\n * The search box and the soft delete are predicates on that query, so a\n * member on the fourth page is found and a deleted one is never a row.\n *\n * ## The base: the membership, and (for screens) not deleted\n *\n * `campaignIds array-contains {campaign}` is the join — the member's own\n * field, read from the other end — and it is the query's ONE array clause.\n *\n * A screen is soft-deleted: a delete stamps `deletedAt` and leaves the\n * document, with its campaigns, in place. So the screens query also asks\n * `deletedAt == null`, which matches only a document that HOLDS the field:\n * every screen create stamps `deletedAt: null` (`artifactCreateListKeys`),\n * and `tools/scripts/backfill-artifacts-list-keys.mjs` stamps the screens\n * written before it. A form has no soft delete — it is deleted outright —\n * so its base is the membership alone, and a retired form stays a member.\n *\n * Unsearched, both lists walk the document name, which every record has:\n * the equality and the array clause merge on Firestore's single-field\n * indexes, so the default read needs no composite.\n *\n * ## The search is a PREFIX RANGE on `nameLower`\n *\n * A word search is an `array-contains` on `nameTokens`, and the membership\n * has already spent the query's one array clause. So the search matches the\n * START of the name — `nameLower` from the typed text to the text followed\n * by the highest character — which is a range, orders the page by name while\n * it applies, and is said above the table (`CAMPAIGN_MEMBERS_SEARCH_NOTICE`),\n * as a site's campaigns list says it.\n *\n * ## The composites: two\n *\n * A range beside equalities needs a composite. Under index merging this\n * shape would take one `(predicate, nameLower)` pair per predicate — three\n * across the two collections — so each collection instead carries the ONE\n * composite that serves its whole search shape:\n *\n * screens campaignIds CONTAINS, deletedAt ASC, nameLower ASC\n * forms campaignIds CONTAINS, nameLower ASC\n *\n * Pinned by `list-query-indexes.spec.ts`. Nothing else is offered: a column\n * filter would be one more predicate beside the range, and so one more\n * composite per collection, for lists that hold a campaign's handful of\n * pages and forms.\n */\n\n/** The name field the search ranges over, as the planner reads it. */\nconst MEMBER_NAME: ListFilterField = {\n column: 'name',\n kind: 'text',\n path: 'displayName',\n lowerPath: 'nameLower',\n operators: ['startsWith'],\n}\n\n/** A campaign's screens or forms: the name's start, walked by document name. */\nexport const CAMPAIGN_MEMBERS_QUERY: ListQueryDeclaration = {\n fields: [MEMBER_NAME],\n sorts: [{ path: LIST_QUERY_ID_PATH, direction: 'asc' }],\n}\n\n/** The collections a campaign's members are read from. */\nexport type CampaignMemberCollection = 'screens' | 'forms'\n\n/** A screen that has not been deleted: `deletedAt` stored as `null`. */\nexport const SCREEN_NOT_DELETED: ListQueryFilter = {\n path: 'deletedAt',\n op: '==',\n value: null,\n}\n\n/**\n * The predicates every read of a campaign's members carries: the\n * membership, and for screens the soft delete.\n */\nexport function campaignMembersBase(\n collection: CampaignMemberCollection,\n campaignId: string,\n): ListQueryFilter[] {\n return [\n { path: CAMPAIGN_MEMBERSHIP_FIELD, op: 'array-contains', value: campaignId },\n ...(collection === 'screens' ? [SCREEN_NOT_DELETED] : []),\n ]\n}\n\n/** Said above a member table while its search applies. */\nexport const CAMPAIGN_MEMBERS_SEARCH_NOTICE =\n 'Search here matches the start of a name.'\n\n/**\n * The search box's words as the clause the query serves: \"Name starts\n * with\", or null when nothing was typed. The table passes it among its\n * clauses and no search words — see the file header for why.\n */\nexport function campaignMembersSearchClause(\n words: readonly string[],\n): ListFilterClause | null {\n const typed = words\n .map((word) => word.trim())\n .filter(Boolean)\n .join(' ')\n return typed ? { field: 'name', op: 'startsWith', value: typed } : null\n}\n"],"names":["CAMPAIGN_MEMBERSHIP_FIELD","LIST_QUERY_ID_PATH","MEMBER_NAME","column","kind","path","lowerPath","operators","CAMPAIGN_MEMBERS_QUERY","fields","sorts","direction","SCREEN_NOT_DELETED","op","value","campaignMembersBase","collection","campaignId","CAMPAIGN_MEMBERS_SEARCH_NOTICE","campaignMembersSearchClause","words","typed","map","word","trim","filter","Boolean","join","field"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,yBAAyB,QAAQ,qBAAiB;AAG3D,SACEC,kBAAkB,QAGb,6CAA4C;AAEnD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgDC,GAED,oEAAoE,GACpE,MAAMC,cAA+B;IACnCC,QAAQ;IACRC,MAAM;IACNC,MAAM;IACNC,WAAW;IACXC,WAAW;QAAC;KAAa;AAC3B;AAEA,8EAA8E,GAC9E,OAAO,MAAMC,yBAA+C;IAC1DC,QAAQ;QAACP;KAAY;IACrBQ,OAAO;QAAC;YAAEL,MAAMJ;YAAoBU,WAAW;QAAM;KAAE;AACzD,EAAC;AAKD,sEAAsE,GACtE,OAAO,MAAMC,qBAAsC;IACjDP,MAAM;IACNQ,IAAI;IACJC,OAAO;AACT,EAAC;AAED;;;CAGC,GACD,OAAO,SAASC,oBACdC,UAAoC,EACpCC,UAAkB;IAElB,OAAO;QACL;YAAEZ,MAAML;YAA2Ba,IAAI;YAAkBC,OAAOG;QAAW;WACvED,eAAe,YAAY;YAACJ;SAAmB,GAAG,EAAE;KACzD;AACH;AAEA,wDAAwD,GACxD,OAAO,MAAMM,iCACX,2CAA0C;AAE5C;;;;CAIC,GACD,OAAO,SAASC,4BACdC,KAAwB;IAExB,MAAMC,QAAQD,MACXE,GAAG,CAAC,CAACC,OAASA,KAAKC,IAAI,IACvBC,MAAM,CAACC,SACPC,IAAI,CAAC;IACR,OAAON,QAAQ;QAAEO,OAAO;QAAQf,IAAI;QAAcC,OAAOO;IAAM,IAAI;AACrE"}
@@ -0,0 +1,327 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type CampaignCaveat } from '@aglyn/shared-ui-email-campaigns/model/campaign-report';
18
+ /**
19
+ * WHAT A CAMPAIGN EARNED — the read half of the commerce↔email join.
20
+ *
21
+ * ## Why this is a join and not an attribution model
22
+ *
23
+ * Every compared ESP reconstructs campaign revenue probabilistically, because
24
+ * none of them owns the order. Klaviyo and Mailchimp watch someone else's
25
+ * store through an integration and a browser snippet, so their figure is a
26
+ * reconciliation against a foreign system and their window is the fudge
27
+ * factor that makes the reconciliation close. Commerce here is first-party:
28
+ * the click and the order are rows in one database, keyed the same way, so
29
+ * the "attribution" is a lookup.
30
+ *
31
+ * That does not make the MODEL choice go away — two campaigns can both have
32
+ * touched a buyer and only one can be credited — but it does mean the model
33
+ * is the only judgement in the number. There is no sampling, no identity
34
+ * resolution and no cookie.
35
+ *
36
+ * ## The model: LAST CLICK, inside a fixed 7-day window
37
+ *
38
+ * Stated in one sentence, which is the whole requirement: **an order is
39
+ * credited to the last campaign whose link the buyer clicked, if they clicked
40
+ * it within the {@link EMAIL_ATTRIBUTION_WINDOW_DAYS} days before they
41
+ * ordered.**
42
+ *
43
+ * Three decisions are inside that sentence.
44
+ *
45
+ * **Last touch, not multi-touch.** Multi-touch is the more honest description
46
+ * of how buying works and it is unpresentable: it splits one order across
47
+ * several campaigns by a rule the merchant did not choose, so no campaign's
48
+ * revenue is a number they can check against their own bank, and two
49
+ * campaigns' figures cannot be added or compared without knowing the split
50
+ * rule. HubSpot ships multi-touch and puts it behind an Enterprise plan and a
51
+ * consultant. A figure a merchant cannot explain to themselves is worse than
52
+ * no figure, and it is worse in the specific way that matters here: they will
53
+ * still make decisions with it.
54
+ *
55
+ * **A CLICK is the touch. An open is not.** An open is evidence about the
56
+ * recipient's mail client, not about the recipient — Apple's Mail Privacy
57
+ * Protection prefetches images, which inflated network-wide open rates by
58
+ * roughly 15% and means a large share of recorded opens had no human behind
59
+ * them. Crediting revenue to an open would therefore credit campaigns for
60
+ * orders from people who never saw them, and the error is not random: it
61
+ * concentrates on whichever campaign most recently reached an Apple Mail
62
+ * user. `email-delivery-log.ts` records the same preference for the same
63
+ * reason, and the audience rules already segment on clicks over opens.
64
+ *
65
+ * **Seven days, and not configurable.** Klaviyo's window is 1–30 days per
66
+ * channel; ActiveCampaign's is a fixed, unadjustable 7. Fixed is the better
67
+ * default here because a configurable window is a setting whose change
68
+ * silently rewrites history: yesterday's report and today's would disagree
69
+ * about a campaign that has not been touched since, with nothing on screen to
70
+ * say why. The stored record carries `windowDays` per order for exactly that
71
+ * reason — a future setting can be added without making the orders already
72
+ * attributed unreadable, because each one says which window it was judged
73
+ * under.
74
+ *
75
+ * ## GROSS and REFUNDED, never a decrement
76
+ *
77
+ * A refunded order must stop counting as revenue a campaign earned, and there
78
+ * are two ways to make it stop. Decrementing the gross figure makes a stored
79
+ * number mean one thing before a refund and another after, with nothing to
80
+ * distinguish them; recording the reversal beside it keeps both facts. This
81
+ * is the shape `contact-refund.ts` chose for `ltvCents`/`refundedCents` on the
82
+ * contact and the orders CSV chose for `amountUsd`/`refundedUsd`, and it is
83
+ * chosen again here so all three answer "what did this earn, net" the same
84
+ * way.
85
+ *
86
+ * So {@link campaignRevenueReport} reports gross, refunded and net, and NET
87
+ * is the figure the screen leads with. Net is clamped at zero for display
88
+ * only: a refund larger than the sale it reverses is arithmetically possible
89
+ * on an order attributed before a partial refund settled, and a negative
90
+ * campaign revenue is a sentence nobody can act on.
91
+ *
92
+ * ## Currencies are never summed
93
+ *
94
+ * Money is stored in minor units and no currency travels with it — every
95
+ * checkout door in this repo sets `currency: 'usd'` on the Stripe line items,
96
+ * so the amounts really are all USD, but that is a fact about the code rather
97
+ * than a field on the order. The rollup therefore buckets BY currency and
98
+ * this module never adds two buckets together. A campaign with two currencies
99
+ * renders two blocks and no total, and says so.
100
+ *
101
+ * The rule survives the merge across a container's emails.
102
+ * {@link campaignRevenueAcrossSends} keys its accumulator on the currency, so
103
+ * two rollups can only ever meet inside a bucket they already share — there
104
+ * is no code path in which a USD amount and a EUR amount reach the same
105
+ * addition, and no combined figure exists for a screen to print by accident.
106
+ */
107
+ /**
108
+ * Re-exported so a reader of the report has the window and the model name
109
+ * without reaching past this module for them — the screen prints both, and a
110
+ * window nobody can see is a window nobody can check.
111
+ */
112
+ export { EMAIL_ATTRIBUTION_MODEL, EMAIL_ATTRIBUTION_WINDOW_DAYS, EMAIL_ATTRIBUTION_WINDOW_MS, } from '@aglyn/shared-util-email';
113
+ /** One currency's totals as the rollup stores them. */
114
+ export interface CampaignRevenueCurrencyStored {
115
+ /** Minor units credited to this campaign, gross of refunds. */
116
+ grossCents?: number;
117
+ /** Minor units handed back on orders that had been credited. */
118
+ refundedCents?: number;
119
+ /** Orders credited to this campaign. */
120
+ orders?: number;
121
+ /** Of those, how many ended fully reversed. */
122
+ refundedOrders?: number;
123
+ }
124
+ /**
125
+ * The stored shape of `campaigns/{campaignId}/reports/revenue`.
126
+ *
127
+ * Its own document, for the reason the link rollup is its own document: the
128
+ * campaign document is read by the history list, the glance widget and the
129
+ * send path, and a map that grows with the campaign's sales would make every
130
+ * one of those reads larger. Split, it is read by the one screen that draws
131
+ * it.
132
+ */
133
+ export interface CampaignRevenueRollup {
134
+ byCurrency?: Record<string, CampaignRevenueCurrencyStored>;
135
+ /** The model the orders in this rollup were credited under. */
136
+ model?: string;
137
+ /** The window, in days, they were credited inside. */
138
+ windowDays?: number;
139
+ }
140
+ /**
141
+ * Money over a population, with the population named — {@link CampaignRate}'s
142
+ * rule applied to an average instead of a share.
143
+ *
144
+ * A percentage and an average go wrong the same way, so they carry the same
145
+ * guarantee: the denominator travels as data and the screen has to print it.
146
+ * "$0.42 per recipient" over an audience nobody named is the figure this
147
+ * whole reporting surface exists to refuse.
148
+ */
149
+ export interface CampaignMoneyPerMessage {
150
+ /** Minor units per message of the denominator. Fractional by nature. */
151
+ cents: number;
152
+ numeratorCents: number;
153
+ denominator: number;
154
+ denominatorLabel: string;
155
+ currency: string;
156
+ }
157
+ /** One currency's block on screen. */
158
+ export interface CampaignRevenueCurrencyReport {
159
+ /** Lowercase ISO code as the sale recorded it, e.g. `'usd'`. */
160
+ currency: string;
161
+ grossCents: number;
162
+ refundedCents: number;
163
+ /** `gross - refunded`, clamped at zero. */
164
+ netCents: number;
165
+ orders: number;
166
+ refundedOrders: number;
167
+ /**
168
+ * Net revenue per DELIVERED message, or `null` when it cannot be taken.
169
+ *
170
+ * Delivered is the denominator for the same reason every engagement rate on
171
+ * this report is taken over it: mail that bounced was never in front of a
172
+ * human, so counting it depresses a figure describing the audience with a
173
+ * fact about the address list.
174
+ *
175
+ * It is deliberately NOT taken over `audienceSize`. That figure is written
176
+ * by the FIRST batch only and is a floor when audience resolution hit its
177
+ * read ceiling, so a campaign delivered over six runs would divide six
178
+ * runs' revenue by one run's measure of the audience — the stale-population
179
+ * division this surface is built to make impossible.
180
+ */
181
+ netPerDelivered: CampaignMoneyPerMessage | null;
182
+ }
183
+ /** Everything the revenue section renders. */
184
+ export interface CampaignRevenueReport {
185
+ /** One block per currency, largest net first. Never summed together. */
186
+ currencies: CampaignRevenueCurrencyReport[];
187
+ /** Orders credited to this campaign, across every currency. */
188
+ attributedOrders: number;
189
+ /**
190
+ * Whether any attribution has ever been recorded for this campaign.
191
+ *
192
+ * `false` means the rollup document does not exist, which is NOT the same
193
+ * as "this campaign earned nothing" — it is also every campaign sent before
194
+ * the join existed, and every campaign on a site with no store. The screen
195
+ * renders the difference rather than printing a zero for both.
196
+ */
197
+ recorded: boolean;
198
+ /** More than one currency is present, so no total may be shown. */
199
+ multiCurrency: boolean;
200
+ /** The model these figures were credited under, as stored. */
201
+ model: string;
202
+ /** The window they were credited inside, as stored. */
203
+ windowDays: number;
204
+ caveats: CampaignCaveat[];
205
+ }
206
+ /**
207
+ * Why there is no total, in the words both reports use.
208
+ *
209
+ * One sentence, one definition. A send's report and its campaign's report
210
+ * make the same refusal for the same reason, and two copies of the sentence
211
+ * is how one of them comes to be softened into a promise of a total.
212
+ */
213
+ export declare const REVENUE_MULTI_CURRENCY_MESSAGE: string;
214
+ /**
215
+ * Money per message, or `null` when the division cannot honestly be taken.
216
+ *
217
+ * The three refusals are {@link campaignRate}'s, and this defers to it rather
218
+ * than restating them: a zero denominator, an unrecorded denominator, and a
219
+ * non-finite input all answer `null` there, so a second implementation of
220
+ * "when may we divide" cannot drift from the first.
221
+ */
222
+ export declare function campaignMoneyPerMessage(numeratorCents: number, denominator: number | undefined, denominatorLabel: string, currency: string): CampaignMoneyPerMessage | null;
223
+ /**
224
+ * Turns the stored rollup into the revenue section.
225
+ *
226
+ * `delivered` comes from the campaign's own `stats` and is passed in rather
227
+ * than re-read, so the numerator and the denominator on screen are taken from
228
+ * the same instant. It is `null` when no delivery event has ever been
229
+ * recorded — the campaign predates the delivery webhook, or the events are
230
+ * still in flight — and every figure over it is then withheld with a caveat,
231
+ * never substituted for `sent`.
232
+ */
233
+ export declare function campaignRevenueReport(options: {
234
+ rollup: CampaignRevenueRollup | undefined;
235
+ /** `stats.delivered`, or `null` when it was never recorded. */
236
+ delivered: number | null;
237
+ /** True while the send is still working through its audience. */
238
+ midFlight?: boolean;
239
+ }): CampaignRevenueReport;
240
+ /** One currency's totals, merged across a campaign's emails. */
241
+ export interface CampaignRevenueCurrencyAcrossSends {
242
+ /** Lowercase ISO code as the sales recorded it, e.g. `'usd'`. */
243
+ currency: string;
244
+ grossCents: number;
245
+ refundedCents: number;
246
+ /** `gross - refunded` over the whole campaign, clamped once at zero. */
247
+ netCents: number;
248
+ orders: number;
249
+ refundedOrders: number;
250
+ /** Emails of the campaign whose revenue record holds this currency. */
251
+ emails: number;
252
+ }
253
+ /** Everything a campaign container's revenue section renders. */
254
+ export interface CampaignRevenueAcrossSends {
255
+ /** One block per currency, largest net first. Never summed together. */
256
+ currencies: CampaignRevenueCurrencyAcrossSends[];
257
+ /**
258
+ * Orders credited across every currency.
259
+ *
260
+ * A COUNT, which is why it may cross currencies when the money may not: an
261
+ * order is one order whatever it was paid in, and counting two of them
262
+ * loses nothing. Adding their amounts loses the unit.
263
+ */
264
+ attributedOrders: number;
265
+ /** Emails whose revenue record was looked for. */
266
+ read: number;
267
+ /**
268
+ * Of those, how many have a revenue record at all.
269
+ *
270
+ * The rollup is created by the attribution writer on the first order it
271
+ * credits, so an email with no record has never been credited with one.
272
+ * Zero across the whole campaign is therefore the container's version of
273
+ * {@link CampaignRevenueReport.recorded} being `false`: it is not "this
274
+ * campaign earned nothing", it is also every campaign sent before the join
275
+ * existed and every campaign on a site with no store.
276
+ */
277
+ recorded: number;
278
+ /** More than one currency is present, so no total may be shown. */
279
+ multiCurrency: boolean;
280
+ /** The models these emails were credited under, distinct and sorted. */
281
+ models: string[];
282
+ /** The windows, in days, they were credited inside, distinct and sorted. */
283
+ windowDays: number[];
284
+ caveats: CampaignCaveat[];
285
+ }
286
+ /**
287
+ * MERGES A CAMPAIGN'S EMAILS INTO ONE REVENUE SECTION, per currency.
288
+ *
289
+ * `reports/revenue` is written per SEND, so a container's figure is one
290
+ * document per email and a merge. The merge is the whole risk: two emails of
291
+ * one campaign can have earned in different currencies, and adding their
292
+ * amounts produces a number that is wrong with nothing on screen to show it.
293
+ *
294
+ * ## The currency is the accumulator's KEY, not a field beside the amount
295
+ *
296
+ * That is the structural half of the guarantee. Amounts are added into a map
297
+ * keyed by currency, so two amounts can only reach the same addition when
298
+ * they already carry the same code — a USD figure and a EUR figure have no
299
+ * path to each other, whatever a later caller asks for. There is no combined
300
+ * field on the result, so a screen cannot print a cross-currency total by
301
+ * reading the wrong property, and
302
+ * {@link CampaignRevenueAcrossSends.multiCurrency} is what tells the screen
303
+ * to label the blocks.
304
+ *
305
+ * ## Clamped ONCE, over the campaign
306
+ *
307
+ * The single-send report clamps its net at zero because a partial refund can
308
+ * settle against an order credited at the full charge. Doing that per email
309
+ * and then summing would let one over-refunded email keep money a sibling
310
+ * email handed back — an email at -$50 clamped to $0 beside one at $200 would
311
+ * report $200 for a campaign holding $150. So gross and refunded are summed
312
+ * as they stand and the difference is clamped once, at the end.
313
+ *
314
+ * ## No per-message average, deliberately
315
+ *
316
+ * The send report divides net revenue by that send's own `delivered`, taken
317
+ * from the same document at the same instant. A container has no such pair.
318
+ * Its delivery total is summed over the emails that RECORDED a delivery count
319
+ * and its revenue over the emails that have a revenue record, and those are
320
+ * different subsets of the campaign — so the quotient would be an average
321
+ * over a population nobody named, which is the defect the whole reporting
322
+ * surface is built to refuse. The amounts are reported without one.
323
+ *
324
+ * @param rollups - one entry per email read, `undefined` where no record
325
+ * exists.
326
+ */
327
+ export declare function campaignRevenueAcrossSends(rollups: readonly (CampaignRevenueRollup | undefined)[]): CampaignRevenueAcrossSends;