@aglyn/tenant-data-admin 1.0.0-beta.218 → 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 (156) hide show
  1. package/package.json +8 -8
  2. package/src/lib/server/api-http.js +5 -1
  3. package/src/lib/server/api-http.js.map +1 -1
  4. package/src/lib/server/api-v1-description.d.ts +112 -0
  5. package/src/lib/server/api-v1-description.js +126 -0
  6. package/src/lib/server/api-v1-description.js.map +1 -0
  7. package/src/lib/server/api-v1-kit.d.ts +163 -0
  8. package/src/lib/server/api-v1-kit.js +191 -0
  9. package/src/lib/server/api-v1-kit.js.map +1 -0
  10. package/src/lib/server/api-v1-resources.d.ts +143 -0
  11. package/src/lib/server/api-v1-resources.js +125 -0
  12. package/src/lib/server/api-v1-resources.js.map +1 -0
  13. package/src/lib/server/campaign-conversion-attribution.d.ts +1 -1
  14. package/src/lib/server/campaign-conversion-attribution.js +3 -3
  15. package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
  16. package/src/lib/server/card-payment-velocity.d.ts +9 -0
  17. package/src/lib/server/card-payment-velocity.js +28 -2
  18. package/src/lib/server/card-payment-velocity.js.map +1 -1
  19. package/src/lib/server/consent-group-carry.js +1 -1
  20. package/src/lib/server/consent-group-carry.js.map +1 -1
  21. package/src/lib/server/consent-group-change.d.ts +2 -1
  22. package/src/lib/server/consent-group-change.js +43 -18
  23. package/src/lib/server/consent-group-change.js.map +1 -1
  24. package/src/lib/server/contact-email-index.d.ts +22 -0
  25. package/src/lib/server/contact-email-index.js +108 -0
  26. package/src/lib/server/contact-email-index.js.map +1 -1
  27. package/src/lib/server/crm-email-activity.d.ts +1 -1
  28. package/src/lib/server/crm-email-activity.js +1 -1
  29. package/src/lib/server/crm-email-activity.js.map +1 -1
  30. package/src/lib/server/crm-records.d.ts +9 -1
  31. package/src/lib/server/crm-records.js +19 -2
  32. package/src/lib/server/crm-records.js.map +1 -1
  33. package/src/lib/server/cron-auth.d.ts +44 -0
  34. package/src/lib/server/cron-auth.js +76 -0
  35. package/src/lib/server/cron-auth.js.map +1 -0
  36. package/src/lib/server/dataset-live-pages.js +6 -6
  37. package/src/lib/server/dataset-live-pages.js.map +1 -1
  38. package/src/lib/server/duplicate-resource.d.ts +26 -0
  39. package/src/lib/server/duplicate-resource.js +47 -25
  40. package/src/lib/server/duplicate-resource.js.map +1 -1
  41. package/src/lib/server/dynamic-list-materialize.js +6 -3
  42. package/src/lib/server/dynamic-list-materialize.js.map +1 -1
  43. package/src/lib/server/email-flow-gate.js +2 -2
  44. package/src/lib/server/email-flow-gate.js.map +1 -1
  45. package/src/lib/server/email-suppression.d.ts +11 -0
  46. package/src/lib/server/email-suppression.js +37 -2
  47. package/src/lib/server/email-suppression.js.map +1 -1
  48. package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
  49. package/src/lib/server/email-unsubscribe-link.js +7 -7
  50. package/src/lib/server/email-unsubscribe-link.js.map +1 -1
  51. package/src/lib/server/erase.d.ts +19 -33
  52. package/src/lib/server/erase.js +37 -162
  53. package/src/lib/server/erase.js.map +1 -1
  54. package/src/lib/server/host-email-tokens.js +1 -1
  55. package/src/lib/server/host-email-tokens.js.map +1 -1
  56. package/src/lib/server/host-memberships.js +3 -3
  57. package/src/lib/server/host-memberships.js.map +1 -1
  58. package/src/lib/server/host-visitor-records.d.ts +32 -4
  59. package/src/lib/server/host-visitor-records.js +73 -14
  60. package/src/lib/server/host-visitor-records.js.map +1 -1
  61. package/src/lib/server/hosted-page-review.d.ts +27 -0
  62. package/src/lib/server/hosted-page-review.js +149 -12
  63. package/src/lib/server/hosted-page-review.js.map +1 -1
  64. package/src/lib/server/media-cdn-rate-limit.d.ts +6 -3
  65. package/src/lib/server/media-cdn-rate-limit.js +6 -3
  66. package/src/lib/server/media-cdn-rate-limit.js.map +1 -1
  67. package/src/lib/server/member-email-aliases.d.ts +10 -2
  68. package/src/lib/server/member-email-aliases.js +26 -2
  69. package/src/lib/server/member-email-aliases.js.map +1 -1
  70. package/src/lib/server/new-account-announcement.d.ts +62 -0
  71. package/src/lib/server/new-account-announcement.js +77 -0
  72. package/src/lib/server/new-account-announcement.js.map +1 -0
  73. package/src/lib/server/notifications.d.ts +22 -2
  74. package/src/lib/server/notifications.js +40 -3
  75. package/src/lib/server/notifications.js.map +1 -1
  76. package/src/lib/server/operator-alerts.d.ts +7 -1
  77. package/src/lib/server/operator-alerts.js +5 -3
  78. package/src/lib/server/operator-alerts.js.map +1 -1
  79. package/src/lib/server/org-containers.d.ts +66 -0
  80. package/src/lib/server/org-containers.js +95 -0
  81. package/src/lib/server/org-containers.js.map +1 -0
  82. package/src/lib/server/org-member-notice.js +3 -2
  83. package/src/lib/server/org-member-notice.js.map +1 -1
  84. package/src/lib/server/payment-fraud-signal.js +5 -1
  85. package/src/lib/server/payment-fraud-signal.js.map +1 -1
  86. package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
  87. package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +36 -2
  88. package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
  89. package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
  90. package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
  91. package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
  92. package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
  93. package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
  94. package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
  95. package/src/lib/server/payment-provider.d.ts +173 -0
  96. package/src/lib/server/payment-provider.js +70 -0
  97. package/src/lib/server/payment-provider.js.map +1 -0
  98. package/src/lib/server/payment-risk-record.d.ts +1 -1
  99. package/src/lib/server/payment-risk-record.js +3 -1
  100. package/src/lib/server/payment-risk-record.js.map +1 -1
  101. package/src/lib/server/personal-data-export.js +1 -1
  102. package/src/lib/server/personal-data-export.js.map +1 -1
  103. package/src/lib/server/platform-marketing-consent.d.ts +1 -1
  104. package/src/lib/server/platform-marketing-consent.js +1 -1
  105. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  106. package/src/lib/server/rate-limit-store.js +13 -3
  107. package/src/lib/server/rate-limit-store.js.map +1 -1
  108. package/src/lib/server/realm-plugins.js +8 -3
  109. package/src/lib/server/realm-plugins.js.map +1 -1
  110. package/src/lib/server/retained-refusals.d.ts +142 -0
  111. package/src/lib/server/retained-refusals.js +325 -0
  112. package/src/lib/server/retained-refusals.js.map +1 -0
  113. package/src/lib/server/risk-notice.d.ts +32 -1
  114. package/src/lib/server/risk-notice.js +90 -28
  115. package/src/lib/server/risk-notice.js.map +1 -1
  116. package/src/lib/server/sending-domains.d.ts +17 -0
  117. package/src/lib/server/sending-domains.js +28 -4
  118. package/src/lib/server/sending-domains.js.map +1 -1
  119. package/src/lib/server/sso-enforcement.js +7 -2
  120. package/src/lib/server/sso-enforcement.js.map +1 -1
  121. package/src/lib/server/staff-alert-email.js +4 -2
  122. package/src/lib/server/staff-alert-email.js.map +1 -1
  123. package/src/lib/server/test-firestore-queries.d.ts +7 -0
  124. package/src/lib/server/test-firestore-queries.js +70 -27
  125. package/src/lib/server/test-firestore-queries.js.map +1 -1
  126. package/src/lib/server/{email-topic-confirmation.js → topic-subscriptions.js} +15 -11
  127. package/src/lib/server/topic-subscriptions.js.map +1 -0
  128. package/src/lib/server/upsert-contact.d.ts +46 -1
  129. package/src/lib/server/upsert-contact.js +87 -20
  130. package/src/lib/server/upsert-contact.js.map +1 -1
  131. package/src/tenancy.d.ts +5 -8
  132. package/src/tenancy.js +12 -13
  133. package/src/tenancy.js.map +1 -1
  134. package/src/lib/server/connect-account-status.js.map +0 -1
  135. package/src/lib/server/connect-payout-failure.js.map +0 -1
  136. package/src/lib/server/contact-email-engagement.d.ts +0 -32
  137. package/src/lib/server/contact-email-engagement.js +0 -145
  138. package/src/lib/server/contact-email-engagement.js.map +0 -1
  139. package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
  140. package/src/lib/server/contact-lifecycle-floor.js +0 -91
  141. package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
  142. package/src/lib/server/contact-merge.d.ts +0 -55
  143. package/src/lib/server/contact-merge.js +0 -288
  144. package/src/lib/server/contact-merge.js.map +0 -1
  145. package/src/lib/server/crm-booking-activity.d.ts +0 -86
  146. package/src/lib/server/crm-booking-activity.js +0 -257
  147. package/src/lib/server/crm-booking-activity.js.map +0 -1
  148. package/src/lib/server/crm-inbound-email.d.ts +0 -145
  149. package/src/lib/server/crm-inbound-email.js +0 -297
  150. package/src/lib/server/crm-inbound-email.js.map +0 -1
  151. package/src/lib/server/email-topic-confirmation.js.map +0 -1
  152. package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
  153. package/src/lib/server/form-dataset-binding-token.js +0 -107
  154. package/src/lib/server/form-dataset-binding-token.js.map +0 -1
  155. package/src/lib/server/stripe-account-mode.js.map +0 -1
  156. /package/src/lib/server/{email-topic-confirmation.d.ts → topic-subscriptions.d.ts} +0 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/payment-fraud-signal.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * STRIPE'S FRAUD SIGNALS, INTO THE ABUSE QUEUE (AGL-3356).\n *\n * The 9/26 incident paid for Pro with a card whose CVC check came back\n * unavailable and which never met 3-D Secure. Stripe had three ways to tell\n * us about a card like that — an early fraud warning from the issuer, a\n * Radar review, a dispute — and the platform webhook either did not\n * subscribe to them or filed them where nobody triages abuse.\n *\n * Each signal now files an urgent row in the abuse queue that names the\n * workspace, the charge and the amount, links the staff Subscription card\n * on the org page (AGL-3359, where billing is canceled), and notifies staff\n * once. NOTHING IS DONE TO THE MONEY OR THE SUBSCRIPTION: no refund, no\n * cancel. An early fraud warning is not a chargeback, a review may clear, and\n * a refund issued before staff look can be the wrong half of a dispute. The\n * row is the prompt; staff decide.\n *\n * Dependencies are passed in rather than imported — the Firestore handle the\n * webhook observes its writes through, and its staff notifier — so the\n * webhook's \"did this delivery do anything\" ledger sees the write, and a spec\n * substituting the admin barrel substitutes these too.\n *=========================================*/\n\nimport { createHash } from 'crypto'\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport type { RiskEventInput } from './risk-notice'\n\n/**\n * The seam every signal here tells people through (AGL-3368): the owners\n * and admins of the workspace, and staff. Passed in, like the Firestore\n * handle, so the webhook's spec substitutes it with the barrel.\n */\nexport type RiskNotifier = (input: RiskEventInput) => Promise<unknown>\n\n/** Which Stripe signal this is. */\nexport type PaymentFraudSignalKind =\n | 'early-fraud-warning'\n | 'radar-review'\n | 'dispute'\n\nconst KIND_LABEL: Record<PaymentFraudSignalKind, string> = {\n 'early-fraud-warning': 'Early fraud warning',\n 'radar-review': 'Radar review opened',\n dispute: 'Card dispute opened',\n}\n\n/** The Stripe object type each signal arrives as, for the queue's text. */\nexport const PAYMENT_FRAUD_SIGNAL_LABELS = KIND_LABEL\n\n/** What the card's own checks said, when the charge could be read. */\nexport interface PaymentFraudCardChecks {\n cvcCheck: string | null\n addressPostalCodeCheck: string | null\n cardCountry: string | null\n riskLevel: string | null\n threeDSecure: string | null\n}\n\nexport interface PaymentFraudSignal {\n kind: PaymentFraudSignalKind\n /** The EFW, review or dispute id: one row per signal, whatever redelivers. */\n stripeObjectId: string\n chargeId: string | null\n paymentIntentId: string | null\n /** The workspace the charge billed, or null when it resolved to none. */\n orgId: string | null\n /** Smallest currency unit, or null when nothing recorded the amount. */\n amountCents: number | null\n currency: string\n /** `fraud_type`, the review's `reason`, the dispute's `reason`. */\n detail: string\n checks: PaymentFraudCardChecks | null\n livemode: boolean\n}\n\n/** The row id: hex, so the admin route's id pattern addresses it. */\nexport function paymentFraudSignalReviewId(\n kind: PaymentFraudSignalKind,\n stripeObjectId: string,\n): string {\n return createHash('sha256')\n .update(`stripe-fraud:${kind}:${stripeObjectId}`)\n .digest('hex')\n .slice(0, 40)\n}\n\n/** The staff-facing reference, beside the intake's `AR-` and the screen's `HS-`. */\nexport function paymentFraudSignalReference(reviewId: string): string {\n return `PF-${reviewId.slice(0, 10).toUpperCase()}`\n}\n\n/** Where staff act on the workspace's billing: its Subscription card. */\nexport function staffSubscriptionCardPath(orgId: string): string {\n return `/admin/orgs/${encodeURIComponent(orgId)}#subscription`\n}\n\n/**\n * The Stripe Dashboard page for a signal: the dispute itself, else the\n * payment it is about.\n */\nexport function stripeSignalDashboardUrl(signal: {\n kind: PaymentFraudSignalKind\n stripeObjectId: string\n chargeId: string | null\n paymentIntentId: string | null\n livemode: boolean\n}): string | null {\n const base = `https://dashboard.stripe.com/${signal.livemode ? '' : 'test/'}`\n if (signal.kind === 'dispute' && signal.stripeObjectId) {\n return `${base}disputes/${encodeURIComponent(signal.stripeObjectId)}`\n }\n const payment = signal.paymentIntentId || signal.chargeId\n return payment ? `${base}payments/${encodeURIComponent(payment)}` : null\n}\n\n/** `$56.00 USD`, or a sentence saying the amount is not known. */\nexport function formatSignalAmount(\n amountCents: number | null,\n currency: string,\n): string {\n if (amountCents === null || !Number.isFinite(amountCents)) {\n return 'amount not recorded'\n }\n return `${(amountCents / 100).toFixed(2)} ${String(currency || 'usd').toUpperCase()}`\n}\n\n/**\n * The one-line summary of a charge Stripe gives back, for a signal whose\n * charge is not on a platform revenue row. A READ — the only Stripe call\n * this module makes — and it answers null rather than throwing, because a\n * fraud alert that fails for want of an amount is worse than one that says\n * the amount is unknown.\n */\nexport async function readStripeChargeForSignal(\n chargeId: string,\n options: { secretKey: string | undefined; fetchImpl?: typeof fetch },\n): Promise<{\n amountCents: number | null\n currency: string\n customerId: string | null\n checks: PaymentFraudCardChecks\n /**\n * The connected account the charge paid (AGL-3360): a destination\n * charge's `transfer_data.destination`. Every sale Aglyn takes for a site\n * is a destination charge on the platform account — the facilitator\n * model (AGL-1956) — so this is the seller. Null for a charge that paid\n * only the platform, a workspace's own subscription.\n */\n sellerAccountId: string | null\n} | null> {\n if (!chargeId || !options.secretKey) return null\n try {\n const response = await (options.fetchImpl ?? fetch)(\n `https://api.stripe.com/v1/charges/${encodeURIComponent(chargeId)}`,\n { headers: { Authorization: `Bearer ${options.secretKey}` } },\n )\n if (!response.ok) return null\n const charge = (await response.json()) as Record<string, any>\n const card = charge?.['payment_method_details']?.['card'] ?? {}\n const text = (value: unknown) =>\n typeof value === 'string' && value ? value : null\n return {\n amountCents: Number.isFinite(Number(charge?.['amount']))\n ? Number(charge['amount'])\n : null,\n currency: String(charge?.['currency'] ?? 'usd'),\n customerId: text(charge?.['customer']),\n checks: {\n cvcCheck: text(card?.['checks']?.['cvc_check']),\n addressPostalCodeCheck: text(card?.['checks']?.['address_postal_code_check']),\n cardCountry: text(card?.['country']),\n riskLevel: text(charge?.['outcome']?.['risk_level']),\n threeDSecure: text(card?.['three_d_secure']?.['result']),\n },\n sellerAccountId: accountRef(charge?.['transfer_data']?.['destination']),\n }\n } catch {\n return null\n }\n}\n\n/** A Stripe account reference, expanded or not, as an id or null. */\nfunction accountRef(value: unknown): string | null {\n const id =\n typeof value === 'string'\n ? value\n : String((value as { id?: unknown } | null)?.id ?? '')\n return id.startsWith('acct_') ? id : null\n}\n\n/** The row's prose: what arrived, about which workspace, and what to do. */\nexport function describePaymentFraudSignal(signal: PaymentFraudSignal): string {\n const lines = [\n `${KIND_LABEL[signal.kind]} from Stripe (${signal.stripeObjectId})` +\n (signal.livemode ? '' : ' — TEST MODE') +\n '.',\n `Workspace: ${signal.orgId ?? 'none — the charge did not resolve to a workspace subscription'}.`,\n `Charge: ${signal.chargeId ?? signal.paymentIntentId ?? 'not named'} · ${formatSignalAmount(signal.amountCents, signal.currency)}.`,\n ]\n if (signal.detail) lines.push(`Stripe says: ${signal.detail}.`)\n const checks = signal.checks\n if (checks) {\n lines.push(\n `Card: CVC ${checks.cvcCheck ?? 'unknown'}, postal code ${checks.addressPostalCodeCheck ?? 'unknown'}, ` +\n `issued in ${checks.cardCountry ?? 'unknown'}, 3DS ${checks.threeDSecure ?? 'not used'}, ` +\n `Radar risk ${checks.riskLevel ?? 'unknown'}.`,\n )\n }\n lines.push(\n signal.kind === 'radar-review'\n ? 'The payment waits in Stripe until the review is closed there.'\n : signal.kind === 'dispute'\n ? 'Answer the dispute in Stripe before its evidence deadline.'\n : 'An early fraud warning is not yet a chargeback; refunding now can avoid one.',\n 'Nothing has been refunded or canceled. Decide on the org’s Subscription card, lock the workspace if it is fraud, and close this row with what you did.',\n )\n return lines.join('\\n').slice(0, 5000)\n}\n\n/**\n * File one signal and tell staff, once per signal.\n *\n * Idempotent on the Stripe object id: a redelivery bumps `reportCount` and\n * leaves the row's status — which staff own from the moment they touch it —\n * where it was. Throws only if the row write throws, which is the webhook's\n * to answer; the notification never throws.\n */\nexport async function recordPaymentFraudSignal(\n signal: PaymentFraudSignal,\n deps: {\n firestore: FirebaseFirestore.Firestore\n notifyRisk: RiskNotifier\n },\n): Promise<{ reviewId: string; reference: string; first: boolean }> {\n const reviewId = paymentFraudSignalReviewId(signal.kind, signal.stripeObjectId)\n const reference = paymentFraudSignalReference(reviewId)\n const ref = deps.firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const first = !(await ref.get()).exists\n const subscriptionCard = signal.orgId\n ? staffSubscriptionCardPath(signal.orgId)\n : null\n await ref.set(\n {\n reference,\n // \"Phishing or fraud\" — the queue's urgent fraud category.\n category: 'phishing',\n severity: 'urgent',\n source: 'stripe-fraud-signal',\n url: null,\n reportedHostname: null,\n hostId: null,\n orgId: signal.orgId,\n details: describePaymentFraudSignal(signal),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: FieldValue.increment(1),\n paymentSignal: {\n kind: signal.kind,\n stripeObjectId: signal.stripeObjectId,\n chargeId: signal.chargeId,\n paymentIntentId: signal.paymentIntentId,\n amountCents: signal.amountCents,\n currency: signal.currency,\n detail: signal.detail,\n checks: signal.checks,\n livemode: signal.livemode,\n subscriptionCard,\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(first\n ? { status: 'open', createdAt: FieldValue.serverTimestamp() }\n : {}),\n },\n { merge: true },\n )\n if (first) {\n // The workspace's owners are told their subscription payment was\n // flagged and how to confirm it; staff get the row with the evidence.\n await deps\n .notifyRisk({\n kind: 'billing-payment-flagged',\n orgId: signal.orgId,\n reviewId,\n reference,\n item: { label: 'your subscription payment', path: '/org/billing/invoices' },\n amount: formatSignalAmount(signal.amountCents, signal.currency),\n stripeUrl: stripeSignalDashboardUrl(signal),\n staffEvidence:\n `${KIND_LABEL[signal.kind]} (${signal.stripeObjectId}) on charge ` +\n `${signal.chargeId ?? signal.paymentIntentId ?? 'not named'}` +\n (signal.detail ? `; Stripe says: ${signal.detail}` : '') +\n (signal.livemode ? '' : ' — TEST MODE') +\n '.',\n })\n .catch(() => undefined)\n }\n return { reviewId, reference, first }\n}\n\n/*==========================================\n * THE SELLER PATTERN: WHEN THE MERCHANT MAY BE THE FRAUDSTER (AGL-3360).\n *\n * A site's own sales are destination charges on the platform account, so\n * Stripe's fraud signals about a shopper's card arrive here too. One of them\n * is the merchant's business — their customer, their order, their decision\n * (`payment-risk-record.ts` puts it on the record and tells them). Staff are\n * not paged for it.\n *\n * What IS staff's business is the shape a phishing actor leaves when they\n * run stolen cards through a storefront of their own: several different\n * charges on ONE connected account drawing issuer fraud reports or\n * chargebacks in a short time. So every signal on a seller's charge is kept\n * in a small per-account ledger, and one urgent abuse-queue row is filed when\n *\n * ≥ 3 DISTINCT charges carry an early fraud warning or a dispute\n * within 7 days.\n *\n * Why those numbers. An early fraud warning is an issuer's report that the\n * cardholder did not make the payment, and a dispute is a formal\n * chargeback; a legitimate small merchant sees either rarely — card-network\n * dispute monitoring starts near 0.9% of a month's transactions, single\n * digits a MONTH for a shop doing hundreds of sales. One or two in a week is\n * ordinary friendly fraud and must never page staff. Three separate charges\n * in one week is a rate a real small shop does not produce, and is what a\n * card tester or a stolen-card storefront produces within days. Counted by\n * CHARGE, because a warning is routinely followed by a dispute on the same\n * payment, and that is one bad payment, not two. Radar reviews are kept in\n * the ledger for the row's prose but never counted: a review is Stripe's\n * suspicion, not an issuer's report, and a busy shop can collect several\n * that clear.\n *\n * One row per window: once filed, the account is not filed again until 7\n * days have passed, so a burst is one row with everything in it rather than\n * a row per signal. NOTHING IS DONE TO THE ACCOUNT OR THE MONEY: staff\n * decide (lock the workspace, pause the account's payouts in Stripe).\n *=========================================*/\n\nexport const SELLER_FRAUD_PATTERN = {\n /** Distinct charges with an early fraud warning or a dispute. */\n minDistinctCharges: 3,\n /** …within this window. */\n windowMs: 7 * 24 * 60 * 60 * 1000,\n /** How long the ledger keeps an entry at all. */\n retainMs: 30 * 24 * 60 * 60 * 1000,\n} as const\n\nconst WINDOW_DAYS = SELLER_FRAUD_PATTERN.windowMs / 86_400_000\n\n/** Admin-SDK only; no client rule opens it (default deny). */\nexport const SELLER_FRAUD_LEDGER_COLLECTION = 'paymentFraudLedger'\n\nexport interface SellerFraudLedgerEntry {\n kind: PaymentFraudSignalKind\n stripeObjectId: string\n chargeId: string | null\n amountCents: number | null\n currency: string\n detail: string\n /** The sites a plugin recognised the charge as belonging to. */\n hostIds: string[]\n atMs: number\n}\n\n/** Whether a seller's ledger shows the pattern at `nowMs`. Pure. */\nexport function sellerFraudPattern(\n entries: readonly SellerFraudLedgerEntry[],\n nowMs: number,\n): { matched: boolean; chargeIds: string[] } {\n const since = nowMs - SELLER_FRAUD_PATTERN.windowMs\n const chargeIds = [\n ...new Set(\n entries\n .filter(\n (entry) =>\n (entry.kind === 'early-fraud-warning' || entry.kind === 'dispute') &&\n entry.atMs >= since,\n )\n .map((entry) => entry.chargeId || entry.stripeObjectId),\n ),\n ]\n return {\n matched: chargeIds.length >= SELLER_FRAUD_PATTERN.minDistinctCharges,\n chargeIds,\n }\n}\n\n/** The connected account's page in the Stripe Dashboard. */\nexport function stripeConnectedAccountUrl(\n accountId: string,\n livemode: boolean,\n): string {\n return `https://dashboard.stripe.com/${livemode ? '' : 'test/'}connect/accounts/${encodeURIComponent(accountId)}`\n}\n\n/**\n * Keep one signal on a seller's charge, and file the pattern row when the\n * ledger crosses the threshold. Idempotent on the Stripe object id: a\n * redelivery changes nothing and files nothing.\n */\nexport async function recordSellerFraudSignal(\n input: {\n signal: PaymentFraudSignal\n sellerAccountId: string\n /** The sites a plugin recognised the charge as belonging to. */\n hostIds: string[]\n /** Workspaces owning those sites, for the row. */\n orgIds: string[]\n },\n deps: {\n firestore: FirebaseFirestore.Firestore\n notifyRisk: RiskNotifier\n nowMs?: number\n },\n): Promise<{\n first: boolean\n matched: boolean\n filed: { reviewId: string; reference: string } | null\n}> {\n const nowMs = deps.nowMs ?? Date.now()\n const { signal, sellerAccountId } = input\n const ledgerRef = deps.firestore\n .collection(SELLER_FRAUD_LEDGER_COLLECTION)\n .doc(sellerAccountId)\n const outcome = await deps.firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(ledgerRef)\n const stored = (\n Array.isArray(fresh.get('entries')) ? fresh.get('entries') : []\n ) as SellerFraudLedgerEntry[]\n if (stored.some((entry) => entry.stripeObjectId === signal.stripeObjectId)) {\n return { first: false, matched: false, fileAtMs: 0, entries: stored }\n }\n const entries: SellerFraudLedgerEntry[] = [\n ...stored.filter(\n (entry) => entry.atMs >= nowMs - SELLER_FRAUD_PATTERN.retainMs,\n ),\n {\n kind: signal.kind,\n stripeObjectId: signal.stripeObjectId,\n chargeId: signal.chargeId,\n amountCents: signal.amountCents,\n currency: signal.currency,\n detail: signal.detail,\n hostIds: input.hostIds,\n atMs: nowMs,\n },\n ]\n const { matched } = sellerFraudPattern(entries, nowMs)\n const lastFiledAtMs = Number(fresh.get('lastFiledAtMs') ?? 0)\n const fileAtMs =\n matched &&\n !(lastFiledAtMs && nowMs - lastFiledAtMs < SELLER_FRAUD_PATTERN.windowMs)\n ? nowMs\n : 0\n transaction.set(\n ledgerRef,\n {\n sellerAccountId,\n entries,\n livemode: signal.livemode,\n updatedAtMs: nowMs,\n ...(fileAtMs ? { lastFiledAtMs: fileAtMs } : {}),\n },\n { merge: true },\n )\n return { first: true, matched, fileAtMs, entries }\n })\n if (!outcome.fileAtMs) {\n return { first: outcome.first, matched: outcome.matched, filed: null }\n }\n\n const { chargeIds } = sellerFraudPattern(outcome.entries, nowMs)\n const inWindow = outcome.entries.filter(\n (entry) => entry.atMs >= nowMs - SELLER_FRAUD_PATTERN.windowMs,\n )\n const reviewId = createHash('sha256')\n .update(`stripe-seller-pattern:${sellerAccountId}:${outcome.fileAtMs}`)\n .digest('hex')\n .slice(0, 40)\n const reference = paymentFraudSignalReference(reviewId)\n const orgId = input.orgIds[0] ?? null\n const hostIds = [...new Set(inWindow.flatMap((entry) => entry.hostIds ?? []))]\n const stripeUrl = stripeConnectedAccountUrl(sellerAccountId, signal.livemode)\n const details = [\n `${chargeIds.length} different charges paid to connected account ${sellerAccountId} drew an issuer fraud warning or a dispute within ${WINDOW_DAYS} days` +\n (signal.livemode ? '' : ' — TEST MODE') +\n '.',\n `Workspace(s): ${input.orgIds.length ? input.orgIds.join(', ') : 'not resolved'}. Site(s): ${hostIds.length ? hostIds.join(', ') : 'not resolved'}.`,\n ...inWindow.map(\n (entry) =>\n `• ${KIND_LABEL[entry.kind]} ${entry.stripeObjectId} on ${entry.chargeId ?? 'an unnamed charge'} · ${formatSignalAmount(entry.amountCents, entry.currency)}` +\n (entry.detail ? ` · ${entry.detail}` : ''),\n ),\n 'A shop taking stolen cards through its own storefront leaves this shape. Each merchant has already been shown the signal on its own order or booking.',\n `Nothing has been refunded, canceled or paused. If this is the seller's fraud, lock the workspace and pause the account's payouts in Stripe (${stripeUrl}), then close this row with what you did.`,\n ]\n .join('\\n')\n .slice(0, 5000)\n await deps.firestore\n .collection(ABUSE_REPORT_COLLECTION)\n .doc(reviewId)\n .set(\n {\n reference,\n // \"Phishing or fraud\" — the queue's urgent fraud category.\n category: 'phishing',\n severity: 'urgent',\n source: 'stripe-seller-fraud-pattern',\n url: null,\n reportedHostname: null,\n hostId: hostIds[0] ?? null,\n orgId,\n details,\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: 1,\n sellerPattern: {\n sellerAccountId,\n stripeAccountUrl: stripeUrl,\n chargeIds,\n orgIds: input.orgIds,\n hostIds,\n threshold: SELLER_FRAUD_PATTERN.minDistinctCharges,\n windowDays: WINDOW_DAYS,\n livemode: signal.livemode,\n },\n status: 'open',\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n // Staff get the pattern with its numbers; the owners get a plain \"we are\n // reviewing recent payments\" with the way to talk to us, never the count\n // or the window that opened the review.\n await deps\n .notifyRisk({\n kind: 'seller-review',\n orgId,\n hostId: hostIds[0] ?? null,\n reviewId,\n reference,\n item: { label: 'recent card payments on your store', path: '/org/settings/holds' },\n stripeUrl,\n staffEvidence:\n `Connected account ${sellerAccountId} drew fraud warnings or disputes on ` +\n `${chargeIds.length} different charges in ${WINDOW_DAYS} days` +\n (signal.livemode ? '' : ' — TEST MODE') +\n '.',\n })\n .catch(() => undefined)\n return { first: true, matched: true, filed: { reviewId, reference } }\n}\n"],"names":["createHash","ABUSE_REPORT_COLLECTION","FieldValue","KIND_LABEL","dispute","PAYMENT_FRAUD_SIGNAL_LABELS","paymentFraudSignalReviewId","kind","stripeObjectId","update","digest","slice","paymentFraudSignalReference","reviewId","toUpperCase","staffSubscriptionCardPath","orgId","encodeURIComponent","stripeSignalDashboardUrl","signal","base","livemode","payment","paymentIntentId","chargeId","formatSignalAmount","amountCents","currency","Number","isFinite","toFixed","String","readStripeChargeForSignal","options","secretKey","charge","card","response","fetchImpl","fetch","headers","Authorization","ok","json","text","value","customerId","checks","cvcCheck","addressPostalCodeCheck","cardCountry","riskLevel","threeDSecure","sellerAccountId","accountRef","id","startsWith","describePaymentFraudSignal","lines","detail","push","join","recordPaymentFraudSignal","deps","reference","ref","firestore","collection","doc","first","get","exists","subscriptionCard","set","category","severity","source","url","reportedHostname","hostId","details","reporterEmail","reporterName","dmca","reportCount","increment","paymentSignal","updatedAt","serverTimestamp","status","createdAt","merge","notifyRisk","item","label","path","amount","stripeUrl","staffEvidence","catch","undefined","SELLER_FRAUD_PATTERN","minDistinctCharges","windowMs","retainMs","WINDOW_DAYS","SELLER_FRAUD_LEDGER_COLLECTION","sellerFraudPattern","entries","nowMs","since","chargeIds","Set","filter","entry","atMs","map","matched","length","stripeConnectedAccountUrl","accountId","recordSellerFraudSignal","input","hostIds","Date","now","ledgerRef","outcome","runTransaction","transaction","fresh","stored","Array","isArray","some","fileAtMs","lastFiledAtMs","updatedAtMs","filed","inWindow","orgIds","flatMap","sellerPattern","stripeAccountUrl","threshold","windowDays"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/payment-fraud-signal.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * STRIPE'S FRAUD SIGNALS, INTO THE ABUSE QUEUE (AGL-3356).\n *\n * The 9/26 incident paid for Pro with a card whose CVC check came back\n * unavailable and which never met 3-D Secure. Stripe had three ways to tell\n * us about a card like that — an early fraud warning from the issuer, a\n * Radar review, a dispute — and the platform webhook either did not\n * subscribe to them or filed them where nobody triages abuse.\n *\n * Each signal now files an urgent row in the abuse queue that names the\n * workspace, the charge and the amount, links the staff Subscription card\n * on the org page (AGL-3359, where billing is canceled), and notifies staff\n * once. NOTHING IS DONE TO THE MONEY OR THE SUBSCRIPTION: no refund, no\n * cancel. An early fraud warning is not a chargeback, a review may clear, and\n * a refund issued before staff look can be the wrong half of a dispute. The\n * row is the prompt; staff decide.\n *\n * Dependencies are passed in rather than imported — the Firestore handle the\n * webhook observes its writes through, and its staff notifier — so the\n * webhook's \"did this delivery do anything\" ledger sees the write, and a spec\n * substituting the admin barrel substitutes these too.\n *=========================================*/\n\nimport { createHash } from 'crypto'\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport { RISK_PAYMENT_EVENTS } from '@aglyn/shared-util-email/risk-notice-catalog'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport type { RiskEventInput } from './risk-notice'\n\n/**\n * The seam every signal here tells people through (AGL-3368): the owners\n * and admins of the workspace, and staff. Passed in, like the Firestore\n * handle, so the webhook's spec substitutes it with the barrel.\n */\nexport type RiskNotifier = (input: RiskEventInput) => Promise<unknown>\n\n/** Which Stripe signal this is. */\nexport type PaymentFraudSignalKind =\n | 'early-fraud-warning'\n | 'radar-review'\n | 'dispute'\n\nconst KIND_LABEL: Record<PaymentFraudSignalKind, string> = {\n 'early-fraud-warning': 'Early fraud warning',\n 'radar-review': 'Radar review opened',\n dispute: 'Card dispute opened',\n}\n\n/** The Stripe object type each signal arrives as, for the queue's text. */\nexport const PAYMENT_FRAUD_SIGNAL_LABELS = KIND_LABEL\n\n/** What the card's own checks said, when the charge could be read. */\nexport interface PaymentFraudCardChecks {\n cvcCheck: string | null\n addressPostalCodeCheck: string | null\n cardCountry: string | null\n riskLevel: string | null\n threeDSecure: string | null\n}\n\nexport interface PaymentFraudSignal {\n kind: PaymentFraudSignalKind\n /** The EFW, review or dispute id: one row per signal, whatever redelivers. */\n stripeObjectId: string\n chargeId: string | null\n paymentIntentId: string | null\n /** The workspace the charge billed, or null when it resolved to none. */\n orgId: string | null\n /** Smallest currency unit, or null when nothing recorded the amount. */\n amountCents: number | null\n currency: string\n /** `fraud_type`, the review's `reason`, the dispute's `reason`. */\n detail: string\n checks: PaymentFraudCardChecks | null\n livemode: boolean\n}\n\n/** The row id: hex, so the admin route's id pattern addresses it. */\nexport function paymentFraudSignalReviewId(\n kind: PaymentFraudSignalKind,\n stripeObjectId: string,\n): string {\n return createHash('sha256')\n .update(`stripe-fraud:${kind}:${stripeObjectId}`)\n .digest('hex')\n .slice(0, 40)\n}\n\n/** The staff-facing reference, beside the intake's `AR-` and the screen's `HS-`. */\nexport function paymentFraudSignalReference(reviewId: string): string {\n return `PF-${reviewId.slice(0, 10).toUpperCase()}`\n}\n\n/** Where staff act on the workspace's billing: its Subscription card. */\nexport function staffSubscriptionCardPath(orgId: string): string {\n return `/admin/orgs/${encodeURIComponent(orgId)}#subscription`\n}\n\n/**\n * The Stripe Dashboard page for a signal: the dispute itself, else the\n * payment it is about.\n */\nexport function stripeSignalDashboardUrl(signal: {\n kind: PaymentFraudSignalKind\n stripeObjectId: string\n chargeId: string | null\n paymentIntentId: string | null\n livemode: boolean\n}): string | null {\n const base = `https://dashboard.stripe.com/${signal.livemode ? '' : 'test/'}`\n if (signal.kind === 'dispute' && signal.stripeObjectId) {\n return `${base}disputes/${encodeURIComponent(signal.stripeObjectId)}`\n }\n const payment = signal.paymentIntentId || signal.chargeId\n return payment ? `${base}payments/${encodeURIComponent(payment)}` : null\n}\n\n/** `$56.00 USD`, or a sentence saying the amount is not known. */\nexport function formatSignalAmount(\n amountCents: number | null,\n currency: string,\n): string {\n if (amountCents === null || !Number.isFinite(amountCents)) {\n return 'amount not recorded'\n }\n return `${(amountCents / 100).toFixed(2)} ${String(currency || 'usd').toUpperCase()}`\n}\n\n/**\n * The one-line summary of a charge Stripe gives back, for a signal whose\n * charge is not on a platform revenue row. A READ — the only Stripe call\n * this module makes — and it answers null rather than throwing, because a\n * fraud alert that fails for want of an amount is worse than one that says\n * the amount is unknown.\n */\nexport async function readStripeChargeForSignal(\n chargeId: string,\n options: { secretKey: string | undefined; fetchImpl?: typeof fetch },\n): Promise<{\n amountCents: number | null\n currency: string\n customerId: string | null\n checks: PaymentFraudCardChecks\n /**\n * The connected account the charge paid (AGL-3360): a destination\n * charge's `transfer_data.destination`. Every sale Aglyn takes for a site\n * is a destination charge on the platform account — the facilitator\n * model (AGL-1956) — so this is the seller. Null for a charge that paid\n * only the platform, a workspace's own subscription.\n */\n sellerAccountId: string | null\n} | null> {\n if (!chargeId || !options.secretKey) return null\n try {\n const response = await (options.fetchImpl ?? fetch)(\n `https://api.stripe.com/v1/charges/${encodeURIComponent(chargeId)}`,\n { headers: { Authorization: `Bearer ${options.secretKey}` } },\n )\n if (!response.ok) return null\n const charge = (await response.json()) as Record<string, any>\n const card = charge?.['payment_method_details']?.['card'] ?? {}\n const text = (value: unknown) =>\n typeof value === 'string' && value ? value : null\n return {\n amountCents: Number.isFinite(Number(charge?.['amount']))\n ? Number(charge['amount'])\n : null,\n currency: String(charge?.['currency'] ?? 'usd'),\n customerId: text(charge?.['customer']),\n checks: {\n cvcCheck: text(card?.['checks']?.['cvc_check']),\n addressPostalCodeCheck: text(card?.['checks']?.['address_postal_code_check']),\n cardCountry: text(card?.['country']),\n riskLevel: text(charge?.['outcome']?.['risk_level']),\n threeDSecure: text(card?.['three_d_secure']?.['result']),\n },\n sellerAccountId: accountRef(charge?.['transfer_data']?.['destination']),\n }\n } catch {\n return null\n }\n}\n\n/** A Stripe account reference, expanded or not, as an id or null. */\nfunction accountRef(value: unknown): string | null {\n const id =\n typeof value === 'string'\n ? value\n : String((value as { id?: unknown } | null)?.id ?? '')\n return id.startsWith('acct_') ? id : null\n}\n\n/** The row's prose: what arrived, about which workspace, and what to do. */\nexport function describePaymentFraudSignal(signal: PaymentFraudSignal): string {\n const lines = [\n `${KIND_LABEL[signal.kind]} from Stripe (${signal.stripeObjectId})` +\n (signal.livemode ? '' : ' — TEST MODE') +\n '.',\n `Workspace: ${signal.orgId ?? 'none — the charge did not resolve to a workspace subscription'}.`,\n `Charge: ${signal.chargeId ?? signal.paymentIntentId ?? 'not named'} · ${formatSignalAmount(signal.amountCents, signal.currency)}.`,\n ]\n if (signal.detail) lines.push(`Stripe says: ${signal.detail}.`)\n const checks = signal.checks\n if (checks) {\n lines.push(\n `Card: CVC ${checks.cvcCheck ?? 'unknown'}, postal code ${checks.addressPostalCodeCheck ?? 'unknown'}, ` +\n `issued in ${checks.cardCountry ?? 'unknown'}, 3DS ${checks.threeDSecure ?? 'not used'}, ` +\n `Radar risk ${checks.riskLevel ?? 'unknown'}.`,\n )\n }\n lines.push(\n signal.kind === 'radar-review'\n ? 'The payment waits in Stripe until the review is closed there.'\n : signal.kind === 'dispute'\n ? 'Answer the dispute in Stripe before its evidence deadline.'\n : 'An early fraud warning is not yet a chargeback; refunding now can avoid one.',\n 'Nothing has been refunded or canceled. Decide on the org’s Subscription card, lock the workspace if it is fraud, and close this row with what you did.',\n )\n return lines.join('\\n').slice(0, 5000)\n}\n\n/**\n * File one signal and tell staff, once per signal.\n *\n * Idempotent on the Stripe object id: a redelivery bumps `reportCount` and\n * leaves the row's status — which staff own from the moment they touch it —\n * where it was. Throws only if the row write throws, which is the webhook's\n * to answer; the notification never throws.\n */\nexport async function recordPaymentFraudSignal(\n signal: PaymentFraudSignal,\n deps: {\n firestore: FirebaseFirestore.Firestore\n notifyRisk: RiskNotifier\n },\n): Promise<{ reviewId: string; reference: string; first: boolean }> {\n const reviewId = paymentFraudSignalReviewId(signal.kind, signal.stripeObjectId)\n const reference = paymentFraudSignalReference(reviewId)\n const ref = deps.firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const first = !(await ref.get()).exists\n const subscriptionCard = signal.orgId\n ? staffSubscriptionCardPath(signal.orgId)\n : null\n await ref.set(\n {\n reference,\n // \"Phishing or fraud\" — the queue's urgent fraud category.\n category: 'phishing',\n severity: 'urgent',\n source: 'stripe-fraud-signal',\n url: null,\n reportedHostname: null,\n hostId: null,\n orgId: signal.orgId,\n details: describePaymentFraudSignal(signal),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: FieldValue.increment(1),\n paymentSignal: {\n kind: signal.kind,\n stripeObjectId: signal.stripeObjectId,\n chargeId: signal.chargeId,\n paymentIntentId: signal.paymentIntentId,\n amountCents: signal.amountCents,\n currency: signal.currency,\n detail: signal.detail,\n checks: signal.checks,\n livemode: signal.livemode,\n subscriptionCard,\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(first\n ? { status: 'open', createdAt: FieldValue.serverTimestamp() }\n : {}),\n },\n { merge: true },\n )\n if (first) {\n // The workspace's owners are told their subscription payment was\n // flagged and how to confirm it; staff get the row with the evidence.\n await deps\n .notifyRisk({\n kind: 'billing-payment-flagged',\n orgId: signal.orgId,\n reviewId,\n reference,\n item: { label: 'your subscription payment', path: '/org/billing/invoices' },\n amount: formatSignalAmount(signal.amountCents, signal.currency),\n // A dispute is the bank's, already opened; a warning or a review is\n // a fraud check. The notice says which.\n paymentEvent: RISK_PAYMENT_EVENTS[signal.kind] ?? null,\n stripeUrl: stripeSignalDashboardUrl(signal),\n staffEvidence:\n `${KIND_LABEL[signal.kind]} (${signal.stripeObjectId}) on charge ` +\n `${signal.chargeId ?? signal.paymentIntentId ?? 'not named'}` +\n (signal.detail ? `; Stripe says: ${signal.detail}` : '') +\n (signal.livemode ? '' : ' — TEST MODE') +\n '.',\n })\n .catch(() => undefined)\n }\n return { reviewId, reference, first }\n}\n\n/*==========================================\n * THE SELLER PATTERN: WHEN THE MERCHANT MAY BE THE FRAUDSTER (AGL-3360).\n *\n * A site's own sales are destination charges on the platform account, so\n * Stripe's fraud signals about a shopper's card arrive here too. One of them\n * is the merchant's business — their customer, their order, their decision\n * (`payment-risk-record.ts` puts it on the record and tells them). Staff are\n * not paged for it.\n *\n * What IS staff's business is the shape a phishing actor leaves when they\n * run stolen cards through a storefront of their own: several different\n * charges on ONE connected account drawing issuer fraud reports or\n * chargebacks in a short time. So every signal on a seller's charge is kept\n * in a small per-account ledger, and one urgent abuse-queue row is filed when\n *\n * ≥ 3 DISTINCT charges carry an early fraud warning or a dispute\n * within 7 days.\n *\n * Why those numbers. An early fraud warning is an issuer's report that the\n * cardholder did not make the payment, and a dispute is a formal\n * chargeback; a legitimate small merchant sees either rarely — card-network\n * dispute monitoring starts near 0.9% of a month's transactions, single\n * digits a MONTH for a shop doing hundreds of sales. One or two in a week is\n * ordinary friendly fraud and must never page staff. Three separate charges\n * in one week is a rate a real small shop does not produce, and is what a\n * card tester or a stolen-card storefront produces within days. Counted by\n * CHARGE, because a warning is routinely followed by a dispute on the same\n * payment, and that is one bad payment, not two. Radar reviews are kept in\n * the ledger for the row's prose but never counted: a review is Stripe's\n * suspicion, not an issuer's report, and a busy shop can collect several\n * that clear.\n *\n * One row per window: once filed, the account is not filed again until 7\n * days have passed, so a burst is one row with everything in it rather than\n * a row per signal. NOTHING IS DONE TO THE ACCOUNT OR THE MONEY: staff\n * decide (lock the workspace, pause the account's payouts in Stripe).\n *=========================================*/\n\nexport const SELLER_FRAUD_PATTERN = {\n /** Distinct charges with an early fraud warning or a dispute. */\n minDistinctCharges: 3,\n /** …within this window. */\n windowMs: 7 * 24 * 60 * 60 * 1000,\n /** How long the ledger keeps an entry at all. */\n retainMs: 30 * 24 * 60 * 60 * 1000,\n} as const\n\nconst WINDOW_DAYS = SELLER_FRAUD_PATTERN.windowMs / 86_400_000\n\n/** Admin-SDK only; no client rule opens it (default deny). */\nexport const SELLER_FRAUD_LEDGER_COLLECTION = 'paymentFraudLedger'\n\nexport interface SellerFraudLedgerEntry {\n kind: PaymentFraudSignalKind\n stripeObjectId: string\n chargeId: string | null\n amountCents: number | null\n currency: string\n detail: string\n /** The sites a plugin recognised the charge as belonging to. */\n hostIds: string[]\n atMs: number\n}\n\n/** Whether a seller's ledger shows the pattern at `nowMs`. Pure. */\nexport function sellerFraudPattern(\n entries: readonly SellerFraudLedgerEntry[],\n nowMs: number,\n): { matched: boolean; chargeIds: string[] } {\n const since = nowMs - SELLER_FRAUD_PATTERN.windowMs\n const chargeIds = [\n ...new Set(\n entries\n .filter(\n (entry) =>\n (entry.kind === 'early-fraud-warning' || entry.kind === 'dispute') &&\n entry.atMs >= since,\n )\n .map((entry) => entry.chargeId || entry.stripeObjectId),\n ),\n ]\n return {\n matched: chargeIds.length >= SELLER_FRAUD_PATTERN.minDistinctCharges,\n chargeIds,\n }\n}\n\n/** The connected account's page in the Stripe Dashboard. */\nexport function stripeConnectedAccountUrl(\n accountId: string,\n livemode: boolean,\n): string {\n return `https://dashboard.stripe.com/${livemode ? '' : 'test/'}connect/accounts/${encodeURIComponent(accountId)}`\n}\n\n/**\n * Keep one signal on a seller's charge, and file the pattern row when the\n * ledger crosses the threshold. Idempotent on the Stripe object id: a\n * redelivery changes nothing and files nothing.\n */\nexport async function recordSellerFraudSignal(\n input: {\n signal: PaymentFraudSignal\n sellerAccountId: string\n /** The sites a plugin recognised the charge as belonging to. */\n hostIds: string[]\n /** Workspaces owning those sites, for the row. */\n orgIds: string[]\n },\n deps: {\n firestore: FirebaseFirestore.Firestore\n notifyRisk: RiskNotifier\n nowMs?: number\n },\n): Promise<{\n first: boolean\n matched: boolean\n filed: { reviewId: string; reference: string } | null\n}> {\n const nowMs = deps.nowMs ?? Date.now()\n const { signal, sellerAccountId } = input\n const ledgerRef = deps.firestore\n .collection(SELLER_FRAUD_LEDGER_COLLECTION)\n .doc(sellerAccountId)\n const outcome = await deps.firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(ledgerRef)\n const stored = (\n Array.isArray(fresh.get('entries')) ? fresh.get('entries') : []\n ) as SellerFraudLedgerEntry[]\n if (stored.some((entry) => entry.stripeObjectId === signal.stripeObjectId)) {\n return { first: false, matched: false, fileAtMs: 0, entries: stored }\n }\n const entries: SellerFraudLedgerEntry[] = [\n ...stored.filter(\n (entry) => entry.atMs >= nowMs - SELLER_FRAUD_PATTERN.retainMs,\n ),\n {\n kind: signal.kind,\n stripeObjectId: signal.stripeObjectId,\n chargeId: signal.chargeId,\n amountCents: signal.amountCents,\n currency: signal.currency,\n detail: signal.detail,\n hostIds: input.hostIds,\n atMs: nowMs,\n },\n ]\n const { matched } = sellerFraudPattern(entries, nowMs)\n const lastFiledAtMs = Number(fresh.get('lastFiledAtMs') ?? 0)\n const fileAtMs =\n matched &&\n !(lastFiledAtMs && nowMs - lastFiledAtMs < SELLER_FRAUD_PATTERN.windowMs)\n ? nowMs\n : 0\n transaction.set(\n ledgerRef,\n {\n sellerAccountId,\n entries,\n livemode: signal.livemode,\n updatedAtMs: nowMs,\n ...(fileAtMs ? { lastFiledAtMs: fileAtMs } : {}),\n },\n { merge: true },\n )\n return { first: true, matched, fileAtMs, entries }\n })\n if (!outcome.fileAtMs) {\n return { first: outcome.first, matched: outcome.matched, filed: null }\n }\n\n const { chargeIds } = sellerFraudPattern(outcome.entries, nowMs)\n const inWindow = outcome.entries.filter(\n (entry) => entry.atMs >= nowMs - SELLER_FRAUD_PATTERN.windowMs,\n )\n const reviewId = createHash('sha256')\n .update(`stripe-seller-pattern:${sellerAccountId}:${outcome.fileAtMs}`)\n .digest('hex')\n .slice(0, 40)\n const reference = paymentFraudSignalReference(reviewId)\n const orgId = input.orgIds[0] ?? null\n const hostIds = [...new Set(inWindow.flatMap((entry) => entry.hostIds ?? []))]\n const stripeUrl = stripeConnectedAccountUrl(sellerAccountId, signal.livemode)\n const details = [\n `${chargeIds.length} different charges paid to connected account ${sellerAccountId} drew an issuer fraud warning or a dispute within ${WINDOW_DAYS} days` +\n (signal.livemode ? '' : ' — TEST MODE') +\n '.',\n `Workspace(s): ${input.orgIds.length ? input.orgIds.join(', ') : 'not resolved'}. Site(s): ${hostIds.length ? hostIds.join(', ') : 'not resolved'}.`,\n ...inWindow.map(\n (entry) =>\n `• ${KIND_LABEL[entry.kind]} ${entry.stripeObjectId} on ${entry.chargeId ?? 'an unnamed charge'} · ${formatSignalAmount(entry.amountCents, entry.currency)}` +\n (entry.detail ? ` · ${entry.detail}` : ''),\n ),\n 'A shop taking stolen cards through its own storefront leaves this shape. Each merchant has already been shown the signal on its own order or booking.',\n `Nothing has been refunded, canceled or paused. If this is the seller's fraud, lock the workspace and pause the account's payouts in Stripe (${stripeUrl}), then close this row with what you did.`,\n ]\n .join('\\n')\n .slice(0, 5000)\n await deps.firestore\n .collection(ABUSE_REPORT_COLLECTION)\n .doc(reviewId)\n .set(\n {\n reference,\n // \"Phishing or fraud\" — the queue's urgent fraud category.\n category: 'phishing',\n severity: 'urgent',\n source: 'stripe-seller-fraud-pattern',\n url: null,\n reportedHostname: null,\n hostId: hostIds[0] ?? null,\n orgId,\n details,\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: 1,\n sellerPattern: {\n sellerAccountId,\n stripeAccountUrl: stripeUrl,\n chargeIds,\n orgIds: input.orgIds,\n hostIds,\n threshold: SELLER_FRAUD_PATTERN.minDistinctCharges,\n windowDays: WINDOW_DAYS,\n livemode: signal.livemode,\n },\n status: 'open',\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n // Staff get the pattern with its numbers; the owners get a plain \"we are\n // reviewing recent payments\" with the way to talk to us, never the count\n // or the window that opened the review.\n await deps\n .notifyRisk({\n kind: 'seller-review',\n orgId,\n hostId: hostIds[0] ?? null,\n reviewId,\n reference,\n item: { label: 'recent card payments on your store', path: '/org/settings/holds' },\n stripeUrl,\n staffEvidence:\n `Connected account ${sellerAccountId} drew fraud warnings or disputes on ` +\n `${chargeIds.length} different charges in ${WINDOW_DAYS} days` +\n (signal.livemode ? '' : ' — TEST MODE') +\n '.',\n })\n .catch(() => undefined)\n return { first: true, matched: true, filed: { reviewId, reference } }\n}\n"],"names":["createHash","ABUSE_REPORT_COLLECTION","RISK_PAYMENT_EVENTS","FieldValue","KIND_LABEL","dispute","PAYMENT_FRAUD_SIGNAL_LABELS","paymentFraudSignalReviewId","kind","stripeObjectId","update","digest","slice","paymentFraudSignalReference","reviewId","toUpperCase","staffSubscriptionCardPath","orgId","encodeURIComponent","stripeSignalDashboardUrl","signal","base","livemode","payment","paymentIntentId","chargeId","formatSignalAmount","amountCents","currency","Number","isFinite","toFixed","String","readStripeChargeForSignal","options","secretKey","charge","card","response","fetchImpl","fetch","headers","Authorization","ok","json","text","value","customerId","checks","cvcCheck","addressPostalCodeCheck","cardCountry","riskLevel","threeDSecure","sellerAccountId","accountRef","id","startsWith","describePaymentFraudSignal","lines","detail","push","join","recordPaymentFraudSignal","deps","reference","ref","firestore","collection","doc","first","get","exists","subscriptionCard","set","category","severity","source","url","reportedHostname","hostId","details","reporterEmail","reporterName","dmca","reportCount","increment","paymentSignal","updatedAt","serverTimestamp","status","createdAt","merge","notifyRisk","item","label","path","amount","paymentEvent","stripeUrl","staffEvidence","catch","undefined","SELLER_FRAUD_PATTERN","minDistinctCharges","windowMs","retainMs","WINDOW_DAYS","SELLER_FRAUD_LEDGER_COLLECTION","sellerFraudPattern","entries","nowMs","since","chargeIds","Set","filter","entry","atMs","map","matched","length","stripeConnectedAccountUrl","accountId","recordSellerFraudSignal","input","hostIds","Date","now","ledgerRef","outcome","runTransaction","transaction","fresh","stored","Array","isArray","some","fileAtMs","lastFiledAtMs","updatedAtMs","filed","inWindow","orgIds","flatMap","sellerPattern","stripeAccountUrl","threshold","windowDays"],"mappings":"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"}
