@aglyn/tenant-data-admin 1.0.0-beta.143
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +7 -0
- package/jest.integration.config.d.ts +39 -0
- package/jest.integration.setup.d.ts +17 -0
- package/package.json +51 -0
- package/src/index.d.ts +17 -0
- package/src/index.js +18 -0
- package/src/index.js.map +1 -0
- package/src/lib/render-cache.d.ts +184 -0
- package/src/lib/render-cache.js +198 -0
- package/src/lib/render-cache.js.map +1 -0
- package/src/lib/server/account-addresses.d.ts +160 -0
- package/src/lib/server/account-addresses.js +311 -0
- package/src/lib/server/account-addresses.js.map +1 -0
- package/src/lib/server/account-emails.d.ts +219 -0
- package/src/lib/server/account-emails.js +640 -0
- package/src/lib/server/account-emails.js.map +1 -0
- package/src/lib/server/admin-audit.d.ts +180 -0
- package/src/lib/server/admin-audit.js +233 -0
- package/src/lib/server/admin-audit.js.map +1 -0
- package/src/lib/server/analytics-retention.d.ts +63 -0
- package/src/lib/server/analytics-retention.js +66 -0
- package/src/lib/server/analytics-retention.js.map +1 -0
- package/src/lib/server/api-http.d.ts +159 -0
- package/src/lib/server/api-http.js +230 -0
- package/src/lib/server/api-http.js.map +1 -0
- package/src/lib/server/api-keys.d.ts +117 -0
- package/src/lib/server/api-keys.js +298 -0
- package/src/lib/server/api-keys.js.map +1 -0
- package/src/lib/server/auth-handoff.d.ts +165 -0
- package/src/lib/server/auth-handoff.js +482 -0
- package/src/lib/server/auth-handoff.js.map +1 -0
- package/src/lib/server/auth-pools.d.ts +246 -0
- package/src/lib/server/auth-pools.js +551 -0
- package/src/lib/server/auth-pools.js.map +1 -0
- package/src/lib/server/billing-addons.d.ts +171 -0
- package/src/lib/server/billing-addons.js +277 -0
- package/src/lib/server/billing-addons.js.map +1 -0
- package/src/lib/server/campaign-attribution-store.d.ts +55 -0
- package/src/lib/server/campaign-attribution-store.js +95 -0
- package/src/lib/server/campaign-attribution-store.js.map +1 -0
- package/src/lib/server/campaign-conversion-attribution.d.ts +205 -0
- package/src/lib/server/campaign-conversion-attribution.js +279 -0
- package/src/lib/server/campaign-conversion-attribution.js.map +1 -0
- package/src/lib/server/client-error-report.d.ts +301 -0
- package/src/lib/server/client-error-report.js +755 -0
- package/src/lib/server/client-error-report.js.map +1 -0
- package/src/lib/server/connect-account-status.d.ts +74 -0
- package/src/lib/server/connect-account-status.js +94 -0
- package/src/lib/server/connect-account-status.js.map +1 -0
- package/src/lib/server/connect-payout-failure.d.ts +103 -0
- package/src/lib/server/connect-payout-failure.js +127 -0
- package/src/lib/server/connect-payout-failure.js.map +1 -0
- package/src/lib/server/console-api-rate-limit.d.ts +81 -0
- package/src/lib/server/console-api-rate-limit.js +111 -0
- package/src/lib/server/console-api-rate-limit.js.map +1 -0
- package/src/lib/server/console-domains.d.ts +326 -0
- package/src/lib/server/console-domains.js +779 -0
- package/src/lib/server/console-domains.js.map +1 -0
- package/src/lib/server/consume-once.d.ts +91 -0
- package/src/lib/server/consume-once.js +96 -0
- package/src/lib/server/consume-once.js.map +1 -0
- package/src/lib/server/contact-company-link.d.ts +85 -0
- package/src/lib/server/contact-company-link.js +105 -0
- package/src/lib/server/contact-company-link.js.map +1 -0
- package/src/lib/server/contact-email-engagement.d.ts +32 -0
- package/src/lib/server/contact-email-engagement.js +145 -0
- package/src/lib/server/contact-email-engagement.js.map +1 -0
- package/src/lib/server/contact-email-index.d.ts +56 -0
- package/src/lib/server/contact-email-index.js +158 -0
- package/src/lib/server/contact-email-index.js.map +1 -0
- package/src/lib/server/contact-lifecycle-floor.d.ts +89 -0
- package/src/lib/server/contact-lifecycle-floor.js +88 -0
- package/src/lib/server/contact-lifecycle-floor.js.map +1 -0
- package/src/lib/server/contact-merge.d.ts +55 -0
- package/src/lib/server/contact-merge.js +279 -0
- package/src/lib/server/contact-merge.js.map +1 -0
- package/src/lib/server/contact-suppression.d.ts +153 -0
- package/src/lib/server/contact-suppression.js +259 -0
- package/src/lib/server/contact-suppression.js.map +1 -0
- package/src/lib/server/crm-booking-activity.d.ts +86 -0
- package/src/lib/server/crm-booking-activity.js +251 -0
- package/src/lib/server/crm-booking-activity.js.map +1 -0
- package/src/lib/server/crm-email-activity.d.ts +112 -0
- package/src/lib/server/crm-email-activity.js +143 -0
- package/src/lib/server/crm-email-activity.js.map +1 -0
- package/src/lib/server/crm-inbound-email.d.ts +145 -0
- package/src/lib/server/crm-inbound-email.js +287 -0
- package/src/lib/server/crm-inbound-email.js.map +1 -0
- package/src/lib/server/crm-next-activity.d.ts +74 -0
- package/src/lib/server/crm-next-activity.js +194 -0
- package/src/lib/server/crm-next-activity.js.map +1 -0
- package/src/lib/server/crm-records.d.ts +144 -0
- package/src/lib/server/crm-records.js +218 -0
- package/src/lib/server/crm-records.js.map +1 -0
- package/src/lib/server/csp-aggregate.d.ts +62 -0
- package/src/lib/server/csp-aggregate.js +276 -0
- package/src/lib/server/csp-aggregate.js.map +1 -0
- package/src/lib/server/data-storage-gate.d.ts +66 -0
- package/src/lib/server/data-storage-gate.js +48 -0
- package/src/lib/server/data-storage-gate.js.map +1 -0
- package/src/lib/server/dataset-live-pages.d.ts +179 -0
- package/src/lib/server/dataset-live-pages.js +421 -0
- package/src/lib/server/dataset-live-pages.js.map +1 -0
- package/src/lib/server/dns-probe.d.ts +53 -0
- package/src/lib/server/dns-probe.js +184 -0
- package/src/lib/server/dns-probe.js.map +1 -0
- package/src/lib/server/document-id.d.ts +29 -0
- package/src/lib/server/document-id.js +100 -0
- package/src/lib/server/document-id.js.map +1 -0
- package/src/lib/server/domain-provider-vercel.d.ts +33 -0
- package/src/lib/server/domain-provider-vercel.js +298 -0
- package/src/lib/server/domain-provider-vercel.js.map +1 -0
- package/src/lib/server/domain-provider-webhook.d.ts +73 -0
- package/src/lib/server/domain-provider-webhook.js +223 -0
- package/src/lib/server/domain-provider-webhook.js.map +1 -0
- package/src/lib/server/domain-provider-wildcard.d.ts +54 -0
- package/src/lib/server/domain-provider-wildcard.js +163 -0
- package/src/lib/server/domain-provider-wildcard.js.map +1 -0
- package/src/lib/server/domain-provider.d.ts +197 -0
- package/src/lib/server/domain-provider.js +221 -0
- package/src/lib/server/domain-provider.js.map +1 -0
- package/src/lib/server/duplicate-activity.d.ts +54 -0
- package/src/lib/server/duplicate-activity.js +67 -0
- package/src/lib/server/duplicate-activity.js.map +1 -0
- package/src/lib/server/duplicate-resource.d.ts +55 -0
- package/src/lib/server/duplicate-resource.js +474 -0
- package/src/lib/server/duplicate-resource.js.map +1 -0
- package/src/lib/server/dynamic-list-materialize.d.ts +153 -0
- package/src/lib/server/dynamic-list-materialize.js +581 -0
- package/src/lib/server/dynamic-list-materialize.js.map +1 -0
- package/src/lib/server/edit-access-authz.d.ts +56 -0
- package/src/lib/server/edit-access-authz.js +107 -0
- package/src/lib/server/edit-access-authz.js.map +1 -0
- package/src/lib/server/edit-access-token.d.ts +72 -0
- package/src/lib/server/edit-access-token.js +116 -0
- package/src/lib/server/edit-access-token.js.map +1 -0
- package/src/lib/server/edit-hint-token.d.ts +83 -0
- package/src/lib/server/edit-hint-token.js +119 -0
- package/src/lib/server/edit-hint-token.js.map +1 -0
- package/src/lib/server/email-delivery-log.d.ts +509 -0
- package/src/lib/server/email-delivery-log.js +1043 -0
- package/src/lib/server/email-delivery-log.js.map +1 -0
- package/src/lib/server/email-flow-gate.d.ts +80 -0
- package/src/lib/server/email-flow-gate.js +153 -0
- package/src/lib/server/email-flow-gate.js.map +1 -0
- package/src/lib/server/email-marketing-gate.d.ts +333 -0
- package/src/lib/server/email-marketing-gate.js +639 -0
- package/src/lib/server/email-marketing-gate.js.map +1 -0
- package/src/lib/server/email-metering.d.ts +293 -0
- package/src/lib/server/email-metering.js +359 -0
- package/src/lib/server/email-metering.js.map +1 -0
- package/src/lib/server/email-revenue-attribution.d.ts +185 -0
- package/src/lib/server/email-revenue-attribution.js +300 -0
- package/src/lib/server/email-revenue-attribution.js.map +1 -0
- package/src/lib/server/email-send-rate.d.ts +232 -0
- package/src/lib/server/email-send-rate.js +417 -0
- package/src/lib/server/email-send-rate.js.map +1 -0
- package/src/lib/server/email-sender-reputation.d.ts +317 -0
- package/src/lib/server/email-sender-reputation.js +464 -0
- package/src/lib/server/email-sender-reputation.js.map +1 -0
- package/src/lib/server/email-suppression.d.ts +490 -0
- package/src/lib/server/email-suppression.js +648 -0
- package/src/lib/server/email-suppression.js.map +1 -0
- package/src/lib/server/email-topic-confirmation.d.ts +150 -0
- package/src/lib/server/email-topic-confirmation.js +272 -0
- package/src/lib/server/email-topic-confirmation.js.map +1 -0
- package/src/lib/server/email-unsubscribe-link.d.ts +174 -0
- package/src/lib/server/email-unsubscribe-link.js +236 -0
- package/src/lib/server/email-unsubscribe-link.js.map +1 -0
- package/src/lib/server/erase-person.d.ts +116 -0
- package/src/lib/server/erase-person.js +272 -0
- package/src/lib/server/erase-person.js.map +1 -0
- package/src/lib/server/erase.d.ts +458 -0
- package/src/lib/server/erase.js +1506 -0
- package/src/lib/server/erase.js.map +1 -0
- package/src/lib/server/firebase-admin.d.ts +71 -0
- package/src/lib/server/firebase-admin.js +343 -0
- package/src/lib/server/firebase-admin.js.map +1 -0
- package/src/lib/server/form-dataset-binding-token.d.ts +35 -0
- package/src/lib/server/form-dataset-binding-token.js +107 -0
- package/src/lib/server/form-dataset-binding-token.js.map +1 -0
- package/src/lib/server/free-workspace-cap.d.ts +201 -0
- package/src/lib/server/free-workspace-cap.js +404 -0
- package/src/lib/server/free-workspace-cap.js.map +1 -0
- package/src/lib/server/ga4-measurement-protocol.d.ts +256 -0
- package/src/lib/server/ga4-measurement-protocol.js +558 -0
- package/src/lib/server/ga4-measurement-protocol.js.map +1 -0
- package/src/lib/server/host-email-tokens.d.ts +47 -0
- package/src/lib/server/host-email-tokens.js +72 -0
- package/src/lib/server/host-email-tokens.js.map +1 -0
- package/src/lib/server/host-memberships.d.ts +54 -0
- package/src/lib/server/host-memberships.js +194 -0
- package/src/lib/server/host-memberships.js.map +1 -0
- package/src/lib/server/host-sending-domain.d.ts +292 -0
- package/src/lib/server/host-sending-domain.js +451 -0
- package/src/lib/server/host-sending-domain.js.map +1 -0
- package/src/lib/server/host-visitor-records.d.ts +150 -0
- package/src/lib/server/host-visitor-records.js +262 -0
- package/src/lib/server/host-visitor-records.js.map +1 -0
- package/src/lib/server/id-token-refusal.d.ts +28 -0
- package/src/lib/server/id-token-refusal.js +111 -0
- package/src/lib/server/id-token-refusal.js.map +1 -0
- package/src/lib/server/legal-acceptance.d.ts +214 -0
- package/src/lib/server/legal-acceptance.js +305 -0
- package/src/lib/server/legal-acceptance.js.map +1 -0
- package/src/lib/server/list-members.d.ts +211 -0
- package/src/lib/server/list-members.js +176 -0
- package/src/lib/server/list-members.js.map +1 -0
- package/src/lib/server/live-page-usage.d.ts +202 -0
- package/src/lib/server/live-page-usage.js +300 -0
- package/src/lib/server/live-page-usage.js.map +1 -0
- package/src/lib/server/lockdown.d.ts +313 -0
- package/src/lib/server/lockdown.js +643 -0
- package/src/lib/server/lockdown.js.map +1 -0
- package/src/lib/server/media-cdn-rate-limit.d.ts +188 -0
- package/src/lib/server/media-cdn-rate-limit.js +204 -0
- package/src/lib/server/media-cdn-rate-limit.js.map +1 -0
- package/src/lib/server/media-delivery.d.ts +267 -0
- package/src/lib/server/media-delivery.js +485 -0
- package/src/lib/server/media-delivery.js.map +1 -0
- package/src/lib/server/media-download-tokens.d.ts +161 -0
- package/src/lib/server/media-download-tokens.js +386 -0
- package/src/lib/server/media-download-tokens.js.map +1 -0
- package/src/lib/server/media-quarantine.d.ts +127 -0
- package/src/lib/server/media-quarantine.js +170 -0
- package/src/lib/server/media-quarantine.js.map +1 -0
- package/src/lib/server/media-signing.d.ts +126 -0
- package/src/lib/server/media-signing.js +159 -0
- package/src/lib/server/media-signing.js.map +1 -0
- package/src/lib/server/media-storage-path.d.ts +96 -0
- package/src/lib/server/media-storage-path.js +111 -0
- package/src/lib/server/media-storage-path.js.map +1 -0
- package/src/lib/server/media-strong-digest.d.ts +85 -0
- package/src/lib/server/media-strong-digest.js +190 -0
- package/src/lib/server/media-strong-digest.js.map +1 -0
- package/src/lib/server/media-tombstone.d.ts +276 -0
- package/src/lib/server/media-tombstone.js +421 -0
- package/src/lib/server/media-tombstone.js.map +1 -0
- package/src/lib/server/media-variants.d.ts +309 -0
- package/src/lib/server/media-variants.js +428 -0
- package/src/lib/server/media-variants.js.map +1 -0
- package/src/lib/server/member-email-aliases.d.ts +180 -0
- package/src/lib/server/member-email-aliases.js +420 -0
- package/src/lib/server/member-email-aliases.js.map +1 -0
- package/src/lib/server/member-photo.d.ts +88 -0
- package/src/lib/server/member-photo.js +185 -0
- package/src/lib/server/member-photo.js.map +1 -0
- package/src/lib/server/membership-recover-throttle.d.ts +211 -0
- package/src/lib/server/membership-recover-throttle.js +231 -0
- package/src/lib/server/membership-recover-throttle.js.map +1 -0
- package/src/lib/server/notifications.d.ts +55 -0
- package/src/lib/server/notifications.js +134 -0
- package/src/lib/server/notifications.js.map +1 -0
- package/src/lib/server/org-billing.d.ts +97 -0
- package/src/lib/server/org-billing.js +236 -0
- package/src/lib/server/org-billing.js.map +1 -0
- package/src/lib/server/organizations.d.ts +837 -0
- package/src/lib/server/organizations.js +1814 -0
- package/src/lib/server/organizations.js.map +1 -0
- package/src/lib/server/password-reset-throttle.d.ts +84 -0
- package/src/lib/server/password-reset-throttle.js +102 -0
- package/src/lib/server/password-reset-throttle.js.map +1 -0
- package/src/lib/server/personal-data-export.d.ts +241 -0
- package/src/lib/server/personal-data-export.js +858 -0
- package/src/lib/server/personal-data-export.js.map +1 -0
- package/src/lib/server/platform-domain-names.d.ts +62 -0
- package/src/lib/server/platform-domain-names.js +197 -0
- package/src/lib/server/platform-domain-names.js.map +1 -0
- package/src/lib/server/platform-marketing-consent.d.ts +133 -0
- package/src/lib/server/platform-marketing-consent.js +209 -0
- package/src/lib/server/platform-marketing-consent.js.map +1 -0
- package/src/lib/server/rate-limit-store.d.ts +702 -0
- package/src/lib/server/rate-limit-store.js +1220 -0
- package/src/lib/server/rate-limit-store.js.map +1 -0
- package/src/lib/server/realm-plugins.d.ts +67 -0
- package/src/lib/server/realm-plugins.js +250 -0
- package/src/lib/server/realm-plugins.js.map +1 -0
- package/src/lib/server/recaptcha-allowlist.d.ts +90 -0
- package/src/lib/server/recaptcha-allowlist.js +409 -0
- package/src/lib/server/recaptcha-allowlist.js.map +1 -0
- package/src/lib/server/release-flags.d.ts +105 -0
- package/src/lib/server/release-flags.js +183 -0
- package/src/lib/server/release-flags.js.map +1 -0
- package/src/lib/server/replay-erasures.d.ts +126 -0
- package/src/lib/server/replay-erasures.js +217 -0
- package/src/lib/server/replay-erasures.js.map +1 -0
- package/src/lib/server/resolve-people.d.ts +66 -0
- package/src/lib/server/resolve-people.js +112 -0
- package/src/lib/server/resolve-people.js.map +1 -0
- package/src/lib/server/safe-equal.d.ts +45 -0
- package/src/lib/server/safe-equal.js +58 -0
- package/src/lib/server/safe-equal.js.map +1 -0
- package/src/lib/server/sending-domain-debt.d.ts +127 -0
- package/src/lib/server/sending-domain-debt.js +268 -0
- package/src/lib/server/sending-domain-debt.js.map +1 -0
- package/src/lib/server/sending-domain-recheck.d.ts +151 -0
- package/src/lib/server/sending-domain-recheck.js +253 -0
- package/src/lib/server/sending-domain-recheck.js.map +1 -0
- package/src/lib/server/sending-domains.d.ts +364 -0
- package/src/lib/server/sending-domains.js +683 -0
- package/src/lib/server/sending-domains.js.map +1 -0
- package/src/lib/server/serve-media-cdn.d.ts +433 -0
- package/src/lib/server/serve-media-cdn.js +1370 -0
- package/src/lib/server/serve-media-cdn.js.map +1 -0
- package/src/lib/server/serve-plugin-fetch.d.ts +61 -0
- package/src/lib/server/serve-plugin-fetch.js +294 -0
- package/src/lib/server/serve-plugin-fetch.js.map +1 -0
- package/src/lib/server/sms-keywords.d.ts +63 -0
- package/src/lib/server/sms-keywords.js +146 -0
- package/src/lib/server/sms-keywords.js.map +1 -0
- package/src/lib/server/sso-break-glass-owners.d.ts +91 -0
- package/src/lib/server/sso-break-glass-owners.js +227 -0
- package/src/lib/server/sso-break-glass-owners.js.map +1 -0
- package/src/lib/server/sso-domain-policy.d.ts +140 -0
- package/src/lib/server/sso-domain-policy.js +239 -0
- package/src/lib/server/sso-domain-policy.js.map +1 -0
- package/src/lib/server/sso-drift-logic.d.ts +106 -0
- package/src/lib/server/sso-drift-logic.js +95 -0
- package/src/lib/server/sso-drift-logic.js.map +1 -0
- package/src/lib/server/sso-enforcement.d.ts +204 -0
- package/src/lib/server/sso-enforcement.js +309 -0
- package/src/lib/server/sso-enforcement.js.map +1 -0
- package/src/lib/server/sso-provisioning.d.ts +320 -0
- package/src/lib/server/sso-provisioning.js +715 -0
- package/src/lib/server/sso-provisioning.js.map +1 -0
- package/src/lib/server/staff-alert-email.d.ts +41 -0
- package/src/lib/server/staff-alert-email.js +63 -0
- package/src/lib/server/staff-alert-email.js.map +1 -0
- package/src/lib/server/stripe-account-mode.d.ts +156 -0
- package/src/lib/server/stripe-account-mode.js +162 -0
- package/src/lib/server/stripe-account-mode.js.map +1 -0
- package/src/lib/server/tenant-write-lockdown.d.ts +131 -0
- package/src/lib/server/tenant-write-lockdown.js +176 -0
- package/src/lib/server/tenant-write-lockdown.js.map +1 -0
- package/src/lib/server/test-firestore.d.ts +45 -0
- package/src/lib/server/test-firestore.js +124 -0
- package/src/lib/server/test-firestore.js.map +1 -0
- package/src/lib/server/token-revocation.d.ts +140 -0
- package/src/lib/server/token-revocation.js +255 -0
- package/src/lib/server/token-revocation.js.map +1 -0
- package/src/lib/server/update-existing.d.ts +32 -0
- package/src/lib/server/update-existing.js +73 -0
- package/src/lib/server/update-existing.js.map +1 -0
- package/src/lib/server/upload-cors-reconcile.d.ts +118 -0
- package/src/lib/server/upload-cors-reconcile.js +234 -0
- package/src/lib/server/upload-cors-reconcile.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +262 -0
- package/src/lib/server/upsert-contact.js +546 -0
- package/src/lib/server/upsert-contact.js.map +1 -0
- package/src/lib/server/usage-invoice.d.ts +203 -0
- package/src/lib/server/usage-invoice.js +397 -0
- package/src/lib/server/usage-invoice.js.map +1 -0
- package/src/lib/server/user-profiles.d.ts +151 -0
- package/src/lib/server/user-profiles.js +364 -0
- package/src/lib/server/user-profiles.js.map +1 -0
- package/src/lib/server/vercel-drain-signature.d.ts +30 -0
- package/src/lib/server/vercel-drain-signature.js +61 -0
- package/src/lib/server/vercel-drain-signature.js.map +1 -0
- package/src/lib/server/vercel-log-drain.d.ts +259 -0
- package/src/lib/server/vercel-log-drain.js +479 -0
- package/src/lib/server/vercel-log-drain.js.map +1 -0
- package/src/lib/server/verify-email-cooldown.d.ts +102 -0
- package/src/lib/server/verify-email-cooldown.js +114 -0
- package/src/lib/server/verify-email-cooldown.js.map +1 -0
- package/src/lib/server/visitor-write-rate-limit.d.ts +87 -0
- package/src/lib/server/visitor-write-rate-limit.js +117 -0
- package/src/lib/server/visitor-write-rate-limit.js.map +1 -0
- package/src/lib/server/workspace-domains.d.ts +156 -0
- package/src/lib/server/workspace-domains.js +271 -0
- package/src/lib/server/workspace-domains.js.map +1 -0
- package/src/tenancy.d.ts +115 -0
- package/src/tenancy.js +142 -0
- package/src/tenancy.js.map +1 -0
|
@@ -0,0 +1,359 @@
|
|
|
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 { firebaseAdmin } from "./firebase-admin.js";
|
|
17
|
+
/**
|
|
18
|
+
* The email meters (AGL-1438). **Two of them, deliberately.**
|
|
19
|
+
*
|
|
20
|
+
* Before this, `hosts/{hostId}/counters/emailSends` was written by exactly one
|
|
21
|
+
* caller — the marketing campaign sender. Workflows, commerce, bookings and
|
|
22
|
+
* invites all called `sendEmail` and counted nothing, so the counter that named
|
|
23
|
+
* itself "email sends" held campaign sends alone. Two things followed from
|
|
24
|
+
* that: `emailSendsPerMonth` under-enforced, and the COGS figure AGL-1134
|
|
25
|
+
* started recording off this counter under-reported real email cost by however
|
|
26
|
+
* much non-campaign volume an org sent.
|
|
27
|
+
*
|
|
28
|
+
* UNITS: plain COUNTS — one per recipient address handed to the sender —
|
|
29
|
+
* for the calendar month named by the `YYYY-MM` FIELD on the counter document.
|
|
30
|
+
* Not bytes, not currency, not a running total: each month's field is
|
|
31
|
+
* independent, so summing months is a legitimate year-to-date and reading one
|
|
32
|
+
* is that month alone. Same unit as `workflowRuns`/`actionRuns` beside it, and
|
|
33
|
+
* the same unit `orgCounterTotals` sums, so a campaign of 300 and 300 workflow
|
|
34
|
+
* notifications are the same 300 on the meter.
|
|
35
|
+
*
|
|
36
|
+
* ### `emailSends` — the COST meter. Counts everything. Gates nothing.
|
|
37
|
+
*
|
|
38
|
+
* Every `sendEmail` call site increments this, whatever sent it. It is what the
|
|
39
|
+
* monthly rollup carries for COGS, and it is RECORDED, NOT PRICED — there is
|
|
40
|
+
* still no per-email rate anywhere, so it enters neither `billedCents` nor
|
|
41
|
+
* `costUsd` nor `ORG_COGS_UNIT_RATES_USD`. Pricing it is a decision with an
|
|
42
|
+
* invoice month behind it.
|
|
43
|
+
*
|
|
44
|
+
* ### `campaignEmailSends` — the ENFORCEABLE meter. Counts campaigns only.
|
|
45
|
+
*
|
|
46
|
+
* `emailSendsPerMonth` is checked against this and nothing else. A campaign is
|
|
47
|
+
* discretionary: refusing one at the cap is the outcome the plan sells, the
|
|
48
|
+
* customer sees a clear message, and they upgrade or wait.
|
|
49
|
+
*
|
|
50
|
+
* **Transactional mail is never blocked by a quota, at any tier.** Password
|
|
51
|
+
* resets, invites, order confirmations, booking reminders and workflow
|
|
52
|
+
* notifications send regardless of counter state; they count toward cost and
|
|
53
|
+
* cannot be refused. The failure modes are not symmetric — a blocked password
|
|
54
|
+
* reset locks somebody out of their own account, and the message explaining
|
|
55
|
+
* why is itself an email that will not send; a dropped order confirmation
|
|
56
|
+
* reads to the buyer as a failed order. That converts a billing event into an
|
|
57
|
+
* outage on somebody else's business, where the overage it saved was bounded
|
|
58
|
+
* and billable. It is the same fail-open posture the rest of this codebase
|
|
59
|
+
* takes: rate limiting fails soft, robots/sitemap fail open so a transient
|
|
60
|
+
* error cannot de-index a customer's site.
|
|
61
|
+
*
|
|
62
|
+
* ### Not double-counted
|
|
63
|
+
*
|
|
64
|
+
* One call site, one call to this function, and a campaign send increments BOTH
|
|
65
|
+
* counters from that single call rather than incrementing `emailSends` here and
|
|
66
|
+
* again somewhere else. The counter is keyed by month ON THE DOCUMENT, so
|
|
67
|
+
* nothing accumulates across a re-read, and the rollup re-derives rather than
|
|
68
|
+
* adds. A meter that double-counts is worse than no meter, because it looks
|
|
69
|
+
* authoritative (AGL-1402).
|
|
70
|
+
*
|
|
71
|
+
* ### Transition note
|
|
72
|
+
*
|
|
73
|
+
* `campaignEmailSends` starts empty. For the month this ships, campaign volume
|
|
74
|
+
* already sent is recorded only under the old `emailSends`, so the campaign cap
|
|
75
|
+
* effectively restarts once. That loosens for at most one month, which is the
|
|
76
|
+
* correct direction to be wrong in — the alternative, seeding the enforceable
|
|
77
|
+
* meter from a counter that now also holds transactional mail, would refuse
|
|
78
|
+
* campaigns because of order confirmations.
|
|
79
|
+
*/ /** Every send, whatever produced it. The cost meter. */ export const EMAIL_SENDS_COUNTER = 'emailSends';
|
|
80
|
+
/** Campaign sends alone. The only meter `emailSendsPerMonth` may refuse. */ export const CAMPAIGN_EMAIL_SENDS_COUNTER = 'campaignEmailSends';
|
|
81
|
+
/** Top-level home for platform-scoped meters: `meters/platform/counters/*`. */ export const PLATFORM_METER_COLLECTION = 'meters';
|
|
82
|
+
export const PLATFORM_METER_DOC = 'platform';
|
|
83
|
+
/** The counters document for a scope, e.g. `hosts/{id}/counters/emailSends`. */ export function emailMeterRef(firestore, scope, counter) {
|
|
84
|
+
if (scope.kind === 'host') {
|
|
85
|
+
return firestore.collection('hosts').doc(scope.hostId).collection('counters').doc(counter);
|
|
86
|
+
}
|
|
87
|
+
if (scope.kind === 'org') {
|
|
88
|
+
return firestore.collection('orgs').doc(scope.orgId).collection('counters').doc(counter);
|
|
89
|
+
}
|
|
90
|
+
return firestore.collection(PLATFORM_METER_COLLECTION).doc(PLATFORM_METER_DOC).collection('counters').doc(counter);
|
|
91
|
+
}
|
|
92
|
+
/** Current calendar month as `YYYY-MM`, matching every other counter here. */ export function currentMeterMonth(now = new Date()) {
|
|
93
|
+
return now.toISOString().slice(0, 7);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Records `count` emails against the cost meter, and against the campaign
|
|
97
|
+
* meter too when the send was a campaign.
|
|
98
|
+
*
|
|
99
|
+
* **Never throws.** Metering is bookkeeping that runs after mail has already
|
|
100
|
+
* gone out — a counter write that fails must not turn a delivered receipt into
|
|
101
|
+
* a 500 for the buyer, and must certainly not be able to stop the next send.
|
|
102
|
+
* Failures are logged and swallowed, the same posture `sendEmail` itself takes.
|
|
103
|
+
*/ export async function recordEmailSends(options) {
|
|
104
|
+
const { scope, sendClass } = options;
|
|
105
|
+
const count = Math.floor(Number(options.count));
|
|
106
|
+
// A send that did not happen is not a cost. `sendEmail` reports `sent` per
|
|
107
|
+
// message, and callers pass the delivered count, so 0 is the ordinary
|
|
108
|
+
// outcome of an unconfigured environment rather than an error.
|
|
109
|
+
if (!Number.isFinite(count) || count <= 0) return;
|
|
110
|
+
if (scope.kind === 'host' && !scope.hostId) return;
|
|
111
|
+
if (scope.kind === 'org' && !scope.orgId) return;
|
|
112
|
+
const month = options.month || currentMeterMonth();
|
|
113
|
+
try {
|
|
114
|
+
var _options_firestore;
|
|
115
|
+
const firestore = (_options_firestore = options.firestore) != null ? _options_firestore : firebaseAdmin.app().firestore();
|
|
116
|
+
const increment = firebaseAdmin.firestore.FieldValue.increment(count);
|
|
117
|
+
const counters = sendClass === 'campaign' ? [
|
|
118
|
+
EMAIL_SENDS_COUNTER,
|
|
119
|
+
CAMPAIGN_EMAIL_SENDS_COUNTER
|
|
120
|
+
] : [
|
|
121
|
+
EMAIL_SENDS_COUNTER
|
|
122
|
+
];
|
|
123
|
+
await Promise.all(counters.map((counter)=>emailMeterRef(firestore, scope, counter).set({
|
|
124
|
+
[month]: increment
|
|
125
|
+
}, {
|
|
126
|
+
merge: true
|
|
127
|
+
})));
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.error('email meter write failed', error);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* One transactional (or, with `sendClass`, campaign) send attributed to a site.
|
|
134
|
+
*
|
|
135
|
+
* The overwhelmingly common call — `await meterHostEmail(hostId)` right after
|
|
136
|
+
* a `sendEmail` — so it reads as one line at every call site and there is one
|
|
137
|
+
* place to change if the counter ever moves.
|
|
138
|
+
*/ export function meterHostEmail(hostId, count = 1, sendClass = 'transactional') {
|
|
139
|
+
return recordEmailSends({
|
|
140
|
+
scope: {
|
|
141
|
+
kind: 'host',
|
|
142
|
+
hostId
|
|
143
|
+
},
|
|
144
|
+
count,
|
|
145
|
+
sendClass
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/** Org-scoped transactional mail: invites, welcome, usage summaries. */ export function meterOrgEmail(orgId, count = 1) {
|
|
149
|
+
return recordEmailSends({
|
|
150
|
+
scope: {
|
|
151
|
+
kind: 'org',
|
|
152
|
+
orgId
|
|
153
|
+
},
|
|
154
|
+
count,
|
|
155
|
+
sendClass: 'transactional'
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Account and staff mail that belongs to no org at send time — a password
|
|
160
|
+
* reset knows only an address, and a staff alert has no customer at all.
|
|
161
|
+
*/ export function meterPlatformEmail(count = 1) {
|
|
162
|
+
return recordEmailSends({
|
|
163
|
+
scope: {
|
|
164
|
+
kind: 'platform'
|
|
165
|
+
},
|
|
166
|
+
count,
|
|
167
|
+
sendClass: 'transactional'
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The enforceable figure for a site this month: campaign sends alone.
|
|
172
|
+
*
|
|
173
|
+
* Reads the campaign meter and NOT `emailSends`, which now also holds every
|
|
174
|
+
* receipt and password reset the site sent. Enforcing the plan's cap against
|
|
175
|
+
* that total is exactly the behaviour this issue exists to prevent.
|
|
176
|
+
*/ export async function campaignEmailSendsForMonth(hostRef, month) {
|
|
177
|
+
var _snapshot_get;
|
|
178
|
+
const snapshot = await hostRef.collection('counters').doc(CAMPAIGN_EMAIL_SENDS_COUNTER).get();
|
|
179
|
+
const used = Number((_snapshot_get = snapshot.get(month)) != null ? _snapshot_get : 0);
|
|
180
|
+
// An absent counter is 0, and a corrupt negative must not read as unused
|
|
181
|
+
// headroom that a cap then honours.
|
|
182
|
+
return Number.isFinite(used) && used > 0 ? used : 0;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The org-level campaign meter, and the ONLY figure `emailSendsPerMonth` is
|
|
186
|
+
* enforced against since AGL-2267.
|
|
187
|
+
*
|
|
188
|
+
* ## Why a second counter and not the per-host one
|
|
189
|
+
*
|
|
190
|
+
* `hosts/{hostId}/counters/campaignEmailSends` is per SITE. `emailSendsPerMonth`
|
|
191
|
+
* is an ORG entitlement. An org with N sites therefore received N × the cap it
|
|
192
|
+
* bought — invisible on Free and Starter (`hostLimit: 1`) and growing with the
|
|
193
|
+
* plan, so the customers who paid most got the most cap they had not paid for.
|
|
194
|
+
*
|
|
195
|
+
* The per-host counter is UNCHANGED and still written, by `meterHostEmail`, on
|
|
196
|
+
* the delivered count. It is per-site history and it feeds the cost meter;
|
|
197
|
+
* nothing about it was wrong except being asked a question about an org.
|
|
198
|
+
*
|
|
199
|
+
* ## Why folding the existing per-site counters in was rejected
|
|
200
|
+
*
|
|
201
|
+
* Summing N site counters into one org total retroactively puts multi-site
|
|
202
|
+
* paying customers over a limit they have been under all along — a limit that
|
|
203
|
+
* then refuses a campaign they had every reason to expect to send, days after
|
|
204
|
+
* they bought the plan. This counter therefore STARTS EMPTY, and the campaign
|
|
205
|
+
* cap effectively restarts once for the month this ships.
|
|
206
|
+
*
|
|
207
|
+
* That is the same transition `campaignEmailSends` itself was given, and the
|
|
208
|
+
* same reasoning: it loosens for at most one month, which is the correct
|
|
209
|
+
* direction to be wrong in, and it needs no production measurement of who is
|
|
210
|
+
* currently over — a measurement that could only be taken against live
|
|
211
|
+
* customer data and would have blocked the fix indefinitely.
|
|
212
|
+
*/ export const ORG_CAMPAIGN_EMAIL_SENDS_COUNTER = CAMPAIGN_EMAIL_SENDS_COUNTER;
|
|
213
|
+
/** Reads an org-scoped monthly counter field, clamped like every other. */ function readMonthField(snapshot, month) {
|
|
214
|
+
var _ref;
|
|
215
|
+
const used = Number((_ref = (snapshot == null ? void 0 : snapshot.exists) ? snapshot.get(month) : 0) != null ? _ref : 0);
|
|
216
|
+
return Number.isFinite(used) && used > 0 ? Math.floor(used) : 0;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The enforceable figure for an ORG this month: campaign sends alone.
|
|
220
|
+
*
|
|
221
|
+
* Read-only. Used by the campaign composer's recipient preview, which must
|
|
222
|
+
* not reserve anything (AGL-2178 — "nothing has been written above this
|
|
223
|
+
* line"), and by anything that reports headroom.
|
|
224
|
+
*/ export async function orgCampaignEmailSendsForMonth(orgId, month, firestore) {
|
|
225
|
+
if (!orgId) return 0;
|
|
226
|
+
const db = firestore != null ? firestore : firebaseAdmin.app().firestore();
|
|
227
|
+
const snapshot = await db.collection('orgs').doc(orgId).collection('counters').doc(ORG_CAMPAIGN_EMAIL_SENDS_COUNTER).get();
|
|
228
|
+
return readMonthField(snapshot, month);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Claims `count` campaign sends against the org's monthly allowance, ATOMICALLY.
|
|
232
|
+
*
|
|
233
|
+
* ## Why a reservation and not a check
|
|
234
|
+
*
|
|
235
|
+
* The cap used to be read before the send and incremented after delivery, so
|
|
236
|
+
* two concurrent campaigns both passed the same reading and both sent — the
|
|
237
|
+
* cap was advisory in exactly the conditions it existed for. **A read-then-write
|
|
238
|
+
* cap is not a cap.**
|
|
239
|
+
*
|
|
240
|
+
* The transaction reads the counter and writes an ABSOLUTE value derived from
|
|
241
|
+
* that read, deliberately not `FieldValue.increment`. Firestore aborts and
|
|
242
|
+
* re-runs the callback when a document the transaction read has moved, so a
|
|
243
|
+
* second sender that starts inside the first one's window re-reads the raised
|
|
244
|
+
* figure and is refused. An increment would be atomic on the number and
|
|
245
|
+
* useless for the decision, because the decision is made from a value the
|
|
246
|
+
* write never proves it still held.
|
|
247
|
+
*
|
|
248
|
+
* A refused reservation writes NOTHING.
|
|
249
|
+
*
|
|
250
|
+
* ## Why reserve-then-reconcile rather than reserve-and-keep
|
|
251
|
+
*
|
|
252
|
+
* A campaign reserves against a PARTIAL delivery: `sendEmail` reports per
|
|
253
|
+
* message, and only some of a 500-address batch may go out. Keeping the whole
|
|
254
|
+
* reservation would charge a customer's allowance for mail that never left.
|
|
255
|
+
* So the claim is taken in full up front — that is what makes it a cap — and
|
|
256
|
+
* {@link reconcileCampaignSendReservation} gives back the difference once the
|
|
257
|
+
* delivered count is known.
|
|
258
|
+
*
|
|
259
|
+
* **The failure mode is stated rather than hidden**: if the process dies
|
|
260
|
+
* between reserving and reconciling, the org is charged for the undelivered
|
|
261
|
+
* remainder for the rest of that calendar month. That is conservative in the
|
|
262
|
+
* direction this issue cares about — it can only ever refuse more mail, never
|
|
263
|
+
* let more out — and it self-heals at the month boundary, because each month
|
|
264
|
+
* is an independent field on the document.
|
|
265
|
+
*
|
|
266
|
+
* `limit` may be `Infinity` (`UNLIMITED`), which admits everything.
|
|
267
|
+
*/ export async function reserveCampaignEmailSends(options) {
|
|
268
|
+
var _options_firestore;
|
|
269
|
+
const count = Math.max(0, Math.floor(Number(options.count) || 0));
|
|
270
|
+
const limit = Number(options.limit);
|
|
271
|
+
// A plan-less or unresolvable org must not be a bypass. The caller resolves
|
|
272
|
+
// free-tier entitlements for it (AGL-247) and the cap is 0, so an empty
|
|
273
|
+
// orgId that reached here means the counter has nowhere to live — refuse
|
|
274
|
+
// rather than send unbounded.
|
|
275
|
+
if (!options.orgId) {
|
|
276
|
+
return {
|
|
277
|
+
ok: false,
|
|
278
|
+
used: 0,
|
|
279
|
+
limit: Number.isFinite(limit) ? limit : 0
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const db = (_options_firestore = options.firestore) != null ? _options_firestore : firebaseAdmin.app().firestore();
|
|
283
|
+
const ref = db.collection('orgs').doc(options.orgId).collection('counters').doc(ORG_CAMPAIGN_EMAIL_SENDS_COUNTER);
|
|
284
|
+
return db.runTransaction(async (tx)=>{
|
|
285
|
+
const snapshot = await tx.get(ref);
|
|
286
|
+
const used = readMonthField(snapshot, options.month);
|
|
287
|
+
if (used + count > limit) return {
|
|
288
|
+
ok: false,
|
|
289
|
+
used,
|
|
290
|
+
limit
|
|
291
|
+
};
|
|
292
|
+
tx.set(ref, {
|
|
293
|
+
[options.month]: used + count
|
|
294
|
+
}, {
|
|
295
|
+
merge: true
|
|
296
|
+
});
|
|
297
|
+
return {
|
|
298
|
+
ok: true,
|
|
299
|
+
reservation: {
|
|
300
|
+
orgId: options.orgId,
|
|
301
|
+
month: options.month,
|
|
302
|
+
reserved: count
|
|
303
|
+
},
|
|
304
|
+
used,
|
|
305
|
+
limit
|
|
306
|
+
};
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Returns the undelivered part of a reservation.
|
|
311
|
+
*
|
|
312
|
+
* Also a transaction, and also an absolute write from its own read: a refund
|
|
313
|
+
* computed from a stale figure would undo a reservation another campaign took
|
|
314
|
+
* in the meantime, which is the same defect one direction over.
|
|
315
|
+
*
|
|
316
|
+
* **Never throws** and never drives the counter below zero. This runs after
|
|
317
|
+
* mail has already gone out; a bookkeeping failure must not turn a delivered
|
|
318
|
+
* campaign into a 500, exactly like `recordEmailSends` above. The cost of
|
|
319
|
+
* swallowing it is that the org keeps a claim it did not use for the rest of
|
|
320
|
+
* the month, which is the safe direction.
|
|
321
|
+
*/ export async function reconcileCampaignSendReservation(reservation, delivered, firestore) {
|
|
322
|
+
if (!(reservation == null ? void 0 : reservation.orgId)) return;
|
|
323
|
+
const sent = Math.max(0, Math.floor(Number(delivered) || 0));
|
|
324
|
+
const refund = Math.max(0, reservation.reserved - sent);
|
|
325
|
+
if (refund <= 0) return;
|
|
326
|
+
try {
|
|
327
|
+
const db = firestore != null ? firestore : firebaseAdmin.app().firestore();
|
|
328
|
+
const ref = db.collection('orgs').doc(reservation.orgId).collection('counters').doc(ORG_CAMPAIGN_EMAIL_SENDS_COUNTER);
|
|
329
|
+
await db.runTransaction(async (tx)=>{
|
|
330
|
+
const snapshot = await tx.get(ref);
|
|
331
|
+
const used = readMonthField(snapshot, reservation.month);
|
|
332
|
+
tx.set(ref, {
|
|
333
|
+
[reservation.month]: Math.max(0, used - refund)
|
|
334
|
+
}, {
|
|
335
|
+
merge: true
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
} catch (error) {
|
|
339
|
+
console.error('campaign reservation reconcile failed', error);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Volume above the plan's included band, in emails — RE-EXPORTED, not defined
|
|
344
|
+
* here, the same move AGL-2155 made for the bandwidth helpers.
|
|
345
|
+
*
|
|
346
|
+
* The definition moved down to `@aglyn/aglyn/app-utils/plan-entitlements`
|
|
347
|
+
* because the billing page has to render the overage BEFORE it is charged,
|
|
348
|
+
* and that page is a client component which cannot import this module: the
|
|
349
|
+
* Admin SDK comes with it. A second copy of the subtraction on the client is
|
|
350
|
+
* the shape where the readout and the invoice quietly stop agreeing.
|
|
351
|
+
*
|
|
352
|
+
* Re-exported so every server caller keeps importing it from the module it
|
|
353
|
+
* always did — one definition, no drift, no import churn.
|
|
354
|
+
*
|
|
355
|
+
* @see priceEmailSendOverage — what the excess costs, at the plan's rate.
|
|
356
|
+
*/ export { emailSendsOverage } from "@aglyn/aglyn/app-utils/plan-entitlements";
|
|
357
|
+
export default recordEmailSends;
|
|
358
|
+
|
|
359
|
+
//# sourceMappingURL=email-metering.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-metering.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 { firebaseAdmin } from './firebase-admin'\n\n/**\n * The email meters (AGL-1438). **Two of them, deliberately.**\n *\n * Before this, `hosts/{hostId}/counters/emailSends` was written by exactly one\n * caller — the marketing campaign sender. Workflows, commerce, bookings and\n * invites all called `sendEmail` and counted nothing, so the counter that named\n * itself \"email sends\" held campaign sends alone. Two things followed from\n * that: `emailSendsPerMonth` under-enforced, and the COGS figure AGL-1134\n * started recording off this counter under-reported real email cost by however\n * much non-campaign volume an org sent.\n *\n * UNITS: plain COUNTS — one per recipient address handed to the sender —\n * for the calendar month named by the `YYYY-MM` FIELD on the counter document.\n * Not bytes, not currency, not a running total: each month's field is\n * independent, so summing months is a legitimate year-to-date and reading one\n * is that month alone. Same unit as `workflowRuns`/`actionRuns` beside it, and\n * the same unit `orgCounterTotals` sums, so a campaign of 300 and 300 workflow\n * notifications are the same 300 on the meter.\n *\n * ### `emailSends` — the COST meter. Counts everything. Gates nothing.\n *\n * Every `sendEmail` call site increments this, whatever sent it. It is what the\n * monthly rollup carries for COGS, and it is RECORDED, NOT PRICED — there is\n * still no per-email rate anywhere, so it enters neither `billedCents` nor\n * `costUsd` nor `ORG_COGS_UNIT_RATES_USD`. Pricing it is a decision with an\n * invoice month behind it.\n *\n * ### `campaignEmailSends` — the ENFORCEABLE meter. Counts campaigns only.\n *\n * `emailSendsPerMonth` is checked against this and nothing else. A campaign is\n * discretionary: refusing one at the cap is the outcome the plan sells, the\n * customer sees a clear message, and they upgrade or wait.\n *\n * **Transactional mail is never blocked by a quota, at any tier.** Password\n * resets, invites, order confirmations, booking reminders and workflow\n * notifications send regardless of counter state; they count toward cost and\n * cannot be refused. The failure modes are not symmetric — a blocked password\n * reset locks somebody out of their own account, and the message explaining\n * why is itself an email that will not send; a dropped order confirmation\n * reads to the buyer as a failed order. That converts a billing event into an\n * outage on somebody else's business, where the overage it saved was bounded\n * and billable. It is the same fail-open posture the rest of this codebase\n * takes: rate limiting fails soft, robots/sitemap fail open so a transient\n * error cannot de-index a customer's site.\n *\n * ### Not double-counted\n *\n * One call site, one call to this function, and a campaign send increments BOTH\n * counters from that single call rather than incrementing `emailSends` here and\n * again somewhere else. The counter is keyed by month ON THE DOCUMENT, so\n * nothing accumulates across a re-read, and the rollup re-derives rather than\n * adds. A meter that double-counts is worse than no meter, because it looks\n * authoritative (AGL-1402).\n *\n * ### Transition note\n *\n * `campaignEmailSends` starts empty. For the month this ships, campaign volume\n * already sent is recorded only under the old `emailSends`, so the campaign cap\n * effectively restarts once. That loosens for at most one month, which is the\n * correct direction to be wrong in — the alternative, seeding the enforceable\n * meter from a counter that now also holds transactional mail, would refuse\n * campaigns because of order confirmations.\n */\n\n/** Every send, whatever produced it. The cost meter. */\nexport const EMAIL_SENDS_COUNTER = 'emailSends'\n\n/** Campaign sends alone. The only meter `emailSendsPerMonth` may refuse. */\nexport const CAMPAIGN_EMAIL_SENDS_COUNTER = 'campaignEmailSends'\n\n/** Top-level home for platform-scoped meters: `meters/platform/counters/*`. */\nexport const PLATFORM_METER_COLLECTION = 'meters'\nexport const PLATFORM_METER_DOC = 'platform'\n\n/**\n * Who the send is attributed to.\n *\n * - `host` — a site sent it: campaigns, receipts, booking mail, workflow\n * notifications, member mail. Rolls up to the owning org.\n * - `org` — the org sent it with no site involved: invites, member-added,\n * the welcome mail, usage summaries, erasure notices.\n * - `platform` — Aglyn's own account and staff mail: password resets and\n * verification (which happen before any org is known), new-device and\n * passkey alerts, staff alerts, the system-email test send. Real cost, but\n * not any one customer's, so it is counted apart from every org rollup and\n * never reaches a COGS figure or an invoice.\n */\nexport type EmailMeterScope =\n | { kind: 'host'; hostId: string }\n | { kind: 'org'; orgId: string }\n | { kind: 'platform' }\n\n/**\n * Whether a quota may refuse this send.\n *\n * `campaign` is the ONLY discretionary class. Everything else is\n * `transactional`, which means: count it, never gate on it.\n */\nexport type EmailSendClass = 'campaign' | 'transactional'\n\nexport interface RecordEmailSendsOptions {\n scope: EmailMeterScope\n /** Emails actually handed to the sender. Zero and negatives are no-ops. */\n count: number\n sendClass: EmailSendClass\n /** `YYYY-MM`; defaults to the current UTC month. */\n month?: string\n /** Injectable for tests; defaults to the Admin SDK's Firestore. */\n firestore?: any\n}\n\n/** The counters document for a scope, e.g. `hosts/{id}/counters/emailSends`. */\nexport function emailMeterRef(\n firestore: any,\n scope: EmailMeterScope,\n counter: string,\n): any {\n if (scope.kind === 'host') {\n return firestore\n .collection('hosts')\n .doc(scope.hostId)\n .collection('counters')\n .doc(counter)\n }\n if (scope.kind === 'org') {\n return firestore\n .collection('orgs')\n .doc(scope.orgId)\n .collection('counters')\n .doc(counter)\n }\n return firestore\n .collection(PLATFORM_METER_COLLECTION)\n .doc(PLATFORM_METER_DOC)\n .collection('counters')\n .doc(counter)\n}\n\n/** Current calendar month as `YYYY-MM`, matching every other counter here. */\nexport function currentMeterMonth(now: Date = new Date()): string {\n return now.toISOString().slice(0, 7)\n}\n\n/**\n * Records `count` emails against the cost meter, and against the campaign\n * meter too when the send was a campaign.\n *\n * **Never throws.** Metering is bookkeeping that runs after mail has already\n * gone out — a counter write that fails must not turn a delivered receipt into\n * a 500 for the buyer, and must certainly not be able to stop the next send.\n * Failures are logged and swallowed, the same posture `sendEmail` itself takes.\n */\nexport async function recordEmailSends(\n options: RecordEmailSendsOptions,\n): Promise<void> {\n const { scope, sendClass } = options\n const count = Math.floor(Number(options.count))\n // A send that did not happen is not a cost. `sendEmail` reports `sent` per\n // message, and callers pass the delivered count, so 0 is the ordinary\n // outcome of an unconfigured environment rather than an error.\n if (!Number.isFinite(count) || count <= 0) return\n if (scope.kind === 'host' && !scope.hostId) return\n if (scope.kind === 'org' && !scope.orgId) return\n\n const month = options.month || currentMeterMonth()\n try {\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const increment = firebaseAdmin.firestore.FieldValue.increment(count)\n const counters =\n sendClass === 'campaign'\n ? [EMAIL_SENDS_COUNTER, CAMPAIGN_EMAIL_SENDS_COUNTER]\n : [EMAIL_SENDS_COUNTER]\n await Promise.all(\n counters.map((counter) =>\n emailMeterRef(firestore, scope, counter).set(\n { [month]: increment },\n { merge: true },\n ),\n ),\n )\n } catch (error) {\n console.error('email meter write failed', error)\n }\n}\n\n/**\n * One transactional (or, with `sendClass`, campaign) send attributed to a site.\n *\n * The overwhelmingly common call — `await meterHostEmail(hostId)` right after\n * a `sendEmail` — so it reads as one line at every call site and there is one\n * place to change if the counter ever moves.\n */\nexport function meterHostEmail(\n hostId: string,\n count = 1,\n sendClass: EmailSendClass = 'transactional',\n): Promise<void> {\n return recordEmailSends({\n scope: { kind: 'host', hostId },\n count,\n sendClass,\n })\n}\n\n/** Org-scoped transactional mail: invites, welcome, usage summaries. */\nexport function meterOrgEmail(orgId: string, count = 1): Promise<void> {\n return recordEmailSends({\n scope: { kind: 'org', orgId },\n count,\n sendClass: 'transactional',\n })\n}\n\n/**\n * Account and staff mail that belongs to no org at send time — a password\n * reset knows only an address, and a staff alert has no customer at all.\n */\nexport function meterPlatformEmail(count = 1): Promise<void> {\n return recordEmailSends({\n scope: { kind: 'platform' },\n count,\n sendClass: 'transactional',\n })\n}\n\n/**\n * The enforceable figure for a site this month: campaign sends alone.\n *\n * Reads the campaign meter and NOT `emailSends`, which now also holds every\n * receipt and password reset the site sent. Enforcing the plan's cap against\n * that total is exactly the behaviour this issue exists to prevent.\n */\nexport async function campaignEmailSendsForMonth(\n hostRef: any,\n month: string,\n): Promise<number> {\n const snapshot = await hostRef\n .collection('counters')\n .doc(CAMPAIGN_EMAIL_SENDS_COUNTER)\n .get()\n const used = Number(snapshot.get(month) ?? 0)\n // An absent counter is 0, and a corrupt negative must not read as unused\n // headroom that a cap then honours.\n return Number.isFinite(used) && used > 0 ? used : 0\n}\n\n/**\n * The org-level campaign meter, and the ONLY figure `emailSendsPerMonth` is\n * enforced against since AGL-2267.\n *\n * ## Why a second counter and not the per-host one\n *\n * `hosts/{hostId}/counters/campaignEmailSends` is per SITE. `emailSendsPerMonth`\n * is an ORG entitlement. An org with N sites therefore received N × the cap it\n * bought — invisible on Free and Starter (`hostLimit: 1`) and growing with the\n * plan, so the customers who paid most got the most cap they had not paid for.\n *\n * The per-host counter is UNCHANGED and still written, by `meterHostEmail`, on\n * the delivered count. It is per-site history and it feeds the cost meter;\n * nothing about it was wrong except being asked a question about an org.\n *\n * ## Why folding the existing per-site counters in was rejected\n *\n * Summing N site counters into one org total retroactively puts multi-site\n * paying customers over a limit they have been under all along — a limit that\n * then refuses a campaign they had every reason to expect to send, days after\n * they bought the plan. This counter therefore STARTS EMPTY, and the campaign\n * cap effectively restarts once for the month this ships.\n *\n * That is the same transition `campaignEmailSends` itself was given, and the\n * same reasoning: it loosens for at most one month, which is the correct\n * direction to be wrong in, and it needs no production measurement of who is\n * currently over — a measurement that could only be taken against live\n * customer data and would have blocked the fix indefinitely.\n */\nexport const ORG_CAMPAIGN_EMAIL_SENDS_COUNTER = CAMPAIGN_EMAIL_SENDS_COUNTER\n\n/** A granted claim on the org's monthly campaign allowance. */\nexport interface CampaignSendReservation {\n orgId: string\n month: string\n /** Messages claimed up front. */\n reserved: number\n}\n\nexport type ReserveCampaignSendsResult =\n | { ok: true; reservation: CampaignSendReservation; used: number; limit: number }\n | { ok: false; used: number; limit: number }\n\n/** Reads an org-scoped monthly counter field, clamped like every other. */\nfunction readMonthField(snapshot: any, month: string): number {\n const used = Number((snapshot?.exists ? snapshot.get(month) : 0) ?? 0)\n return Number.isFinite(used) && used > 0 ? Math.floor(used) : 0\n}\n\n/**\n * The enforceable figure for an ORG this month: campaign sends alone.\n *\n * Read-only. Used by the campaign composer's recipient preview, which must\n * not reserve anything (AGL-2178 — \"nothing has been written above this\n * line\"), and by anything that reports headroom.\n */\nexport async function orgCampaignEmailSendsForMonth(\n orgId: string,\n month: string,\n firestore?: any,\n): Promise<number> {\n if (!orgId) return 0\n const db = firestore ?? firebaseAdmin.app().firestore()\n const snapshot = await db\n .collection('orgs')\n .doc(orgId)\n .collection('counters')\n .doc(ORG_CAMPAIGN_EMAIL_SENDS_COUNTER)\n .get()\n return readMonthField(snapshot, month)\n}\n\n/**\n * Claims `count` campaign sends against the org's monthly allowance, ATOMICALLY.\n *\n * ## Why a reservation and not a check\n *\n * The cap used to be read before the send and incremented after delivery, so\n * two concurrent campaigns both passed the same reading and both sent — the\n * cap was advisory in exactly the conditions it existed for. **A read-then-write\n * cap is not a cap.**\n *\n * The transaction reads the counter and writes an ABSOLUTE value derived from\n * that read, deliberately not `FieldValue.increment`. Firestore aborts and\n * re-runs the callback when a document the transaction read has moved, so a\n * second sender that starts inside the first one's window re-reads the raised\n * figure and is refused. An increment would be atomic on the number and\n * useless for the decision, because the decision is made from a value the\n * write never proves it still held.\n *\n * A refused reservation writes NOTHING.\n *\n * ## Why reserve-then-reconcile rather than reserve-and-keep\n *\n * A campaign reserves against a PARTIAL delivery: `sendEmail` reports per\n * message, and only some of a 500-address batch may go out. Keeping the whole\n * reservation would charge a customer's allowance for mail that never left.\n * So the claim is taken in full up front — that is what makes it a cap — and\n * {@link reconcileCampaignSendReservation} gives back the difference once the\n * delivered count is known.\n *\n * **The failure mode is stated rather than hidden**: if the process dies\n * between reserving and reconciling, the org is charged for the undelivered\n * remainder for the rest of that calendar month. That is conservative in the\n * direction this issue cares about — it can only ever refuse more mail, never\n * let more out — and it self-heals at the month boundary, because each month\n * is an independent field on the document.\n *\n * `limit` may be `Infinity` (`UNLIMITED`), which admits everything.\n */\nexport async function reserveCampaignEmailSends(options: {\n orgId: string\n month: string\n count: number\n limit: number\n firestore?: any\n}): Promise<ReserveCampaignSendsResult> {\n const count = Math.max(0, Math.floor(Number(options.count) || 0))\n const limit = Number(options.limit)\n // A plan-less or unresolvable org must not be a bypass. The caller resolves\n // free-tier entitlements for it (AGL-247) and the cap is 0, so an empty\n // orgId that reached here means the counter has nowhere to live — refuse\n // rather than send unbounded.\n if (!options.orgId) {\n return { ok: false, used: 0, limit: Number.isFinite(limit) ? limit : 0 }\n }\n const db = options.firestore ?? firebaseAdmin.app().firestore()\n const ref = db\n .collection('orgs')\n .doc(options.orgId)\n .collection('counters')\n .doc(ORG_CAMPAIGN_EMAIL_SENDS_COUNTER)\n\n return db.runTransaction(async (tx: any) => {\n const snapshot = await tx.get(ref)\n const used = readMonthField(snapshot, options.month)\n if (used + count > limit) return { ok: false, used, limit }\n tx.set(ref, { [options.month]: used + count }, { merge: true })\n return {\n ok: true,\n reservation: { orgId: options.orgId, month: options.month, reserved: count },\n used,\n limit,\n }\n })\n}\n\n/**\n * Returns the undelivered part of a reservation.\n *\n * Also a transaction, and also an absolute write from its own read: a refund\n * computed from a stale figure would undo a reservation another campaign took\n * in the meantime, which is the same defect one direction over.\n *\n * **Never throws** and never drives the counter below zero. This runs after\n * mail has already gone out; a bookkeeping failure must not turn a delivered\n * campaign into a 500, exactly like `recordEmailSends` above. The cost of\n * swallowing it is that the org keeps a claim it did not use for the rest of\n * the month, which is the safe direction.\n */\nexport async function reconcileCampaignSendReservation(\n reservation: CampaignSendReservation | null | undefined,\n delivered: number,\n firestore?: any,\n): Promise<void> {\n if (!reservation?.orgId) return\n const sent = Math.max(0, Math.floor(Number(delivered) || 0))\n const refund = Math.max(0, reservation.reserved - sent)\n if (refund <= 0) return\n try {\n const db = firestore ?? firebaseAdmin.app().firestore()\n const ref = db\n .collection('orgs')\n .doc(reservation.orgId)\n .collection('counters')\n .doc(ORG_CAMPAIGN_EMAIL_SENDS_COUNTER)\n await db.runTransaction(async (tx: any) => {\n const snapshot = await tx.get(ref)\n const used = readMonthField(snapshot, reservation.month)\n tx.set(\n ref,\n { [reservation.month]: Math.max(0, used - refund) },\n { merge: true },\n )\n })\n } catch (error) {\n console.error('campaign reservation reconcile failed', error)\n }\n}\n\n/**\n * Volume above the plan's included band, in emails — RE-EXPORTED, not defined\n * here, the same move AGL-2155 made for the bandwidth helpers.\n *\n * The definition moved down to `@aglyn/aglyn/app-utils/plan-entitlements`\n * because the billing page has to render the overage BEFORE it is charged,\n * and that page is a client component which cannot import this module: the\n * Admin SDK comes with it. A second copy of the subtraction on the client is\n * the shape where the readout and the invoice quietly stop agreeing.\n *\n * Re-exported so every server caller keeps importing it from the module it\n * always did — one definition, no drift, no import churn.\n *\n * @see priceEmailSendOverage — what the excess costs, at the plan's rate.\n */\nexport { emailSendsOverage } from '@aglyn/aglyn/app-utils/plan-entitlements'\n\nexport default recordEmailSends\n"],"names":["firebaseAdmin","EMAIL_SENDS_COUNTER","CAMPAIGN_EMAIL_SENDS_COUNTER","PLATFORM_METER_COLLECTION","PLATFORM_METER_DOC","emailMeterRef","firestore","scope","counter","kind","collection","doc","hostId","orgId","currentMeterMonth","now","Date","toISOString","slice","recordEmailSends","options","sendClass","count","Math","floor","Number","isFinite","month","app","increment","FieldValue","counters","Promise","all","map","set","merge","error","console","meterHostEmail","meterOrgEmail","meterPlatformEmail","campaignEmailSendsForMonth","hostRef","snapshot","get","used","ORG_CAMPAIGN_EMAIL_SENDS_COUNTER","readMonthField","exists","orgCampaignEmailSendsForMonth","db","reserveCampaignEmailSends","max","limit","ok","ref","runTransaction","tx","reservation","reserved","reconcileCampaignSendReservation","delivered","sent","refund","emailSendsOverage"],"mappings":"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"}
|
|
@@ -0,0 +1,185 @@
|
|
|
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 { EMAIL_ATTRIBUTION_WINDOW_MS } from '@aglyn/shared-util-email';
|
|
18
|
+
/**
|
|
19
|
+
* THE COMMERCE↔EMAIL JOIN — an order, credited to the campaign that led to it.
|
|
20
|
+
*
|
|
21
|
+
* ## Why this is a join
|
|
22
|
+
*
|
|
23
|
+
* Every compared ESP attributes revenue through an integration into a store
|
|
24
|
+
* it does not own: a catalog sync, an on-site tracking snippet, an identity
|
|
25
|
+
* graph, and a window wide enough to absorb what the reconciliation misses.
|
|
26
|
+
* Commerce here is first-party and runs on the merchant's own Stripe Connect
|
|
27
|
+
* account, so the click and the order are two rows in one database keyed the
|
|
28
|
+
* same way. The window is not a fudge factor here — it is the model, and
|
|
29
|
+
* nothing else about the number is estimated.
|
|
30
|
+
*
|
|
31
|
+
* ## Three writes, and what each one is for
|
|
32
|
+
*
|
|
33
|
+
* - **The touch**, `emailDeliveries/{personKey}.campaignTouches[hostId]`,
|
|
34
|
+
* written by the delivery webhook on a click. Owned by
|
|
35
|
+
* `email-delivery-log.ts`, because it lives on the person's document and
|
|
36
|
+
* the erasure path has to be able to remove it.
|
|
37
|
+
* - **The attribution record**, `hosts/{hostId}/emailAttributions/{orderId}`,
|
|
38
|
+
* written here when an order is credited. It is the audit trail — which
|
|
39
|
+
* campaign, which click, which model, which window — and it is what the
|
|
40
|
+
* refund path reads to find out which campaign to take the money back off.
|
|
41
|
+
* - **The rollup**, `campaigns/{campaignId}/reports/revenue`, incremented
|
|
42
|
+
* here. One document per campaign, so the report reads it whole.
|
|
43
|
+
*
|
|
44
|
+
* ## Why the record is a document and not a field on the order
|
|
45
|
+
*
|
|
46
|
+
* Three reasons, and the first is decisive. `create()` fails when the
|
|
47
|
+
* document already exists, which is exact idempotency for free: webhook
|
|
48
|
+
* delivery is at-least-once, and a redelivered purchase must not be able to
|
|
49
|
+
* credit a campaign twice. Second, not every purchase door writes an order —
|
|
50
|
+
* a booking is a sale with no order document — and a field would have had to
|
|
51
|
+
* either skip those or conjure the document, and conjuring is the
|
|
52
|
+
* phantom-document shape this codebase spent a sweep removing. Third, the
|
|
53
|
+
* order document is read by the console list, the fulfilment path, the
|
|
54
|
+
* supplier outbox and the CSV export, and none of them wants a field about
|
|
55
|
+
* email.
|
|
56
|
+
*
|
|
57
|
+
* ## Never throws
|
|
58
|
+
*
|
|
59
|
+
* Same contract as `upsertHostContact`, which calls it, and for the same
|
|
60
|
+
* reason: the money has already moved and the order already records it, so
|
|
61
|
+
* nothing here may fail a sale. A lost attribution understates a campaign; a
|
|
62
|
+
* thrown one loses a checkout.
|
|
63
|
+
*/
|
|
64
|
+
/** The per-host collection of attribution records. */
|
|
65
|
+
export declare const EMAIL_ATTRIBUTIONS_COLLECTION = "emailAttributions";
|
|
66
|
+
/** The single rollup document under a campaign. */
|
|
67
|
+
export declare const CAMPAIGN_REVENUE_REPORT_DOC = "revenue";
|
|
68
|
+
/**
|
|
69
|
+
* The currency an amount is recorded under when the caller does not say.
|
|
70
|
+
*
|
|
71
|
+
* Every checkout door in this repo — cart, buy-now, POS, draft orders,
|
|
72
|
+
* reservations, subscriptions — writes `currency: 'usd'` onto the Stripe line
|
|
73
|
+
* items, and no order document carries a currency field to read back. So this
|
|
74
|
+
* is a statement about what the code charges rather than a guess about the
|
|
75
|
+
* money. The parameter exists so a door that ever charges in something else
|
|
76
|
+
* says so and lands in its own bucket, because the one thing the report may
|
|
77
|
+
* never do is add two currencies together.
|
|
78
|
+
*/
|
|
79
|
+
export declare const DEFAULT_ATTRIBUTION_CURRENCY = "usd";
|
|
80
|
+
/** What one attribution record holds. */
|
|
81
|
+
export interface EmailAttributionRecord {
|
|
82
|
+
campaignId: string;
|
|
83
|
+
/** When the credited click happened. */
|
|
84
|
+
clickedAtMs: number;
|
|
85
|
+
/** When the order was placed. */
|
|
86
|
+
orderedAtMs: number;
|
|
87
|
+
/** Minor units credited, gross — the amount the buyer was charged. */
|
|
88
|
+
amountCents: number;
|
|
89
|
+
/** Lowercase currency code the amount is in. */
|
|
90
|
+
currency: string;
|
|
91
|
+
/** The model this credit was decided under. */
|
|
92
|
+
model: string;
|
|
93
|
+
/** The window, in days, it was decided inside. */
|
|
94
|
+
windowDays: number;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Credits one order to the campaign whose link the buyer last clicked.
|
|
98
|
+
*
|
|
99
|
+
* ## What happens when there is nobody to credit
|
|
100
|
+
*
|
|
101
|
+
* Each case answers `null` and writes NOTHING, which is the whole design:
|
|
102
|
+
*
|
|
103
|
+
* - **No email on the order.** A guest checkout that never identified its
|
|
104
|
+
* buyer cannot be joined to anybody's clicks. There is no fallback and
|
|
105
|
+
* there deliberately is not one — the alternatives are an IP or a device
|
|
106
|
+
* guess, which is the probabilistic attribution owning the checkout exists
|
|
107
|
+
* to avoid.
|
|
108
|
+
* - **A guest with an email but no contact record.** This is ATTRIBUTED
|
|
109
|
+
* normally. The join keys on the address hash, exactly as the touch and
|
|
110
|
+
* the suppression list do, so it never asks whether a contact document
|
|
111
|
+
* exists — which matters, because contact creation is audience-band gated
|
|
112
|
+
* and a Free org's dropped contact would otherwise silently drop the
|
|
113
|
+
* revenue with it.
|
|
114
|
+
* - **No touch, or a touch on another site.** Nobody clicked, so nobody is
|
|
115
|
+
* credited. The touch map is keyed by host and the send path refuses
|
|
116
|
+
* cross-site reach; the revenue join agrees with it.
|
|
117
|
+
* - **A touch outside the window**, in either direction. See
|
|
118
|
+
* {@link emailTouchIsInWindow} — a click AFTER the order is the receipt,
|
|
119
|
+
* not the cause.
|
|
120
|
+
*
|
|
121
|
+
* None of these is counted anywhere. A miss costs no write, which is what
|
|
122
|
+
* keeps the ordinary order — placed by somebody who is not on the mailing
|
|
123
|
+
* list at all — at exactly one document read. What it means for the report is
|
|
124
|
+
* stated on the report: the figure counts orders it could join, and is a
|
|
125
|
+
* floor.
|
|
126
|
+
*
|
|
127
|
+
* @returns the record written, or `null` when nothing was credited.
|
|
128
|
+
*/
|
|
129
|
+
export declare function attributeOrderToEmail(options: {
|
|
130
|
+
hostId: string;
|
|
131
|
+
/** The order, booking or invoice the money came in on. */
|
|
132
|
+
orderId: string;
|
|
133
|
+
/** The buyer as the sale recorded them, raw — normalized downstream. */
|
|
134
|
+
email: unknown;
|
|
135
|
+
/** Gross minor units the buyer was charged. */
|
|
136
|
+
amountCents: number;
|
|
137
|
+
/** Lowercase currency code, when the door knows one. */
|
|
138
|
+
currency?: string;
|
|
139
|
+
/** When the order was placed. Defaults to now. */
|
|
140
|
+
orderedAtMs?: number;
|
|
141
|
+
}, firestore?: any): Promise<EmailAttributionRecord | null>;
|
|
142
|
+
/**
|
|
143
|
+
* Takes back revenue a campaign was credited with, when it is refunded.
|
|
144
|
+
*
|
|
145
|
+
* ## Recorded beside the gross, never subtracted from it
|
|
146
|
+
*
|
|
147
|
+
* `grossCents` is left exactly as it is and the reversal lands in
|
|
148
|
+
* `refundedCents` next to it. This is the shape `contact-refund.ts` chose for
|
|
149
|
+
* `ltvCents`/`refundedCents` and the orders CSV chose for
|
|
150
|
+
* `amountUsd`/`refundedUsd`, and it is chosen a third time here so all three
|
|
151
|
+
* answer "what did this earn, net" identically. Decrementing would make a
|
|
152
|
+
* stored number mean one thing for rollups written before a refund and
|
|
153
|
+
* another after, with nothing on the document to tell them apart — and would
|
|
154
|
+
* put the question "can it go negative" onto storage, where the answer cannot
|
|
155
|
+
* be clamped without destroying evidence. Both stored figures are monotonic
|
|
156
|
+
* counters of money that really moved in one direction; the derived net is
|
|
157
|
+
* clamped at the point of display and nowhere else.
|
|
158
|
+
*
|
|
159
|
+
* ## The currency comes from the RECORD, not from the caller
|
|
160
|
+
*
|
|
161
|
+
* A refund reverses a specific sale, and the bucket it comes out of has to be
|
|
162
|
+
* the bucket it went into. Reading the currency back off the attribution
|
|
163
|
+
* record is what guarantees that, and it is the second thing the record is
|
|
164
|
+
* for.
|
|
165
|
+
*
|
|
166
|
+
* ## Chargebacks come through here too
|
|
167
|
+
*
|
|
168
|
+
* Money reversed is money reversed, whichever door it left by, so a lost
|
|
169
|
+
* dispute reverses the credit the same way a refund does. `kind` exists for
|
|
170
|
+
* the caller's clarity and changes nothing about the arithmetic — the same
|
|
171
|
+
* choice `recordContactRefund` made one field along.
|
|
172
|
+
*
|
|
173
|
+
* @returns whether a reversal was recorded.
|
|
174
|
+
*/
|
|
175
|
+
export declare function reverseEmailAttributedRevenue(options: {
|
|
176
|
+
hostId: string;
|
|
177
|
+
orderId: string;
|
|
178
|
+
/** Minor units reversed by THIS attempt, never the order total. */
|
|
179
|
+
amountCents: number;
|
|
180
|
+
/** True only for the write that moved the order into `refunded`. */
|
|
181
|
+
closedTheOrder: boolean;
|
|
182
|
+
kind?: 'refund' | 'chargeback';
|
|
183
|
+
}, firestore?: any): Promise<boolean>;
|
|
184
|
+
/** The window, re-exported so a caller needs one import for the whole join. */
|
|
185
|
+
export { EMAIL_ATTRIBUTION_WINDOW_MS };
|