@aglyn/tenant-data-admin 1.0.0-beta.219 → 1.0.0-beta.220
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -8
- package/src/lib/server/api-http.js +5 -1
- package/src/lib/server/api-http.js.map +1 -1
- package/src/lib/server/api-v1-kit.d.ts +7 -0
- package/src/lib/server/api-v1-kit.js.map +1 -1
- package/src/lib/server/api-v1-resources.d.ts +1 -2
- package/src/lib/server/api-v1-resources.js +0 -1
- package/src/lib/server/api-v1-resources.js.map +1 -1
- package/src/lib/server/campaign-conversion-attribution.d.ts +1 -1
- package/src/lib/server/campaign-conversion-attribution.js +3 -3
- package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
- package/src/lib/server/card-payment-velocity.d.ts +9 -0
- package/src/lib/server/card-payment-velocity.js +28 -2
- package/src/lib/server/card-payment-velocity.js.map +1 -1
- package/src/lib/server/consent-group-carry.js +1 -1
- package/src/lib/server/consent-group-carry.js.map +1 -1
- package/src/lib/server/consent-group-change.js +2 -2
- package/src/lib/server/consent-group-change.js.map +1 -1
- package/src/lib/server/dynamic-list-materialize.js +3 -3
- package/src/lib/server/dynamic-list-materialize.js.map +1 -1
- package/src/lib/server/email-flow-gate.js +2 -2
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/email-suppression.js +1 -1
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +32 -4
- package/src/lib/server/host-visitor-records.js +73 -14
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.d.ts +27 -0
- package/src/lib/server/hosted-page-review.js +149 -12
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/media-cdn-rate-limit.d.ts +6 -3
- package/src/lib/server/media-cdn-rate-limit.js +6 -3
- package/src/lib/server/media-cdn-rate-limit.js.map +1 -1
- package/src/lib/server/member-email-aliases.d.ts +9 -1
- package/src/lib/server/member-email-aliases.js +25 -1
- package/src/lib/server/member-email-aliases.js.map +1 -1
- package/src/lib/server/notifications.d.ts +14 -0
- package/src/lib/server/notifications.js +29 -0
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/operator-alerts.d.ts +7 -1
- package/src/lib/server/operator-alerts.js +5 -3
- package/src/lib/server/operator-alerts.js.map +1 -1
- package/src/lib/server/org-containers.d.ts +66 -0
- package/src/lib/server/org-containers.js +95 -0
- package/src/lib/server/org-containers.js.map +1 -0
- package/src/lib/server/org-member-notice.js +3 -2
- package/src/lib/server/org-member-notice.js.map +1 -1
- package/src/lib/server/payment-fraud-signal.js +5 -1
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/payment-provider-stripe-connect-payouts.js +35 -1
- package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -1
- package/src/lib/server/payment-risk-record.d.ts +1 -1
- package/src/lib/server/payment-risk-record.js +3 -1
- package/src/lib/server/payment-risk-record.js.map +1 -1
- package/src/lib/server/platform-marketing-consent.d.ts +1 -1
- package/src/lib/server/platform-marketing-consent.js +1 -1
- package/src/lib/server/platform-marketing-consent.js.map +1 -1
- package/src/lib/server/risk-notice.d.ts +32 -1
- package/src/lib/server/risk-notice.js +90 -28
- package/src/lib/server/risk-notice.js.map +1 -1
- package/src/lib/server/sending-domains.d.ts +17 -0
- package/src/lib/server/sending-domains.js +28 -4
- package/src/lib/server/sending-domains.js.map +1 -1
- package/src/lib/server/sso-enforcement.js +7 -2
- package/src/lib/server/sso-enforcement.js.map +1 -1
- package/src/lib/server/staff-alert-email.js +4 -2
- package/src/lib/server/staff-alert-email.js.map +1 -1
- package/src/lib/server/{email-topic-confirmation.js → topic-subscriptions.js} +15 -11
- package/src/lib/server/topic-subscriptions.js.map +1 -0
- package/src/lib/server/upsert-contact.js +2 -2
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +1 -3
- package/src/tenancy.js +1 -7
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/contact-email-engagement.d.ts +0 -32
- package/src/lib/server/contact-email-engagement.js +0 -145
- package/src/lib/server/contact-email-engagement.js.map +0 -1
- package/src/lib/server/crm-booking-activity.d.ts +0 -86
- package/src/lib/server/crm-booking-activity.js +0 -257
- package/src/lib/server/crm-booking-activity.js.map +0 -1
- package/src/lib/server/crm-inbound-email.d.ts +0 -168
- package/src/lib/server/crm-inbound-email.js +0 -282
- package/src/lib/server/crm-inbound-email.js.map +0 -1
- package/src/lib/server/email-topic-confirmation.js.map +0 -1
- /package/src/lib/server/{email-topic-confirmation.d.ts → topic-subscriptions.d.ts} +0 -0
|
@@ -0,0 +1,95 @@
|
|
|
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 { pluginContainerKind } from "@aglyn/aglyn/plugin-manager/plugin-containers";
|
|
17
|
+
import { isDocumentId } from "./document-id.js";
|
|
18
|
+
function recordOf(id, snapshot, nameField) {
|
|
19
|
+
var _snapshot_get;
|
|
20
|
+
if (!(snapshot == null ? void 0 : snapshot.exists)) return {
|
|
21
|
+
id,
|
|
22
|
+
exists: false,
|
|
23
|
+
live: false,
|
|
24
|
+
name: '',
|
|
25
|
+
visibleTo: []
|
|
26
|
+
};
|
|
27
|
+
const visibleTo = snapshot.get('visibleTo');
|
|
28
|
+
return {
|
|
29
|
+
id,
|
|
30
|
+
exists: true,
|
|
31
|
+
live: !snapshot.get('deletedAt'),
|
|
32
|
+
name: String((_snapshot_get = snapshot.get(nameField)) != null ? _snapshot_get : '').trim(),
|
|
33
|
+
visibleTo: Array.isArray(visibleTo) ? visibleTo.map(String) : []
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The fields a reader may rely on, and the only ones a listing reads: a
|
|
38
|
+
* container can be a large document, and nothing here needs the rest.
|
|
39
|
+
*/ const fieldsOf = (nameField)=>[
|
|
40
|
+
nameField,
|
|
41
|
+
'deletedAt',
|
|
42
|
+
'visibleTo'
|
|
43
|
+
];
|
|
44
|
+
/** The kind's collection under the org, or `null` when no plugin keeps the kind. */ function containersOf(firestore, kind, orgId) {
|
|
45
|
+
const declared = pluginContainerKind(kind);
|
|
46
|
+
if (!declared || !isDocumentId(orgId)) return null;
|
|
47
|
+
return {
|
|
48
|
+
ref: firestore.collection('orgs').doc(orgId).collection(declared.orgCollection),
|
|
49
|
+
nameField: declared.nameField
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The containers named by `ids`, one record per id in the order asked: one
|
|
54
|
+
* keyed read each, in a single `getAll`. An id that cannot be a document id
|
|
55
|
+
* is answered as gone without a read.
|
|
56
|
+
*
|
|
57
|
+
* Throws what the read throws; a caller that must not fail on it catches.
|
|
58
|
+
*/ export async function readOrgContainers(firestore, kind, orgId, ids) {
|
|
59
|
+
if (!ids.length) return [];
|
|
60
|
+
const containers = containersOf(firestore, kind, orgId);
|
|
61
|
+
const readable = ids.filter((id)=>isDocumentId(id));
|
|
62
|
+
if (!containers || !readable.length) return ids.map((id)=>recordOf(id, undefined, ''));
|
|
63
|
+
// `getAll` answers in the order asked, so each snapshot is matched to its
|
|
64
|
+
// id by position rather than by a field of the snapshot.
|
|
65
|
+
const found = await firestore.getAll(...readable.map((id)=>containers.ref.doc(id)));
|
|
66
|
+
const byId = new Map(readable.map((id, index)=>[
|
|
67
|
+
id,
|
|
68
|
+
found[index]
|
|
69
|
+
]));
|
|
70
|
+
return ids.map((id)=>recordOf(id, byId.get(id), containers.nameField));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Up to `ceiling` of the organization's containers of a kind, in document
|
|
74
|
+
* order, retired ones included and marked: a caller that matches by name
|
|
75
|
+
* decides what a retired one means to it. Read as a projection of the
|
|
76
|
+
* fields a record carries.
|
|
77
|
+
*/ export async function listOrgContainers(firestore, kind, orgId, ceiling) {
|
|
78
|
+
const containers = containersOf(firestore, kind, orgId);
|
|
79
|
+
if (!containers || ceiling <= 0) return [];
|
|
80
|
+
const snapshot = await containers.ref.select(...fieldsOf(containers.nameField)).limit(ceiling).get();
|
|
81
|
+
return snapshot.docs.map((doc)=>recordOf(doc.id, doc, containers.nameField));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Up to `limit` of the organization's containers of a kind whose declared
|
|
85
|
+
* name field is exactly `name`, retired ones included and marked, so a caller
|
|
86
|
+
* resolving a name can tell a live match from a retired one sharing it.
|
|
87
|
+
*/ export async function findOrgContainersByName(firestore, kind, orgId, name, limit) {
|
|
88
|
+
const containers = containersOf(firestore, kind, orgId);
|
|
89
|
+
const wanted = name.trim();
|
|
90
|
+
if (!containers || !wanted || limit <= 0) return [];
|
|
91
|
+
const snapshot = await containers.ref.where(containers.nameField, '==', wanted).select(...fieldsOf(containers.nameField)).limit(limit).get();
|
|
92
|
+
return snapshot.docs.map((doc)=>recordOf(doc.id, doc, containers.nameField));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
//# sourceMappingURL=org-containers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/org-containers.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 { pluginContainerKind } from '@aglyn/aglyn/plugin-manager/plugin-containers'\nimport { isDocumentId } from './document-id'\n\n/**\n * AN ORGANIZATION'S CONTAINERS OF ONE KIND, read on the server by a plugin\n * that files its records under them and does not keep them.\n *\n * A container kind is declared by the plugin that keeps it\n * (`plugin-manager/plugin-containers.ts`): which org collection holds the\n * containers and which field names one. That declaration is the whole of what\n * another plugin may rely on — a container's id, its name, the sites its\n * `visibleTo` places it on, and whether its owner retired it (`deletedAt`) —\n * and this module reads exactly that, where the declaration says. A sequence\n * that validates the campaigns it is filed under, an import that matches\n * campaign names, a timeline entry that names the campaign a lead was filed\n * under: none of them names the owner's collection, so the owner may move it.\n *\n * A kind no plugin keeps reads nothing: every id answers as gone, and a list\n * is empty. That is the honest answer for a workspace without the owner.\n */\n\n/** One container, as much of it as a plugin that files under it may read. */\nexport interface OrgContainerRecord {\n id: string\n /** The org holds a container under this id, retired or not. */\n exists: boolean\n /** It exists and its owner has not retired it. */\n live: boolean\n /** Its name, from the field its kind declares; `''` when it has none or is gone. */\n name: string\n /** Its `visibleTo` scope tokens; `[]` when it has none or is gone. */\n visibleTo: string[]\n}\n\ntype Snapshot = Pick<FirebaseFirestore.DocumentSnapshot, 'exists' | 'get'>\n\nfunction recordOf(id: string, snapshot: Snapshot | undefined, nameField: string): OrgContainerRecord {\n if (!snapshot?.exists) return { id, exists: false, live: false, name: '', visibleTo: [] }\n const visibleTo = snapshot.get('visibleTo')\n return {\n id,\n exists: true,\n live: !snapshot.get('deletedAt'),\n name: String(snapshot.get(nameField) ?? '').trim(),\n visibleTo: Array.isArray(visibleTo) ? visibleTo.map(String) : [],\n }\n}\n\n/**\n * The fields a reader may rely on, and the only ones a listing reads: a\n * container can be a large document, and nothing here needs the rest.\n */\nconst fieldsOf = (nameField: string) => [nameField, 'deletedAt', 'visibleTo']\n\n/** The kind's collection under the org, or `null` when no plugin keeps the kind. */\nfunction containersOf(\n firestore: FirebaseFirestore.Firestore,\n kind: string,\n orgId: string,\n): { ref: FirebaseFirestore.CollectionReference; nameField: string } | null {\n const declared = pluginContainerKind(kind)\n if (!declared || !isDocumentId(orgId)) return null\n return {\n ref: firestore.collection('orgs').doc(orgId).collection(declared.orgCollection),\n nameField: declared.nameField,\n }\n}\n\n/**\n * The containers named by `ids`, one record per id in the order asked: one\n * keyed read each, in a single `getAll`. An id that cannot be a document id\n * is answered as gone without a read.\n *\n * Throws what the read throws; a caller that must not fail on it catches.\n */\nexport async function readOrgContainers(\n firestore: FirebaseFirestore.Firestore,\n kind: string,\n orgId: string,\n ids: readonly string[],\n): Promise<OrgContainerRecord[]> {\n if (!ids.length) return []\n const containers = containersOf(firestore, kind, orgId)\n const readable = ids.filter((id) => isDocumentId(id))\n if (!containers || !readable.length) return ids.map((id) => recordOf(id, undefined, ''))\n // `getAll` answers in the order asked, so each snapshot is matched to its\n // id by position rather than by a field of the snapshot.\n const found = await firestore.getAll(...readable.map((id) => containers.ref.doc(id)))\n const byId = new Map(readable.map((id, index) => [id, found[index]]))\n return ids.map((id) => recordOf(id, byId.get(id), containers.nameField))\n}\n\n/**\n * Up to `ceiling` of the organization's containers of a kind, in document\n * order, retired ones included and marked: a caller that matches by name\n * decides what a retired one means to it. Read as a projection of the\n * fields a record carries.\n */\nexport async function listOrgContainers(\n firestore: FirebaseFirestore.Firestore,\n kind: string,\n orgId: string,\n ceiling: number,\n): Promise<OrgContainerRecord[]> {\n const containers = containersOf(firestore, kind, orgId)\n if (!containers || ceiling <= 0) return []\n const snapshot = await containers.ref\n .select(...fieldsOf(containers.nameField))\n .limit(ceiling)\n .get()\n return snapshot.docs.map((doc) => recordOf(doc.id, doc, containers.nameField))\n}\n\n/**\n * Up to `limit` of the organization's containers of a kind whose declared\n * name field is exactly `name`, retired ones included and marked, so a caller\n * resolving a name can tell a live match from a retired one sharing it.\n */\nexport async function findOrgContainersByName(\n firestore: FirebaseFirestore.Firestore,\n kind: string,\n orgId: string,\n name: string,\n limit: number,\n): Promise<OrgContainerRecord[]> {\n const containers = containersOf(firestore, kind, orgId)\n const wanted = name.trim()\n if (!containers || !wanted || limit <= 0) return []\n const snapshot = await containers.ref\n .where(containers.nameField, '==', wanted)\n .select(...fieldsOf(containers.nameField))\n .limit(limit)\n .get()\n return snapshot.docs.map((doc) => recordOf(doc.id, doc, containers.nameField))\n}\n"],"names":["pluginContainerKind","isDocumentId","recordOf","id","snapshot","nameField","exists","live","name","visibleTo","get","String","trim","Array","isArray","map","fieldsOf","containersOf","firestore","kind","orgId","declared","ref","collection","doc","orgCollection","readOrgContainers","ids","length","containers","readable","filter","undefined","found","getAll","byId","Map","index","listOrgContainers","ceiling","select","limit","docs","findOrgContainersByName","wanted","where"],"mappings":"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"}
|
|
@@ -73,9 +73,10 @@ const MAX_SENDS = 25;
|
|
|
73
73
|
'notice.subject': input.subject,
|
|
74
74
|
'notice.body': input.text,
|
|
75
75
|
'org.name': (_brand_orgName = brand.orgName) != null ? _brand_orgName : ''
|
|
76
|
-
}, brand,
|
|
76
|
+
}, brand, // The last resort reads as the built-in copy does: subject, then body.
|
|
77
|
+
{
|
|
77
78
|
subject: input.subject,
|
|
78
|
-
text: input.text
|
|
79
|
+
text: `${input.subject}\n\n${input.text}`
|
|
79
80
|
});
|
|
80
81
|
let sent = 0;
|
|
81
82
|
for (const to of recipients.slice(0, MAX_SENDS)){
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/org-member-notice.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 { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { withoutMailWithheldAccounts } from './account-mail'\nimport { findUserByUidAcrossPools } from './auth-pools'\nimport { filterSuppressedEmails } from './email-suppression'\nimport { listOrgMembers } from './organizations'\nimport { orgSystemEmailBrand, renderSystemEmailContent } from './render-system-email'\n\n/**\n * A NOTICE TO NAMED MEMBERS OF AN ORGANIZATION, BY EMAIL (AGL-3244).\n *\n * The console notification (`notifications.ts`) is the record, and its email\n * channel is a preference that defaults off. Some things cannot wait on a\n * preference: a sequence mailbox that paused itself holds every enrollment\n * on it until its owner acts, and the owner is not watching the page. This\n * sends the email regardless, to the members named and — when asked — to\n * the organization's owners and admins beside them, one send per address\n * so nobody is shown anyone else's.\n *\n * Addresses come from the roster (`orgs/{orgId}/members/{uid}.email`), and\n * for a member whose row carries none, from the directory. Suppressed\n * addresses are skipped, the send is metered to the organization, and\n * nothing here throws: the notice is a courtesy beside a state the caller\n * already wrote.\n */\n\nexport interface OrgMemberNoticeInput {\n orgId: string\n /** The members to write to, by uid. */\n uids: readonly string[]\n /** Also the organization's owners and admins. */\n includeAdmins?: boolean\n subject: string\n text: string\n /** Resend tag / log label. */\n context: string\n}\n\nexport interface OrgMemberNoticeResult {\n /** Addresses the notice went to. */\n sent: number\n /** Why nothing could be sent at all, when nothing could. */\n reason?: 'unconfigured' | 'no-recipients' | 'failed'\n}\n\n/** At most this many directory lookups, and this many sends, per notice. */\nconst MAX_LOOKUPS = 10\nconst MAX_SENDS = 25\n\n/** Sends one notice to each member — see the module note. */\nexport async function sendOrgMemberNotice(input: OrgMemberNoticeInput): Promise<OrgMemberNoticeResult> {\n if (!isEmailConfigured()) return { sent: 0, reason: 'unconfigured' }\n try {\n const members = await listOrgMembers(input.orgId)\n const rosterEmail = new Map<string, string>()\n for (const member of members) {\n const address = String(member.email ?? '')\n .trim()\n .toLowerCase()\n if (address.includes('@')) rosterEmail.set(member.$id, address)\n }\n const uids = new Set(input.uids.filter(Boolean))\n if (input.includeAdmins) {\n for (const member of members) {\n if (member.role === 'owner' || member.role === 'admin') uids.add(member.$id)\n }\n }\n const addresses = new Set<string>()\n let lookups = 0\n // A locked or disabled account is not mailed (AGL-3418).\n for (const uid of await withoutMailWithheldAccounts([...uids])) {\n let address = rosterEmail.get(uid) ?? ''\n if (!address && lookups < MAX_LOOKUPS) {\n lookups += 1\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n address = String(pooled?.record?.email ?? '')\n .trim()\n .toLowerCase()\n }\n if (address.includes('@')) addresses.add(address)\n }\n const recipients = await filterSuppressedEmails([...addresses])\n if (!recipients.length) return { sent: 0, reason: 'no-recipients' }\n // One message for everyone, in the organization's brand, as the\n // `workspace-notice` system email (AGL-3367).\n const brand = await orgSystemEmailBrand(input.orgId)\n const content = await renderSystemEmailContent(\n 'workspace-notice',\n {\n 'notice.subject': input.subject,\n 'notice.body': input.text,\n 'org.name': brand.orgName ?? '',\n },\n brand,\n { subject: input.subject, text: input.text },\n )\n let sent = 0\n for (const to of recipients.slice(0, MAX_SENDS)) {\n const result = await sendEmail({\n to,\n ...content,\n ...(brand.fromName ? { fromName: brand.fromName } : {}),\n context: input.context,\n })\n if (!result.sent) continue\n sent += 1\n const { meterOrgEmail } = await import('./email-metering')\n await meterOrgEmail(input.orgId).catch(() => undefined)\n }\n return sent ? { sent } : { sent: 0, reason: 'failed' }\n } catch (error) {\n console.error('[org-member-notice] send failed', error)\n return { sent: 0, reason: 'failed' }\n }\n}\n"],"names":["isEmailConfigured","sendEmail","withoutMailWithheldAccounts","findUserByUidAcrossPools","filterSuppressedEmails","listOrgMembers","orgSystemEmailBrand","renderSystemEmailContent","MAX_LOOKUPS","MAX_SENDS","sendOrgMemberNotice","input","sent","reason","brand","members","orgId","rosterEmail","Map","member","address","String","email","trim","toLowerCase","includes","set","$id","uids","Set","filter","Boolean","includeAdmins","role","add","addresses","lookups","uid","get","pooled","catch","record","recipients","length","content","subject","text","orgName","to","slice","result","fromName","context","meterOrgEmail","undefined","error","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,iBAAiB,EAAEC,SAAS,QAAQ,2BAA0B;AACvE,SAASC,2BAA2B,QAAQ,oBAAgB;AAC5D,SAASC,wBAAwB,QAAQ,kBAAc;AACvD,SAASC,sBAAsB,QAAQ,yBAAqB;AAC5D,SAASC,cAAc,QAAQ,qBAAiB;AAChD,SAASC,mBAAmB,EAAEC,wBAAwB,QAAQ,2BAAuB;AAuCrF,0EAA0E,GAC1E,MAAMC,cAAc;AACpB,MAAMC,YAAY;AAElB,2DAA2D,GAC3D,OAAO,eAAeC,oBAAoBC,KAA2B;IACnE,IAAI,CAACX,qBAAqB,OAAO;QAAEY,MAAM;QAAGC,QAAQ;IAAe;IACnE,IAAI;YAuCcC;QAtChB,MAAMC,UAAU,MAAMV,eAAeM,MAAMK,KAAK;QAChD,MAAMC,cAAc,IAAIC;QACxB,KAAK,MAAMC,UAAUJ,QAAS;gBACLI;YAAvB,MAAMC,UAAUC,QAAOF,gBAAAA,OAAOG,KAAK,YAAZH,gBAAgB,IACpCI,IAAI,GACJC,WAAW;YACd,IAAIJ,QAAQK,QAAQ,CAAC,MAAMR,YAAYS,GAAG,CAACP,OAAOQ,GAAG,EAAEP;QACzD;QACA,MAAMQ,OAAO,IAAIC,IAAIlB,MAAMiB,IAAI,CAACE,MAAM,CAACC;QACvC,IAAIpB,MAAMqB,aAAa,EAAE;YACvB,KAAK,MAAMb,UAAUJ,QAAS;gBAC5B,IAAII,OAAOc,IAAI,KAAK,WAAWd,OAAOc,IAAI,KAAK,SAASL,KAAKM,GAAG,CAACf,OAAOQ,GAAG;YAC7E;QACF;QACA,MAAMQ,YAAY,IAAIN;QACtB,IAAIO,UAAU;QACd,yDAAyD;QACzD,KAAK,MAAMC,OAAO,CAAA,MAAMnC,4BAA4B;eAAI0B;SAAK,CAAA,EAAG;gBAChDX;YAAd,IAAIG,WAAUH,mBAAAA,YAAYqB,GAAG,CAACD,gBAAhBpB,mBAAwB;YACtC,IAAI,CAACG,WAAWgB,UAAU5B,aAAa;;oBAGpB+B;gBAFjBH,WAAW;gBACX,MAAMG,SAAS,MAAMpC,yBAAyBkC,KAAKG,KAAK,CAAC,IAAM;gBAC/DpB,UAAUC,eAAOkB,2BAAAA,iBAAAA,OAAQE,MAAM,qBAAdF,eAAgBjB,KAAK,mBAAI,IACvCC,IAAI,GACJC,WAAW;YAChB;YACA,IAAIJ,QAAQK,QAAQ,CAAC,MAAMU,UAAUD,GAAG,CAACd;QAC3C;QACA,MAAMsB,aAAa,MAAMtC,uBAAuB;eAAI+B;SAAU;QAC9D,IAAI,CAACO,WAAWC,MAAM,EAAE,OAAO;YAAE/B,MAAM;YAAGC,QAAQ;QAAgB;QAClE,gEAAgE;QAChE,8CAA8C;QAC9C,MAAMC,QAAQ,MAAMR,oBAAoBK,MAAMK,KAAK;QACnD,MAAM4B,UAAU,MAAMrC,yBACpB,oBACA;YACE,kBAAkBI,MAAMkC,OAAO;YAC/B,eAAelC,MAAMmC,IAAI;YACzB,UAAU,GAAEhC,iBAAAA,MAAMiC,OAAO,YAAbjC,iBAAiB;QAC/B,GACAA,OACA;YAAE+B,SAASlC,MAAMkC,OAAO;YAAEC,
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/org-member-notice.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 { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { withoutMailWithheldAccounts } from './account-mail'\nimport { findUserByUidAcrossPools } from './auth-pools'\nimport { filterSuppressedEmails } from './email-suppression'\nimport { listOrgMembers } from './organizations'\nimport { orgSystemEmailBrand, renderSystemEmailContent } from './render-system-email'\n\n/**\n * A NOTICE TO NAMED MEMBERS OF AN ORGANIZATION, BY EMAIL (AGL-3244).\n *\n * The console notification (`notifications.ts`) is the record, and its email\n * channel is a preference that defaults off. Some things cannot wait on a\n * preference: a sequence mailbox that paused itself holds every enrollment\n * on it until its owner acts, and the owner is not watching the page. This\n * sends the email regardless, to the members named and — when asked — to\n * the organization's owners and admins beside them, one send per address\n * so nobody is shown anyone else's.\n *\n * Addresses come from the roster (`orgs/{orgId}/members/{uid}.email`), and\n * for a member whose row carries none, from the directory. Suppressed\n * addresses are skipped, the send is metered to the organization, and\n * nothing here throws: the notice is a courtesy beside a state the caller\n * already wrote.\n */\n\nexport interface OrgMemberNoticeInput {\n orgId: string\n /** The members to write to, by uid. */\n uids: readonly string[]\n /** Also the organization's owners and admins. */\n includeAdmins?: boolean\n subject: string\n text: string\n /** Resend tag / log label. */\n context: string\n}\n\nexport interface OrgMemberNoticeResult {\n /** Addresses the notice went to. */\n sent: number\n /** Why nothing could be sent at all, when nothing could. */\n reason?: 'unconfigured' | 'no-recipients' | 'failed'\n}\n\n/** At most this many directory lookups, and this many sends, per notice. */\nconst MAX_LOOKUPS = 10\nconst MAX_SENDS = 25\n\n/** Sends one notice to each member — see the module note. */\nexport async function sendOrgMemberNotice(input: OrgMemberNoticeInput): Promise<OrgMemberNoticeResult> {\n if (!isEmailConfigured()) return { sent: 0, reason: 'unconfigured' }\n try {\n const members = await listOrgMembers(input.orgId)\n const rosterEmail = new Map<string, string>()\n for (const member of members) {\n const address = String(member.email ?? '')\n .trim()\n .toLowerCase()\n if (address.includes('@')) rosterEmail.set(member.$id, address)\n }\n const uids = new Set(input.uids.filter(Boolean))\n if (input.includeAdmins) {\n for (const member of members) {\n if (member.role === 'owner' || member.role === 'admin') uids.add(member.$id)\n }\n }\n const addresses = new Set<string>()\n let lookups = 0\n // A locked or disabled account is not mailed (AGL-3418).\n for (const uid of await withoutMailWithheldAccounts([...uids])) {\n let address = rosterEmail.get(uid) ?? ''\n if (!address && lookups < MAX_LOOKUPS) {\n lookups += 1\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n address = String(pooled?.record?.email ?? '')\n .trim()\n .toLowerCase()\n }\n if (address.includes('@')) addresses.add(address)\n }\n const recipients = await filterSuppressedEmails([...addresses])\n if (!recipients.length) return { sent: 0, reason: 'no-recipients' }\n // One message for everyone, in the organization's brand, as the\n // `workspace-notice` system email (AGL-3367).\n const brand = await orgSystemEmailBrand(input.orgId)\n const content = await renderSystemEmailContent(\n 'workspace-notice',\n {\n 'notice.subject': input.subject,\n 'notice.body': input.text,\n 'org.name': brand.orgName ?? '',\n },\n brand,\n // The last resort reads as the built-in copy does: subject, then body.\n { subject: input.subject, text: `${input.subject}\\n\\n${input.text}` },\n )\n let sent = 0\n for (const to of recipients.slice(0, MAX_SENDS)) {\n const result = await sendEmail({\n to,\n ...content,\n ...(brand.fromName ? { fromName: brand.fromName } : {}),\n context: input.context,\n })\n if (!result.sent) continue\n sent += 1\n const { meterOrgEmail } = await import('./email-metering')\n await meterOrgEmail(input.orgId).catch(() => undefined)\n }\n return sent ? { sent } : { sent: 0, reason: 'failed' }\n } catch (error) {\n console.error('[org-member-notice] send failed', error)\n return { sent: 0, reason: 'failed' }\n }\n}\n"],"names":["isEmailConfigured","sendEmail","withoutMailWithheldAccounts","findUserByUidAcrossPools","filterSuppressedEmails","listOrgMembers","orgSystemEmailBrand","renderSystemEmailContent","MAX_LOOKUPS","MAX_SENDS","sendOrgMemberNotice","input","sent","reason","brand","members","orgId","rosterEmail","Map","member","address","String","email","trim","toLowerCase","includes","set","$id","uids","Set","filter","Boolean","includeAdmins","role","add","addresses","lookups","uid","get","pooled","catch","record","recipients","length","content","subject","text","orgName","to","slice","result","fromName","context","meterOrgEmail","undefined","error","console"],"mappings":"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"}
|
|
@@ -37,6 +37,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
37
37
|
* substituting the admin barrel substitutes these too.
|
|
38
38
|
*=========================================*/ import { createHash } from "crypto";
|
|
39
39
|
import { ABUSE_REPORT_COLLECTION } from "@aglyn/aglyn/app-utils/abuse-report";
|
|
40
|
+
import { RISK_PAYMENT_EVENTS } from "@aglyn/shared-util-email/risk-notice-catalog";
|
|
40
41
|
import { FieldValue } from "firebase-admin/firestore";
|
|
41
42
|
const KIND_LABEL = {
|
|
42
43
|
'early-fraud-warning': 'Early fraud warning',
|
|
@@ -176,7 +177,7 @@ const KIND_LABEL = {
|
|
|
176
177
|
merge: true
|
|
177
178
|
});
|
|
178
179
|
if (first) {
|
|
179
|
-
var _ref, _signal_chargeId;
|
|
180
|
+
var _RISK_PAYMENT_EVENTS_signal_kind, _ref, _signal_chargeId;
|
|
180
181
|
// The workspace's owners are told their subscription payment was
|
|
181
182
|
// flagged and how to confirm it; staff get the row with the evidence.
|
|
182
183
|
await deps.notifyRisk({
|
|
@@ -189,6 +190,9 @@ const KIND_LABEL = {
|
|
|
189
190
|
path: '/org/billing/invoices'
|
|
190
191
|
},
|
|
191
192
|
amount: formatSignalAmount(signal.amountCents, signal.currency),
|
|
193
|
+
// A dispute is the bank's, already opened; a warning or a review is
|
|
194
|
+
// a fraud check. The notice says which.
|
|
195
|
+
paymentEvent: (_RISK_PAYMENT_EVENTS_signal_kind = RISK_PAYMENT_EVENTS[signal.kind]) != null ? _RISK_PAYMENT_EVENTS_signal_kind : null,
|
|
192
196
|
stripeUrl: stripeSignalDashboardUrl(signal),
|
|
193
197
|
staffEvidence: `${KIND_LABEL[signal.kind]} (${signal.stripeObjectId}) on charge ` + `${(_ref = (_signal_chargeId = signal.chargeId) != null ? _signal_chargeId : signal.paymentIntentId) != null ? _ref : 'not named'}` + (signal.detail ? `; Stripe says: ${signal.detail}` : '') + (signal.livemode ? '' : ' — TEST MODE') + '.'
|
|
194
198
|
}).catch(()=>undefined);
|
|
@@ -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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"}
|
|
@@ -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
|
-
|
|
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
|
});
|
|
@@ -1 +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 * 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 reason,\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","recordConnectPayoutFailure","collection","input","accountId","String","stripeId","id","firestore","app","amountCents","reason","failedAtMs","Date","now","matches","where","get","doc","set","kind","currency","failureCode","failureMessage","livemode","docs","length","boundProfiles","FieldValue","arrayUnion","map","recordedAt","serverTimestamp","merge","raiseOperatorAlert","dedupeKey","context","account","mirrored","ref","lastPayoutFailureId","lastPayoutFailureKind","lastPayoutFailureAtMs","lastPayoutFailureCents","lastPayoutFailureReason","clearConnectPayoutFailure","cleared","remove","delete"],"mappings":"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"}
|
|
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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"}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { type PaymentRiskSignal, type PaymentRiskSignalKind } from '@aglyn/aglyn/app-utils/payment-risk';
|
|
18
18
|
import type { AglynNotificationType } from '@aglyn/aglyn/app-utils/notifications';
|
|
19
|
-
import type
|
|
19
|
+
import { type RiskEventKind } from '@aglyn/shared-util-email/risk-notice-catalog';
|
|
20
20
|
import type { RiskEventInput } from './risk-notice';
|
|
21
21
|
/**
|
|
22
22
|
* The notifier's shape: `notifyRiskEvent` from the admin barrel (AGL-3368),
|
|
@@ -30,6 +30,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
30
30
|
* Dependencies are passed in — the Firestore handle and the notifier — so a
|
|
31
31
|
* plugin's spec that substitutes the admin barrel substitutes these too.
|
|
32
32
|
*=========================================*/ import { addPaymentRiskSignal, PAYMENT_RISK_FIELD, settlePaymentRiskDispute } from "@aglyn/aglyn/app-utils/payment-risk";
|
|
33
|
+
import { RISK_PAYMENT_EVENTS } from "@aglyn/shared-util-email/risk-notice-catalog";
|
|
33
34
|
/** The risk notice each Stripe signal on a sale is told as. */ export const PAYMENT_RISK_NOTICE_KINDS = {
|
|
34
35
|
'early-fraud-warning': 'sale-fraud-warning',
|
|
35
36
|
'radar-review': 'sale-payment-review',
|
|
@@ -54,7 +55,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
54
55
|
return true;
|
|
55
56
|
});
|
|
56
57
|
if (written && (input.hostId || input.orgId)) {
|
|
57
|
-
var _input_noticeKind, _input_orgId, _input_amount, _input_evidenceDueByMs;
|
|
58
|
+
var _input_noticeKind, _input_orgId, _input_amount, _RISK_PAYMENT_EVENTS_input_signal_kind, _input_evidenceDueByMs;
|
|
58
59
|
await deps.notifyRisk({
|
|
59
60
|
kind: (_input_noticeKind = input.noticeKind) != null ? _input_noticeKind : PAYMENT_RISK_NOTICE_KINDS[input.signal.kind],
|
|
60
61
|
orgId: (_input_orgId = input.orgId) != null ? _input_orgId : null,
|
|
@@ -65,6 +66,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
65
66
|
},
|
|
66
67
|
occurredAtMs: input.signal.atMs,
|
|
67
68
|
amount: (_input_amount = input.amount) != null ? _input_amount : null,
|
|
69
|
+
paymentEvent: (_RISK_PAYMENT_EVENTS_input_signal_kind = RISK_PAYMENT_EVENTS[input.signal.kind]) != null ? _RISK_PAYMENT_EVENTS_input_signal_kind : null,
|
|
68
70
|
evidenceDueByMs: (_input_evidenceDueByMs = input.evidenceDueByMs) != null ? _input_evidenceDueByMs : null,
|
|
69
71
|
dedupeKey: `sale:${input.signal.stripeObjectId}`,
|
|
70
72
|
staffEvidence: input.signal.detail ? `Stripe says: ${input.signal.detail.replace(/_/g, ' ')}.` : null
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/payment-risk-record.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 * STAMP A STRIPE FRAUD SIGNAL ON A MERCHANT'S RECORD (AGL-3360).\n *\n * The server half of `@aglyn/aglyn/app-utils/payment-risk`. A plugin that\n * finds its own record behind a charge — an order, a booking — hands the\n * document here, and this writes the `paymentRisk` field once per signal and\n * tells the site's managers and the workspace's owners once, through the\n * risk notice seam (AGL-3368). Any plugin that sells through Stripe uses\n * the same two calls; nothing about the record's kind is known here.\n *\n * NOTHING IS REFUNDED OR CANCELED. The merchant decides for their own\n * customer; the notification says so.\n *\n * Dependencies are passed in — the Firestore handle and the notifier — so a\n * plugin's spec that substitutes the admin barrel substitutes these too.\n *=========================================*/\n\nimport {\n addPaymentRiskSignal,\n PAYMENT_RISK_FIELD,\n type PaymentRisk,\n type PaymentRiskSignal,\n type PaymentRiskSignalKind,\n settlePaymentRiskDispute,\n} from '@aglyn/aglyn/app-utils/payment-risk'\nimport type { AglynNotificationType } from '@aglyn/aglyn/app-utils/notifications'\nimport type
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/payment-risk-record.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 * STAMP A STRIPE FRAUD SIGNAL ON A MERCHANT'S RECORD (AGL-3360).\n *\n * The server half of `@aglyn/aglyn/app-utils/payment-risk`. A plugin that\n * finds its own record behind a charge — an order, a booking — hands the\n * document here, and this writes the `paymentRisk` field once per signal and\n * tells the site's managers and the workspace's owners once, through the\n * risk notice seam (AGL-3368). Any plugin that sells through Stripe uses\n * the same two calls; nothing about the record's kind is known here.\n *\n * NOTHING IS REFUNDED OR CANCELED. The merchant decides for their own\n * customer; the notification says so.\n *\n * Dependencies are passed in — the Firestore handle and the notifier — so a\n * plugin's spec that substitutes the admin barrel substitutes these too.\n *=========================================*/\n\nimport {\n addPaymentRiskSignal,\n PAYMENT_RISK_FIELD,\n type PaymentRisk,\n type PaymentRiskSignal,\n type PaymentRiskSignalKind,\n settlePaymentRiskDispute,\n} from '@aglyn/aglyn/app-utils/payment-risk'\nimport type { AglynNotificationType } from '@aglyn/aglyn/app-utils/notifications'\nimport {\n RISK_PAYMENT_EVENTS,\n type RiskEventKind,\n} from '@aglyn/shared-util-email/risk-notice-catalog'\nimport type { RiskEventInput } from './risk-notice'\n\n/**\n * The notifier's shape: `notifyRiskEvent` from the admin barrel (AGL-3368),\n * which tells the site's managers and the workspace's owners and admins what\n * arrived, what it means, and how to refund, keep or answer it.\n */\nexport type PaymentRiskNotifier = (input: RiskEventInput) => Promise<unknown>\n\n/** The risk notice each Stripe signal on a sale is told as. */\nexport const PAYMENT_RISK_NOTICE_KINDS: Record<PaymentRiskSignalKind, RiskEventKind> = {\n 'early-fraud-warning': 'sale-fraud-warning',\n 'radar-review': 'sale-payment-review',\n dispute: 'sale-dispute',\n}\n\nexport interface RecordPaymentRiskInput {\n ref: FirebaseFirestore.DocumentReference\n signal: PaymentRiskSignal\n /** The site whose managers are told. Empty tells only `orgId`'s owners. */\n hostId: string\n /**\n * The workspace told when the record belongs to no site — a marketplace\n * sale's publisher. Resolved from the site otherwise.\n */\n orgId?: string | null\n /**\n * The notice to send, when the record's owner cannot act the way a shop\n * does on its own sale (a marketplace publisher cannot refund a charge the\n * platform is merchant of record for). Defaults by signal kind.\n */\n noticeKind?: RiskEventKind\n /** What the record is, as the merchant calls it: `Order 1042`, `Booking`. */\n subjectLabel: string\n /**\n * The console path the notice's actions open, in the stored-notification\n * shape (`/{hostId}/…`) — the order's own dialog, the Bookings page.\n */\n link: string\n /**\n * The plugin's own notification type. Kept for the record's callers; the\n * notice itself is always a `system.riskNotice` now.\n */\n notificationType?: AglynNotificationType\n /** `$56.00 USD`, when the plugin knows the amount. */\n amount?: string | null\n /** A dispute's evidence deadline, when the plugin knows it. */\n evidenceDueByMs?: number | null\n /**\n * More fields to write in the same transaction, from the record as read —\n * a plugin's own activity line (an order's timeline) rides here.\n */\n extraUpdate?: (data: Record<string, unknown>) => Record<string, unknown>\n}\n\n/**\n * Stamp `signal` on `input.ref` and notify, once. Returns true when this\n * delivery wrote it; false for a redelivery or a record that is gone.\n * Throws only when the write throws, which the webhook answers with a 500 and\n * Stripe's redelivery.\n */\nexport async function recordPaymentRiskOnRecord(\n input: RecordPaymentRiskInput,\n deps: {\n firestore: FirebaseFirestore.Firestore\n notifyRisk: PaymentRiskNotifier\n },\n): Promise<boolean> {\n const written = await deps.firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(input.ref)\n if (!fresh.exists) return false\n const data = (fresh.data() ?? {}) as Record<string, unknown>\n const next = addPaymentRiskSignal(\n data[PAYMENT_RISK_FIELD] as PaymentRisk | undefined,\n input.signal,\n )\n if (!next) return false\n transaction.update(input.ref, {\n ...(input.extraUpdate ? input.extraUpdate(data) : {}),\n [PAYMENT_RISK_FIELD]: next,\n })\n return true\n })\n if (written && (input.hostId || input.orgId)) {\n await deps\n .notifyRisk({\n kind: input.noticeKind ?? PAYMENT_RISK_NOTICE_KINDS[input.signal.kind],\n orgId: input.orgId ?? null,\n hostId: input.hostId || null,\n item: { label: input.subjectLabel, path: input.link },\n occurredAtMs: input.signal.atMs,\n amount: input.amount ?? null,\n paymentEvent: RISK_PAYMENT_EVENTS[input.signal.kind] ?? null,\n evidenceDueByMs: input.evidenceDueByMs ?? null,\n dedupeKey: `sale:${input.signal.stripeObjectId}`,\n staffEvidence: input.signal.detail\n ? `Stripe says: ${input.signal.detail.replace(/_/g, ' ')}.`\n : null,\n })\n .catch(() => undefined)\n }\n return written\n}\n\n/**\n * Record a dispute's outcome on the signal already stamped for it. Silent:\n * the plugin that owns money movement on a lost dispute says so itself.\n */\nexport async function settlePaymentRiskDisputeOnRecord(\n ref: FirebaseFirestore.DocumentReference,\n disputeId: string,\n outcome: string,\n deps: { firestore: FirebaseFirestore.Firestore },\n): Promise<boolean> {\n return deps.firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(ref)\n if (!fresh.exists) return false\n const next = settlePaymentRiskDispute(\n fresh.get(PAYMENT_RISK_FIELD) as PaymentRisk | undefined,\n disputeId,\n outcome,\n )\n if (!next) return false\n transaction.update(ref, { [PAYMENT_RISK_FIELD]: next })\n return true\n })\n}\n"],"names":["addPaymentRiskSignal","PAYMENT_RISK_FIELD","settlePaymentRiskDispute","RISK_PAYMENT_EVENTS","PAYMENT_RISK_NOTICE_KINDS","dispute","recordPaymentRiskOnRecord","input","deps","written","firestore","runTransaction","transaction","fresh","get","ref","exists","data","next","signal","update","extraUpdate","hostId","orgId","notifyRisk","kind","noticeKind","item","label","subjectLabel","path","link","occurredAtMs","atMs","amount","paymentEvent","evidenceDueByMs","dedupeKey","stripeObjectId","staffEvidence","detail","replace","catch","undefined","settlePaymentRiskDisputeOnRecord","disputeId","outcome"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;2CAe2C,GAE3C,SACEA,oBAAoB,EACpBC,kBAAkB,EAIlBC,wBAAwB,QACnB,sCAAqC;AAE5C,SACEC,mBAAmB,QAEd,+CAA8C;AAUrD,6DAA6D,GAC7D,OAAO,MAAMC,4BAA0E;IACrF,uBAAuB;IACvB,gBAAgB;IAChBC,SAAS;AACX,EAAC;AAyCD;;;;;CAKC,GACD,OAAO,eAAeC,0BACpBC,KAA6B,EAC7BC,IAGC;IAED,MAAMC,UAAU,MAAMD,KAAKE,SAAS,CAACC,cAAc,CAAC,OAAOC;YAG3CC;QAFd,MAAMA,QAAQ,MAAMD,YAAYE,GAAG,CAACP,MAAMQ,GAAG;QAC7C,IAAI,CAACF,MAAMG,MAAM,EAAE,OAAO;QAC1B,MAAMC,QAAQJ,cAAAA,MAAMI,IAAI,cAAVJ,cAAgB,CAAC;QAC/B,MAAMK,OAAOlB,qBACXiB,IAAI,CAAChB,mBAAmB,EACxBM,MAAMY,MAAM;QAEd,IAAI,CAACD,MAAM,OAAO;QAClBN,YAAYQ,MAAM,CAACb,MAAMQ,GAAG,EAAE,aACxBR,MAAMc,WAAW,GAAGd,MAAMc,WAAW,CAACJ,QAAQ,CAAC;YACnD,CAAChB,mBAAmB,EAAEiB;;QAExB,OAAO;IACT;IACA,IAAIT,WAAYF,CAAAA,MAAMe,MAAM,IAAIf,MAAMgB,KAAK,AAAD,GAAI;YAGlChB,mBACCA,cAICA,eACMJ,wCACGI;QATrB,MAAMC,KACHgB,UAAU,CAAC;YACVC,IAAI,GAAElB,oBAAAA,MAAMmB,UAAU,YAAhBnB,oBAAoBH,yBAAyB,CAACG,MAAMY,MAAM,CAACM,IAAI,CAAC;YACtEF,KAAK,GAAEhB,eAAAA,MAAMgB,KAAK,YAAXhB,eAAe;YACtBe,QAAQf,MAAMe,MAAM,IAAI;YACxBK,MAAM;gBAAEC,OAAOrB,MAAMsB,YAAY;gBAAEC,MAAMvB,MAAMwB,IAAI;YAAC;YACpDC,cAAczB,MAAMY,MAAM,CAACc,IAAI;YAC/BC,MAAM,GAAE3B,gBAAAA,MAAM2B,MAAM,YAAZ3B,gBAAgB;YACxB4B,YAAY,GAAEhC,yCAAAA,mBAAmB,CAACI,MAAMY,MAAM,CAACM,IAAI,CAAC,YAAtCtB,yCAA0C;YACxDiC,eAAe,GAAE7B,yBAAAA,MAAM6B,eAAe,YAArB7B,yBAAyB;YAC1C8B,WAAW,CAAC,KAAK,EAAE9B,MAAMY,MAAM,CAACmB,cAAc,EAAE;YAChDC,eAAehC,MAAMY,MAAM,CAACqB,MAAM,GAC9B,CAAC,aAAa,EAAEjC,MAAMY,MAAM,CAACqB,MAAM,CAACC,OAAO,CAAC,MAAM,KAAK,CAAC,CAAC,GACzD;QACN,GACCC,KAAK,CAAC,IAAMC;IACjB;IACA,OAAOlC;AACT;AAEA;;;CAGC,GACD,OAAO,eAAemC,iCACpB7B,GAAwC,EACxC8B,SAAiB,EACjBC,OAAe,EACftC,IAAgD;IAEhD,OAAOA,KAAKE,SAAS,CAACC,cAAc,CAAC,OAAOC;QAC1C,MAAMC,QAAQ,MAAMD,YAAYE,GAAG,CAACC;QACpC,IAAI,CAACF,MAAMG,MAAM,EAAE,OAAO;QAC1B,MAAME,OAAOhB,yBACXW,MAAMC,GAAG,CAACb,qBACV4C,WACAC;QAEF,IAAI,CAAC5B,MAAM,OAAO;QAClBN,YAAYQ,MAAM,CAACL,KAAK;YAAE,CAACd,mBAAmB,EAAEiB;QAAK;QACrD,OAAO;IACT;AACF"}
|