@@ -55,6 +55,9 @@
55
55
  * the document the merchant and publisher surfaces already read. A card that
56
56
  * had to open a second collection would be a second, disagreeing read of the
57
57
  * same fact.
58
+ *
59
+ * Part of the Stripe Connect payment provider: a plugin reaches it through
60
+ * the contract's `applyAccountEvent`, never by importing it.
58
61
  */
59
62
  /** Which leg of the money movement failed. */
60
63
  export type ConnectPayoutFailureKind = 'payout' | 'transfer';
@@ -35,6 +35,38 @@ function text(value) {
35
35
  const source = object;
36
36
  return (_ref = (_text = text(source == null ? void 0 : source.failure_message)) != null ? _text : text(source == null ? void 0 : source.failure_code)) != null ? _ref : 'Stripe did not say why';
37
37
  }
38
+ /**
39
+ * The workspaces behind the profiles that bind a connected account, by name,
40
+ * so the operator alert says whose money it was and not only `acct_…`
41
+ * (AGL-3432).
42
+ *
43
+ * A profile is keyed by the workspace it belongs to, or by the person who
44
+ * owns the workspaces it sells for; this tries the first and falls back to
45
+ * the second, so it needs to know no plugin's collection. Taken only on a
46
+ * failure, at most three profiles and three workspaces each, and best-effort:
47
+ * a read that fails names nobody rather than holding the alert.
48
+ */ async function boundWorkspaceNames(firestore, profileIds) {
49
+ const names = new Set();
50
+ const keep = (name)=>{
51
+ if (typeof name === 'string' && name.trim()) names.add(name.trim());
52
+ };
53
+ try {
54
+ for (const id of profileIds.slice(0, 3)){
55
+ const org = await firestore.collection('orgs').doc(id).get();
56
+ if (org.exists) {
57
+ keep(org.get('name'));
58
+ continue;
59
+ }
60
+ const owned = await firestore.collection('orgs').where('ownerUid', '==', id).limit(3).get();
61
+ for (const owner of owned.docs)keep(owner.get('name'));
62
+ }
63
+ } catch (unused) {
64
+ // The alert names the account id alone.
65
+ }
66
+ return [
67
+ ...names
68
+ ].join(', ');
69
+ }
38
70
  /**
39
71
  * Records one failed payout or transfer, and mirrors it onto the profiles in
40
72
  * `collection` that bind the account.
@@ -85,7 +117,9 @@ function text(value) {
85
117
  kind: input.kind,
86
118
  stripeId,
87
119
  account: accountId,
88
- reason,
120
+ merchant: await boundWorkspaceNames(firestore, matches.docs.map((doc)=>doc.id)),
121
+ // The template ends the sentence; Stripe's message usually already does.
122
+ reason: reason.replace(/[.\s]+$/, ''),
89
123
  amount: formatOperatorAlertAmount(amountCents, (_text1 = text((_input_object5 = input.object) == null ? void 0 : _input_object5.currency)) != null ? _text1 : 'usd')
90
124
  }
91
125
  });
@@ -139,4 +173,4 @@ function text(value) {
139
173
  return cleared;
140
174
  }
141
175
 
142
- //# sourceMappingURL=connect-payout-failure.js.map
176
+ //# sourceMappingURL=payment-provider-stripe-connect-payouts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/payment-provider-stripe-connect-payouts.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 { formatOperatorAlertAmount } from '@aglyn/aglyn/app-utils/operator-alerts'\nimport { firebaseAdmin } from './firebase-admin'\nimport { updateExisting } from './update-existing'\n\n/**\n * A PAYOUT THAT NEVER LANDED, RECORDED.\n *\n * ## What was invisible, and to whom\n *\n * Nothing in this codebase handled `payout.failed` or `transfer.failed`. The\n * consequences ran in opposite directions on the two sides of the same money:\n *\n * - **The merchant** saw a storefront that looked healthy — orders settling,\n * the payments card reporting enabled — while the funds accumulated in a\n * Connect account that could not release them. Aglyn stored no balance and\n * no payout schedule, so nothing in the console could contradict that.\n * - **The publisher** saw a seller ledger whose payout figure is derived from\n * `transferCents`, which is what Stripe was ASKED to move. A failed payout\n * leaves that number untouched, so the ledger actively asserted money had\n * arrived when it had not.\n *\n * And Aglyn held no record at all, so there was nothing to reconcile against,\n * no history, and no way to answer \"has this happened to this account before\".\n *\n * ## Recorded, surfaced — never retried\n *\n * This module only writes things down. It does NOT re-issue a payout or a\n * transfer, deliberately: Stripe runs its own retry schedule, and a second\n * transfer against an account that has just refused one is how a duplicate\n * lands. A human decides what happens next, which is the same posture the\n * refund-reversal recovery queue takes for the money Stripe refuses to pull\n * back — the sibling family of problem.\n *\n * ## Two records, because they answer different questions\n *\n * `connectPayoutFailures/{stripeId}` is the durable HISTORY, keyed by the\n * Stripe id so a redelivery converges rather than duplicating. It is written\n * even when no profile in the named collection binds the account, because\n * \"money did not arrive and we cannot say whose\" is precisely the state that\n * must not be silent.\n *\n * The profile documents get a MIRROR of the latest failure, because that is\n * the document the merchant and publisher surfaces already read. A card that\n * had to open a second collection would be a second, disagreeing read of the\n * same fact.\n *\n * Part of the Stripe Connect payment provider: a plugin reaches it through\n * the contract's `applyAccountEvent`, never by importing it.\n */\n\n/** Which leg of the money movement failed. */\nexport type ConnectPayoutFailureKind = 'payout' | 'transfer'\n\nexport interface ConnectPayoutFailureInput {\n /**\n * `payout` is the connected account's balance reaching its bank, and its\n * account id comes from `event.account` — the Payout object's own\n * `destination` is the BANK, not the Connect account. `transfer` is the\n * platform's balance reaching the connected account, a platform event whose\n * `destination` IS the account.\n */\n kind: ConnectPayoutFailureKind\n /** `event.data.object` — the Payout or Transfer. */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n object: any\n /** The connected account the money was headed for. */\n accountId: string\n /** `event.livemode`. */\n livemode?: unknown\n}\n\nfunction cents(value: unknown): number {\n const amount = Math.round(Number(value ?? 0))\n return Number.isFinite(amount) ? amount : 0\n}\n\nfunction text(value: unknown): string | null {\n return typeof value === 'string' && value.trim() ? value.trim() : null\n}\n\n/**\n * The sentence a merchant reads. Stripe's `failure_message` is written for a\n * developer and is often absent, so the code is kept beside it and a missing\n * pair still produces something a human can act on rather than an empty\n * warning that looks like a rendering fault.\n */\nexport function payoutFailureReason(object: unknown): string {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const source = object as any\n return (\n text(source?.failure_message) ??\n text(source?.failure_code) ??\n 'Stripe did not say why'\n )\n}\n\n/**\n * The workspaces behind the profiles that bind a connected account, by name,\n * so the operator alert says whose money it was and not only `acct_…`\n * (AGL-3432).\n *\n * A profile is keyed by the workspace it belongs to, or by the person who\n * owns the workspaces it sells for; this tries the first and falls back to\n * the second, so it needs to know no plugin's collection. Taken only on a\n * failure, at most three profiles and three workspaces each, and best-effort:\n * a read that fails names nobody rather than holding the alert.\n */\nasync function boundWorkspaceNames(\n firestore: FirebaseFirestore.Firestore,\n profileIds: readonly string[],\n): Promise<string> {\n const names = new Set<string>()\n const keep = (name: unknown) => {\n if (typeof name === 'string' && name.trim()) names.add(name.trim())\n }\n try {\n for (const id of profileIds.slice(0, 3)) {\n const org = await firestore.collection('orgs').doc(id).get()\n if (org.exists) {\n keep(org.get('name'))\n continue\n }\n const owned = await firestore\n .collection('orgs')\n .where('ownerUid', '==', id)\n .limit(3)\n .get()\n for (const owner of owned.docs) keep(owner.get('name'))\n }\n } catch {\n // The alert names the account id alone.\n }\n return [...names].join(', ')\n}\n\n/**\n * Records one failed payout or transfer, and mirrors it onto the profiles in\n * `collection` that bind the account.\n *\n * @returns how many profile documents were mirrored onto. `0` is an ordinary\n * answer — the other plugin's collection binds this account, or nothing\n * does — and never means the failure went unrecorded.\n */\nexport async function recordConnectPayoutFailure(\n collection: string,\n input: ConnectPayoutFailureInput,\n): Promise<number> {\n const accountId = String(input?.accountId ?? '').trim()\n const stripeId = text(input?.object?.id)\n if (!accountId || !stripeId) return 0\n const firestore = firebaseAdmin.app().firestore()\n const amountCents = cents(input.object?.amount)\n const reason = payoutFailureReason(input.object)\n const failedAtMs = Date.now()\n\n const matches = await firestore\n .collection(collection)\n .where('stripeAccountId', '==', accountId)\n .get()\n\n // The durable record FIRST, and unconditionally. An account no profile binds\n // is the case with the least visibility and the most need of a written\n // trace; making the history conditional on a successful join would lose\n // exactly the failures nobody could otherwise explain.\n await firestore\n .collection('connectPayoutFailures')\n .doc(stripeId)\n .set(\n {\n kind: input.kind,\n accountId,\n amountCents,\n currency: text(input.object?.currency) ?? 'usd',\n failureCode: text(input.object?.failure_code),\n failureMessage: text(input.object?.failure_message),\n reason,\n failedAtMs,\n ...(typeof input.livemode === 'boolean'\n ? { livemode: input.livemode }\n : {}),\n // A lead a human can search, accumulated across both plugins: the same\n // account can be bound by a storefront profile and a publisher profile,\n // and each plugin records only its own.\n ...(matches.docs.length\n ? {\n boundProfiles: firebaseAdmin.firestore.FieldValue.arrayUnion(\n ...matches.docs.map((doc) => `${collection}/${doc.id}`),\n ),\n }\n : {}),\n recordedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n // Money due to a merchant or seller did not arrive, and a human decides\n // what happens next (AGL-3377). Loaded lazily, like everything this module\n // only needs once a failure is real.\n const { raiseOperatorAlert } = await import('./operator-alerts')\n await raiseOperatorAlert('billing.connectPayoutFailed', {\n dedupeKey: stripeId,\n context: {\n kind: input.kind,\n stripeId,\n account: accountId,\n merchant: await boundWorkspaceNames(\n firestore,\n matches.docs.map((doc) => doc.id),\n ),\n // The template ends the sentence; Stripe's message usually already does.\n reason: reason.replace(/[.\\s]+$/, ''),\n amount: formatOperatorAlertAmount(amountCents, text(input.object?.currency) ?? 'usd'),\n },\n })\n\n // `updateExisting` rather than a merge-set, for the reason its sibling\n // states: the refs come from a query, and a merge-set would resurrect a\n // profile erased in between as a stub holding nothing but a payout warning.\n let mirrored = 0\n for (const doc of matches.docs) {\n if (\n await updateExisting(doc.ref, {\n lastPayoutFailureId: stripeId,\n lastPayoutFailureKind: input.kind,\n lastPayoutFailureAtMs: failedAtMs,\n lastPayoutFailureCents: amountCents,\n lastPayoutFailureReason: reason,\n })\n ) {\n mirrored += 1\n }\n }\n return mirrored\n}\n\n/**\n * Clears the mirrored warning when a later payout to the same account\n * succeeds.\n *\n * NOT a retry and not a correction of the history: `connectPayoutFailures`\n * keeps every failure, because \"has this account failed before\" is a question\n * the record exists to answer. This only stops the merchant's card asserting a\n * problem that has since resolved — a stale warning trains people to ignore\n * the surface, which costs more than it saves.\n */\nexport async function clearConnectPayoutFailure(\n collection: string,\n accountId: string,\n): Promise<number> {\n const id = String(accountId ?? '').trim()\n if (!id) return 0\n const firestore = firebaseAdmin.app().firestore()\n const matches = await firestore\n .collection(collection)\n .where('stripeAccountId', '==', id)\n .get()\n let cleared = 0\n for (const doc of matches.docs) {\n // Only touch a profile that actually carries a warning, so an ordinary\n // successful payout does not write to every profile on every event.\n if (doc.get('lastPayoutFailureAtMs') == null) continue\n const remove = firebaseAdmin.firestore.FieldValue.delete()\n if (\n await updateExisting(doc.ref, {\n lastPayoutFailureId: remove,\n lastPayoutFailureKind: remove,\n lastPayoutFailureAtMs: remove,\n lastPayoutFailureCents: remove,\n lastPayoutFailureReason: remove,\n })\n ) {\n cleared += 1\n }\n }\n return cleared\n}\n"],"names":["formatOperatorAlertAmount","firebaseAdmin","updateExisting","cents","value","amount","Math","round","Number","isFinite","text","trim","payoutFailureReason","object","source","failure_message","failure_code","boundWorkspaceNames","firestore","profileIds","names","Set","keep","name","add","id","slice","org","collection","doc","get","exists","owned","where","limit","owner","docs","join","recordConnectPayoutFailure","input","accountId","String","stripeId","app","amountCents","reason","failedAtMs","Date","now","matches","set","kind","currency","failureCode","failureMessage","livemode","length","boundProfiles","FieldValue","arrayUnion","map","recordedAt","serverTimestamp","merge","raiseOperatorAlert","dedupeKey","context","account","merchant","replace","mirrored","ref","lastPayoutFailureId","lastPayoutFailureKind","lastPayoutFailureAtMs","lastPayoutFailureCents","lastPayoutFailureReason","clearConnectPayoutFailure","cleared","remove","delete"],"mappings":"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"}
@@ -47,6 +47,9 @@
47
47
  * IDEMPOTENT AND ORDER-INDEPENDENT by construction — it mirrors current state,
48
48
  * never a delta — so a Stripe redelivery and an out-of-order pair both
49
49
  * converge.
50
+ *
51
+ * Part of the Stripe Connect payment provider: a plugin reaches it through
52
+ * the contract's `applyAccountEvent`, never by importing it.
50
53
  */
51
54
  export interface ConnectAccountStatusEvent {
52
55
  /** `event.data.object` for `account.updated` — the Stripe Account. */
@@ -91,4 +91,4 @@ import { updateExisting } from "./update-existing.js";
91
91
  return updated;
92
92
  }
93
93
 
94
- //# sourceMappingURL=connect-account-status.js.map
94
+ //# sourceMappingURL=payment-provider-stripe-connect-status.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/payment-provider-stripe-connect-status.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'\nimport { sendGa4StripeConnected } from './ga4-measurement-protocol'\nimport { updateExisting } from './update-existing'\n\n/**\n * Connect readiness, refreshed from Stripe rather than from a cached flag\n * (AGL-1997).\n *\n * `stripeChargesEnabled` is the field every money route gates on — commerce\n * checkout, cart checkout, draft orders, reservations, POS — and until this it\n * was refreshed ONLY when the merchant happened to reopen the Connect route.\n * So an account Stripe later restricts (a verification deadline passing, a\n * capability revoked, documents going stale) kept a cached `true`, the store\n * kept selling, and the failure surfaced to the SHOPPER at payment time. The\n * merchant, who could have fixed it, was the last to know.\n *\n * `account.updated` is the event that carries this. Stripe sends it on every\n * change to a connected account, which is why the fix is a subscription and\n * not a poll.\n *\n * WHY IT WRITES ONLY WHAT STRIPE ACTUALLY SAID. Both flags are written only\n * when the payload states them as booleans. Coercing an absent field with\n * `Boolean(...)` would manufacture a `false` and lock out a merchant who is\n * fine — the over-restrictive direction, but still a wrong answer invented\n * from no evidence — and defaulting the other way would re-create the very\n * fail-open this closes. An event that states neither flag writes nothing at\n * all; it is not an Account payload.\n *\n * WHY `updateExisting` AND NOT A MERGE-SET. The refs come from a query, so the\n * documents existed a moment ago — but a merge-set would resurrect one erased\n * in between as a stub holding two payout booleans and nothing else, the\n * AGL-1763 phantom shape. `update()` rejects instead, and a rejection here\n * costs nothing: the next `account.updated` re-delivers the same state.\n *\n * IDEMPOTENT AND ORDER-INDEPENDENT by construction — it mirrors current state,\n * never a delta — so a Stripe redelivery and an out-of-order pair both\n * converge.\n *\n * Part of the Stripe Connect payment provider: a plugin reaches it through\n * the contract's `applyAccountEvent`, never by importing it.\n */\nexport interface ConnectAccountStatusEvent {\n /** `event.data.object` for `account.updated` — the Stripe Account. */\n id?: unknown\n charges_enabled?: unknown\n payouts_enabled?: unknown\n}\n\n/**\n * Mirrors a connected account's charge/payout readiness onto whichever\n * documents in `collection` name it in `stripeAccountId`.\n *\n * @param eventLivemode `event.livemode` from the enclosing `account.updated`\n * event (AGL-2471) — NOT `event.data.object.livemode`, which Stripe's\n * Account object does not have. Anything other than a boolean writes\n * nothing, leaving the field absent, which every money gate reads as\n * unverified and refuses.\n * @param collection Firestore collection holding the payout binding —\n * `profiles` for storefront merchants, `publisherProfiles` for marketplace\n * publishers. Each plugin syncs its own; a non-matching account updates\n * nothing.\n * @returns the number of documents updated. 0 is the ordinary answer for an\n * account this collection does not bind, and the caller should not treat it\n * as a failure.\n */\nexport async function syncConnectAccountStatus(\n collection: string,\n account: ConnectAccountStatusEvent | null | undefined,\n eventLivemode?: unknown,\n): Promise<number> {\n const accountId = typeof account?.id === 'string' ? account.id.trim() : ''\n if (!accountId) return 0\n const patch: Record<string, boolean> = {}\n if (typeof account?.charges_enabled === 'boolean') {\n patch['stripeChargesEnabled'] = account.charges_enabled\n }\n if (typeof account?.payouts_enabled === 'boolean') {\n patch['stripePayoutsEnabled'] = account.payouts_enabled\n }\n // WHICH STRIPE WORLD (AGL-2471). The Account object has no `livemode`, but\n // the EVENT carrying it does, and it is Stripe's own statement about the\n // account being described — the strongest evidence available, and it\n // arrives on every change. This is what heals a linkage whose mode was\n // never recorded, without anyone touching the database by hand.\n //\n // Same doctrine as the two flags above: only a literal boolean is written.\n if (typeof eventLivemode === 'boolean') {\n patch['stripeAccountLivemode'] = eventLivemode\n }\n // Nothing stated, nothing written — see the doc comment.\n if (!Object.keys(patch).length) return 0\n\n const matches = await firebaseAdmin\n .app()\n .firestore()\n .collection(collection)\n .where('stripeAccountId', '==', accountId)\n .get()\n let updated = 0\n for (const doc of matches.docs) {\n // THE ACTIVATION TRANSITION, read BEFORE the write (AGL-1580).\n //\n // `stripe_connected` is one of the launch key events, and its two\n // client-side emitters are gated on the merchant's profile still reading\n // \"not connected\" when they click. This webhook is what makes that false:\n // it lands while the merchant is still on Stripe's hosted onboarding, so\n // the flag has already flipped by the time they get back and the browser\n // event can never fire. See `sendGa4StripeConnected` for why the two\n // guards cannot both open for the same account.\n //\n // `!== true` and not `=== false`: a profile that has never been asked has\n // the field ABSENT, and that is the commonest first-connect shape of all.\n const becameConnected =\n patch['stripeChargesEnabled'] === true &&\n doc.get('stripeChargesEnabled') !== true\n // `updateExisting` rethrows anything that is not \"the document is gone\",\n // so a permission or transport failure still reaches the webhook and gets\n // the redelivery it needs.\n if (await updateExisting(doc.ref, patch)) {\n updated += 1\n // Reported only after the write LANDED. A document erased between the\n // query and the update is not an activation, and reporting one would\n // put a merchant in the metric who no longer exists.\n //\n // Awaited, not floated: this runs inside the Stripe webhook's claim on\n // the event, and a floated promise in a serverless handler is frozen at\n // response and never runs — the AGL-2346 shape. The sender swallows\n // everything and cannot un-claim the event by throwing.\n if (becameConnected) await sendGa4StripeConnected({ accountId })\n }\n }\n return updated\n}\n"],"names":["firebaseAdmin","sendGa4StripeConnected","updateExisting","syncConnectAccountStatus","collection","account","eventLivemode","accountId","id","trim","patch","charges_enabled","payouts_enabled","Object","keys","length","matches","app","firestore","where","get","updated","doc","docs","becameConnected","ref"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,aAAa,QAAQ,sBAAkB;AAChD,SAASC,sBAAsB,QAAQ,gCAA4B;AACnE,SAASC,cAAc,QAAQ,uBAAmB;AA8ClD;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,eAAeC,yBACpBC,UAAkB,EAClBC,OAAqD,EACrDC,aAAuB;IAEvB,MAAMC,YAAY,QAAOF,2BAAAA,QAASG,EAAE,MAAK,WAAWH,QAAQG,EAAE,CAACC,IAAI,KAAK;IACxE,IAAI,CAACF,WAAW,OAAO;IACvB,MAAMG,QAAiC,CAAC;IACxC,IAAI,QAAOL,2BAAAA,QAASM,eAAe,MAAK,WAAW;QACjDD,KAAK,CAAC,uBAAuB,GAAGL,QAAQM,eAAe;IACzD;IACA,IAAI,QAAON,2BAAAA,QAASO,eAAe,MAAK,WAAW;QACjDF,KAAK,CAAC,uBAAuB,GAAGL,QAAQO,eAAe;IACzD;IACA,2EAA2E;IAC3E,yEAAyE;IACzE,qEAAqE;IACrE,uEAAuE;IACvE,gEAAgE;IAChE,EAAE;IACF,2EAA2E;IAC3E,IAAI,OAAON,kBAAkB,WAAW;QACtCI,KAAK,CAAC,wBAAwB,GAAGJ;IACnC;IACA,yDAAyD;IACzD,IAAI,CAACO,OAAOC,IAAI,CAACJ,OAAOK,MAAM,EAAE,OAAO;IAEvC,MAAMC,UAAU,MAAMhB,cACnBiB,GAAG,GACHC,SAAS,GACTd,UAAU,CAACA,YACXe,KAAK,CAAC,mBAAmB,MAAMZ,WAC/Ba,GAAG;IACN,IAAIC,UAAU;IACd,KAAK,MAAMC,OAAON,QAAQO,IAAI,CAAE;QAC9B,+DAA+D;QAC/D,EAAE;QACF,kEAAkE;QAClE,yEAAyE;QACzE,0EAA0E;QAC1E,yEAAyE;QACzE,yEAAyE;QACzE,qEAAqE;QACrE,gDAAgD;QAChD,EAAE;QACF,0EAA0E;QAC1E,0EAA0E;QAC1E,MAAMC,kBACJd,KAAK,CAAC,uBAAuB,KAAK,QAClCY,IAAIF,GAAG,CAAC,4BAA4B;QACtC,yEAAyE;QACzE,0EAA0E;QAC1E,2BAA2B;QAC3B,IAAI,MAAMlB,eAAeoB,IAAIG,GAAG,EAAEf,QAAQ;YACxCW,WAAW;YACX,sEAAsE;YACtE,qEAAqE;YACrE,qDAAqD;YACrD,EAAE;YACF,uEAAuE;YACvE,wEAAwE;YACxE,oEAAoE;YACpE,wDAAwD;YACxD,IAAIG,iBAAiB,MAAMvB,uBAAuB;gBAAEM;YAAU;QAChE;IACF;IACA,OAAOc;AACT"}
@@ -14,8 +14,19 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
+ import type { PaymentMode, PaymentProvider, PaymentProviderEvent } from './payment-provider';
17
18
  /**
18
- * Whether a stored Connect linkage can actually take money HERE (AGL-2471).
19
+ * The Stripe Connect adapter behind the payment-provider contract
20
+ * (`payment-provider.ts`): a tenant's merchant account is a Stripe Connect
21
+ * account, charged and paid out through the platform's Stripe key.
22
+ *
23
+ * This module is the part that is Stripe's — which mode a key states, how the
24
+ * API is asked, which webhook events describe a connected account and where
25
+ * each one keeps the account id. What an event WRITES lives in the two modules
26
+ * it dispatches to, loaded only when such an event arrives, so the money doors
27
+ * that import the contract for its readiness rule load no Firebase.
28
+ *
29
+ * ## Whether a stored Connect linkage can actually take money HERE (AGL-2471)
19
30
  *
20
31
  * THE DEFECT. Production Firestore held three Connect linkages and all three
21
32
  * named TEST-mode accounts. One — `profiles/7AVEMtDa…`, `stripeAccountId:
@@ -50,20 +61,10 @@
50
61
  * (above), so the account's mode IS the key's mode; the key's mode is
51
62
  * then confirmed against the API via `resolvePlatformStripeMode`.
52
63
  *
53
- * WHY THE RULE IS ASYMMETRIC, AND NOT "REFUSE ANYTHING UNPROVEN". An absent
54
- * `stripeAccountLivemode` refuses only on a LIVE deployment. That is where
55
- * real money moves and where the defect was found, and a live deployment can
56
- * always re-establish the field: the merchant reconnects, or Stripe sends one
57
- * `account.updated`. Everywhere else — a test-key deployment, a developer
58
- * machine, a self-hosted install still in sandbox, the whole test suite — an
59
- * unverified linkage keeps the behaviour it had before, because no real money
60
- * can move there and Stripe enforces the mode boundary itself. Refusing
61
- * everywhere would have bought production nothing extra and broken every
62
- * non-production deployment.
63
- *
64
- * A PROVEN mismatch (`stripeAccountLivemode` recorded and disagreeing) is
65
- * refused in BOTH directions, because that costs nothing to detect and is
66
- * never right.
64
+ * The profile field is `stripeAccountLivemode`, and the rule that reads it —
65
+ * refused on a live deployment when absent, refused in both directions when
66
+ * it disagrees — is the contract's `merchantAccountReadiness`, which says why
67
+ * it is asymmetric.
67
68
  *
68
69
  * WHAT IS SNIFFED, AND WHY THAT ONE IS FAIR. The SECRET KEY states its own
69
70
  * mode — `sk_live_`, `sk_test_`, `rk_live_`, `rk_test_` — and that is a
@@ -80,40 +81,6 @@
80
81
  * change — the split key is the right call — so the gate has to be the thing
81
82
  * that notices.
82
83
  */
83
- /** Which Stripe world a key or an account belongs to. */
84
- export type StripeMode = 'live' | 'test';
85
- /**
86
- * Why a stored linkage may not be charged against. Every value other than
87
- * `'ready'` is a refusal, and the mode ones are refusals the old two-field
88
- * test could not make.
89
- */
90
- export type ConnectReadiness =
91
- /** Charge away. */
92
- 'ready'
93
- /** No Connect account is stored at all — the onboarding call to action. */
94
- | 'not-connected'
95
- /** Stripe will not let this account take charges. */
96
- | 'charges-disabled'
97
- /**
98
- * The linkage predates AGL-2471 (or was written by something that did not
99
- * record mode), so its mode was never established — AND this deployment is
100
- * LIVE. This is the shape all three poisoned production records have.
101
- * The connect route and the `account.updated` sync both write the field, so
102
- * a genuine merchant self-heals on the next of either.
103
- */
104
- | 'mode-unverified'
105
- /** The account belongs to the OTHER Stripe world. The AGL-2471 defect. */
106
- | 'mode-mismatch';
107
- export interface ConnectReadinessInput {
108
- /** `stripeAccountId` off the profile, whatever shape the document has. */
109
- accountId?: unknown;
110
- /** `stripeChargesEnabled` off the profile. */
111
- chargesEnabled?: unknown;
112
- /** `stripeAccountLivemode` off the profile — absent before AGL-2471. */
113
- accountLivemode?: unknown;
114
- /** Defaults to the mode of `STRIPE_SECRET_KEY`. */
115
- platformMode?: StripeMode | undefined;
116
- }
117
84
  /**
118
85
  * The mode a Stripe secret key states about itself, or `undefined` when the
119
86
  * string is not a Stripe secret key.
@@ -122,7 +89,7 @@ export interface ConnectReadinessInput {
122
89
  * refuses everything else rather than guessing. Handing it an `acct_…` id
123
90
  * returns `undefined` — the whole point is that account ids say nothing.
124
91
  */
125
- export declare function platformStripeMode(key?: string | undefined): StripeMode | undefined;
92
+ export declare function platformStripeMode(key?: string | undefined): PaymentMode | undefined;
126
93
  /**
127
94
  * The platform's mode, asked of Stripe and only then of the key.
128
95
  *
@@ -132,25 +99,33 @@ export declare function platformStripeMode(key?: string | undefined): StripeMode
132
99
  * balance; without it, a self-hosted install with a scoped key could not
133
100
  * finish onboarding at all.
134
101
  */
135
- export declare function resolvePlatformStripeMode(key?: string | undefined, fetchImpl?: typeof fetch): Promise<StripeMode | undefined>;
102
+ export declare function resolvePlatformStripeMode(key?: string | undefined, fetchImpl?: typeof fetch): Promise<PaymentMode | undefined>;
136
103
  /**
137
- * Decides whether a stored Connect linkage may be charged against.
138
- *
139
- * Order matters: the two refusals that existed before AGL-2471 are asked
140
- * first, so an unconnected or restricted merchant still gets the answer they
141
- * always got, and only a linkage that WOULD have passed reaches the mode
142
- * question.
143
- */
144
- export declare function connectReadiness(input: ConnectReadinessInput): ConnectReadiness;
145
- /**
146
- * The form every money door calls: `true` only when the linkage may be
147
- * charged, with the two mode refusals reported to the server log.
148
- *
149
- * The log line is the part the shopper's generic 409 cannot carry. Before
150
- * this, a mode mismatch surfaced as a 502 from Stripe with nothing anywhere
151
- * naming the cause; the sale is still refused the same way, but now somebody
152
- * can find out why.
104
+ * A Stripe webhook event about a connected account, applied to the profiles in
105
+ * `collection` that store it — the contract's `applyAccountEvent`.
106
+ *
107
+ * Three events describe a connected account, and each keeps the account id
108
+ * somewhere different:
109
+ *
110
+ * - `account.updated` — its readiness changed (AGL-1997). The object IS the
111
+ * Account, and the EVENT carries `livemode` (AGL-2471): the Account object
112
+ * has no such field, and the event's is what lets a linkage whose mode was
113
+ * never recorded heal itself instead of staying refused forever.
114
+ * - `payout.failed` / `transfer.failed` — money headed for the account never
115
+ * landed. A payout is the connected account's balance failing to reach its
116
+ * bank, so its account id is `event.account`: the Payout's own
117
+ * `destination` names the BANK. A transfer is the platform's balance
118
+ * failing to reach the connected account, a platform event whose
119
+ * `destination` IS the account. Recorded and surfaced, never retried:
120
+ * Stripe runs its own retry schedule, and a second transfer against an
121
+ * account that has just refused one is how a duplicate lands.
122
+ * - `payout.paid` — a later payout landed, which retires the warning the
123
+ * failure left on the profile. The history is kept.
124
+ *
125
+ * Every other event answers `false` without loading anything. A write that
126
+ * fails throws, and the webhook's 500 earns the redelivery; every write
127
+ * mirrors current state or is keyed by Stripe's id, so a redelivery converges.
153
128
  */
154
- export declare function connectLinkageIsReady(input: ConnectReadinessInput, context?: {
155
- subject?: string;
156
- }): boolean;
129
+ export declare function applyStripeConnectAccountEvent(collection: string, delivered: PaymentProviderEvent): Promise<boolean>;
130
+ /** The adapter the contract returns. */
131
+ export declare const STRIPE_CONNECT_PAYMENT_PROVIDER: PaymentProvider;