@aglyn/tenant-data-admin 1.0.0-beta.203 → 1.0.0-beta.204

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/capture-email-check.d.ts +109 -0
  3. package/src/lib/server/capture-email-check.js +362 -0
  4. package/src/lib/server/capture-email-check.js.map +1 -0
  5. package/src/lib/server/card-payment-velocity.d.ts +11 -16
  6. package/src/lib/server/card-payment-velocity.js +24 -28
  7. package/src/lib/server/card-payment-velocity.js.map +1 -1
  8. package/src/lib/server/email-deliverability.d.ts +19 -3
  9. package/src/lib/server/email-deliverability.js +34 -16
  10. package/src/lib/server/email-deliverability.js.map +1 -1
  11. package/src/lib/server/held-page-subject.d.ts +93 -0
  12. package/src/lib/server/held-page-subject.js +206 -0
  13. package/src/lib/server/held-page-subject.js.map +1 -0
  14. package/src/lib/server/host-email-tokens.d.ts +42 -1
  15. package/src/lib/server/host-email-tokens.js +100 -12
  16. package/src/lib/server/host-email-tokens.js.map +1 -1
  17. package/src/lib/server/host-visitor-records.js +21 -5
  18. package/src/lib/server/host-visitor-records.js.map +1 -1
  19. package/src/lib/server/hosted-page-review.d.ts +16 -5
  20. package/src/lib/server/hosted-page-review.js +242 -61
  21. package/src/lib/server/hosted-page-review.js.map +1 -1
  22. package/src/lib/server/list-members.js +16 -0
  23. package/src/lib/server/list-members.js.map +1 -1
  24. package/src/lib/server/outbound-send-review.d.ts +22 -0
  25. package/src/lib/server/outbound-send-review.js +74 -6
  26. package/src/lib/server/outbound-send-review.js.map +1 -1
  27. package/src/lib/server/payment-fraud-signal.d.ts +20 -12
  28. package/src/lib/server/payment-fraud-signal.js +41 -11
  29. package/src/lib/server/payment-fraud-signal.js.map +1 -1
  30. package/src/lib/server/payment-risk-record.d.ts +37 -13
  31. package/src/lib/server/payment-risk-record.js +23 -8
  32. package/src/lib/server/payment-risk-record.js.map +1 -1
  33. package/src/lib/server/render-system-email.d.ts +3 -0
  34. package/src/lib/server/render-system-email.js +39 -11
  35. package/src/lib/server/render-system-email.js.map +1 -1
  36. package/src/lib/server/risk-notice.d.ts +327 -0
  37. package/src/lib/server/risk-notice.js +1240 -0
  38. package/src/lib/server/risk-notice.js.map +1 -0
  39. package/src/lib/server/sender-readiness.d.ts +36 -0
  40. package/src/lib/server/sender-readiness.js +99 -0
  41. package/src/lib/server/sender-readiness.js.map +1 -0
  42. package/src/lib/server/test-firestore.js +1 -0
  43. package/src/lib/server/test-firestore.js.map +1 -1
  44. package/src/lib/server/upsert-contact.js +19 -3
  45. package/src/lib/server/upsert-contact.js.map +1 -1
  46. package/src/tenancy.d.ts +2 -0
  47. package/src/tenancy.js +3 -0
  48. package/src/tenancy.js.map +1 -1
@@ -53,6 +53,17 @@ const KIND_LABEL = {
53
53
  /** Where staff act on the workspace's billing: its Subscription card. */ export function staffSubscriptionCardPath(orgId) {
54
54
  return `/admin/orgs/${encodeURIComponent(orgId)}#subscription`;
55
55
  }
56
+ /**
57
+ * The Stripe Dashboard page for a signal: the dispute itself, else the
58
+ * payment it is about.
59
+ */ export function stripeSignalDashboardUrl(signal) {
60
+ const base = `https://dashboard.stripe.com/${signal.livemode ? '' : 'test/'}`;
61
+ if (signal.kind === 'dispute' && signal.stripeObjectId) {
62
+ return `${base}disputes/${encodeURIComponent(signal.stripeObjectId)}`;
63
+ }
64
+ const payment = signal.paymentIntentId || signal.chargeId;
65
+ return payment ? `${base}payments/${encodeURIComponent(payment)}` : null;
66
+ }
56
67
  /** `$56.00 USD`, or a sentence saying the amount is not known. */ export function formatSignalAmount(amountCents, currency) {
57
68
  if (amountCents === null || !Number.isFinite(amountCents)) {
58
69
  return 'amount not recorded';
@@ -166,11 +177,20 @@ const KIND_LABEL = {
166
177
  });
167
178
  if (first) {
168
179
  var _ref, _signal_chargeId;
169
- await deps.notify({
170
- type: 'system.abuseReportUrgent',
171
- title: `${KIND_LABEL[signal.kind]} — ${formatSignalAmount(signal.amountCents, signal.currency)}`,
172
- body: `${signal.orgId ? `Workspace ${signal.orgId}` : 'A charge that is not a workspace subscription'}, ` + `charge ${(_ref = (_signal_chargeId = signal.chargeId) != null ? _signal_chargeId : signal.paymentIntentId) != null ? _ref : 'not named'}. ` + `Nothing has been refunded or canceled. Reference ${reference}.`,
173
- link: '/admin/abuse-reports'
180
+ // The workspace's owners are told their subscription payment was
181
+ // flagged and how to confirm it; staff get the row with the evidence.
182
+ await deps.notifyRisk({
183
+ kind: 'billing-payment-flagged',
184
+ orgId: signal.orgId,
185
+ reviewId,
186
+ reference,
187
+ item: {
188
+ label: 'your subscription payment',
189
+ path: '/org/billing/invoices'
190
+ },
191
+ amount: formatSignalAmount(signal.amountCents, signal.currency),
192
+ stripeUrl: stripeSignalDashboardUrl(signal),
193
+ 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') + '.'
174
194
  }).catch(()=>undefined);
175
195
  }
176
196
  return {
@@ -240,7 +260,7 @@ const WINDOW_DAYS = SELLER_FRAUD_PATTERN.windowMs / 86400000;
240
260
  * ledger crosses the threshold. Idempotent on the Stripe object id: a
241
261
  * redelivery changes nothing and files nothing.
242
262
  */ export async function recordSellerFraudSignal(input, deps) {
243
- var _deps_nowMs, _input_orgIds_, _hostIds_;
263
+ var _deps_nowMs, _input_orgIds_, _hostIds_, _hostIds_1;
244
264
  const nowMs = (_deps_nowMs = deps.nowMs) != null ? _deps_nowMs : Date.now();
245
265
  const { signal, sellerAccountId } = input;
246
266
  const ledgerRef = deps.firestore.collection(SELLER_FRAUD_LEDGER_COLLECTION).doc(sellerAccountId);
@@ -349,11 +369,21 @@ const WINDOW_DAYS = SELLER_FRAUD_PATTERN.windowMs / 86400000;
349
369
  }, {
350
370
  merge: true
351
371
  });
352
- await deps.notify({
353
- type: 'system.abuseReportUrgent',
354
- title: `Seller fraud pattern — ${chargeIds.length} charges on one account`,
355
- body: `Connected account ${sellerAccountId}` + (orgId ? ` (workspace ${orgId})` : '') + ` drew fraud warnings or disputes on ${chargeIds.length} different charges in ${WINDOW_DAYS} days. ` + `Nothing has been refunded, canceled or paused. Reference ${reference}.`,
356
- link: '/admin/abuse-reports'
372
+ // Staff get the pattern with its numbers; the owners get a plain "we are
373
+ // reviewing recent payments" with the way to talk to us, never the count
374
+ // or the window that opened the review.
375
+ await deps.notifyRisk({
376
+ kind: 'seller-review',
377
+ orgId,
378
+ hostId: (_hostIds_1 = hostIds[0]) != null ? _hostIds_1 : null,
379
+ reviewId,
380
+ reference,
381
+ item: {
382
+ label: 'recent card payments on your store',
383
+ path: '/org/settings/holds'
384
+ },
385
+ stripeUrl,
386
+ staffEvidence: `Connected account ${sellerAccountId} drew fraud warnings or disputes on ` + `${chargeIds.length} different charges in ${WINDOW_DAYS} days` + (signal.livemode ? '' : ' — TEST MODE') + '.'
357
387
  }).catch(()=>undefined);
358
388
  return {
359
389
  first: true,
@@ -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'\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/** `$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 notify: (payload: {\n type: 'system.abuseReportUrgent'\n title: string\n body: string\n link: string\n }) => Promise<void>\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 await deps\n .notify({\n type: 'system.abuseReportUrgent',\n title: `${KIND_LABEL[signal.kind]} — ${formatSignalAmount(signal.amountCents, signal.currency)}`,\n body:\n `${signal.orgId ? `Workspace ${signal.orgId}` : 'A charge that is not a workspace subscription'}, ` +\n `charge ${signal.chargeId ?? signal.paymentIntentId ?? 'not named'}. ` +\n `Nothing has been refunded or canceled. Reference ${reference}.`,\n link: '/admin/abuse-reports',\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 notify: (payload: {\n type: 'system.abuseReportUrgent'\n title: string\n body: string\n link: string\n }) => Promise<void>\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 await deps\n .notify({\n type: 'system.abuseReportUrgent',\n title: `Seller fraud pattern — ${chargeIds.length} charges on one account`,\n body:\n `Connected account ${sellerAccountId}` +\n (orgId ? ` (workspace ${orgId})` : '') +\n ` drew fraud warnings or disputes on ${chargeIds.length} different charges in ${WINDOW_DAYS} days. ` +\n `Nothing has been refunded, canceled or paused. Reference ${reference}.`,\n link: '/admin/abuse-reports',\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","formatSignalAmount","amountCents","currency","Number","isFinite","toFixed","String","readStripeChargeForSignal","chargeId","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","signal","lines","livemode","paymentIntentId","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","notify","type","title","body","link","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","stripeUrl","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 { 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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"}
@@ -14,26 +14,50 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- import { type PaymentRiskSignal } from '@aglyn/aglyn/app-utils/payment-risk';
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
- /** The notifier's shape: `notifyHostManagers` from the admin barrel. */
20
- export type PaymentRiskNotifier = (hostId: string, payload: {
21
- type: AglynNotificationType;
22
- title: string;
23
- body: string;
24
- link: string;
25
- }) => Promise<void>;
19
+ import type { RiskEventKind } from '@aglyn/shared-util-email/risk-notice-catalog';
20
+ import type { RiskEventInput } from './risk-notice';
21
+ /**
22
+ * The notifier's shape: `notifyRiskEvent` from the admin barrel (AGL-3368),
23
+ * which tells the site's managers and the workspace's owners and admins what
24
+ * arrived, what it means, and how to refund, keep or answer it.
25
+ */
26
+ export type PaymentRiskNotifier = (input: RiskEventInput) => Promise<unknown>;
27
+ /** The risk notice each Stripe signal on a sale is told as. */
28
+ export declare const PAYMENT_RISK_NOTICE_KINDS: Record<PaymentRiskSignalKind, RiskEventKind>;
26
29
  export interface RecordPaymentRiskInput {
27
30
  ref: FirebaseFirestore.DocumentReference;
28
31
  signal: PaymentRiskSignal;
29
- /** The site whose managers are told. Empty tells nobody. */
32
+ /** The site whose managers are told. Empty tells only `orgId`'s owners. */
30
33
  hostId: string;
34
+ /**
35
+ * The workspace told when the record belongs to no site — a marketplace
36
+ * sale's publisher. Resolved from the site otherwise.
37
+ */
38
+ orgId?: string | null;
39
+ /**
40
+ * The notice to send, when the record's owner cannot act the way a shop
41
+ * does on its own sale (a marketplace publisher cannot refund a charge the
42
+ * platform is merchant of record for). Defaults by signal kind.
43
+ */
44
+ noticeKind?: RiskEventKind;
31
45
  /** What the record is, as the merchant calls it: `Order 1042`, `Booking`. */
32
46
  subjectLabel: string;
33
- /** The console path the notification opens. */
47
+ /**
48
+ * The console path the notice's actions open, in the stored-notification
49
+ * shape (`/{hostId}/…`) — the order's own dialog, the Bookings page.
50
+ */
34
51
  link: string;
35
- /** The plugin's own notification type: `content.order`, `content.booking`. */
36
- notificationType: AglynNotificationType;
52
+ /**
53
+ * The plugin's own notification type. Kept for the record's callers; the
54
+ * notice itself is always a `system.riskNotice` now.
55
+ */
56
+ notificationType?: AglynNotificationType;
57
+ /** `$56.00 USD`, when the plugin knows the amount. */
58
+ amount?: string | null;
59
+ /** A dispute's evidence deadline, when the plugin knows it. */
60
+ evidenceDueByMs?: number | null;
37
61
  /**
38
62
  * More fields to write in the same transaction, from the record as read —
39
63
  * a plugin's own activity line (an order's timeline) rides here.
@@ -48,7 +72,7 @@ export interface RecordPaymentRiskInput {
48
72
  */
49
73
  export declare function recordPaymentRiskOnRecord(input: RecordPaymentRiskInput, deps: {
50
74
  firestore: FirebaseFirestore.Firestore;
51
- notify: PaymentRiskNotifier;
75
+ notifyRisk: PaymentRiskNotifier;
52
76
  }): Promise<boolean>;
53
77
  /**
54
78
  * Record a dispute's outcome on the signal already stamped for it. Silent:
@@ -20,7 +20,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
20
20
  * The server half of `@aglyn/aglyn/app-utils/payment-risk`. A plugin that
21
21
  * finds its own record behind a charge — an order, a booking — hands the
22
22
  * document here, and this writes the `paymentRisk` field once per signal and
23
- * tells the site's managers once. Any plugin that sells through Stripe uses
23
+ * tells the site's managers and the workspace's owners once, through the
24
+ * risk notice seam (AGL-3368). Any plugin that sells through Stripe uses
24
25
  * the same two calls; nothing about the record's kind is known here.
25
26
  *
26
27
  * NOTHING IS REFUNDED OR CANCELED. The merchant decides for their own
@@ -28,7 +29,12 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
28
29
  *
29
30
  * Dependencies are passed in — the Firestore handle and the notifier — so a
30
31
  * plugin's spec that substitutes the admin barrel substitutes these too.
31
- *=========================================*/ import { addPaymentRiskSignal, NOTHING_MOVED, PAYMENT_RISK_FIELD, paymentRiskAdvice, paymentRiskTitle, settlePaymentRiskDispute } from "@aglyn/aglyn/app-utils/payment-risk";
32
+ *=========================================*/ import { addPaymentRiskSignal, PAYMENT_RISK_FIELD, settlePaymentRiskDispute } from "@aglyn/aglyn/app-utils/payment-risk";
33
+ /** The risk notice each Stripe signal on a sale is told as. */ export const PAYMENT_RISK_NOTICE_KINDS = {
34
+ 'early-fraud-warning': 'sale-fraud-warning',
35
+ 'radar-review': 'sale-payment-review',
36
+ dispute: 'sale-dispute'
37
+ };
32
38
  /**
33
39
  * Stamp `signal` on `input.ref` and notify, once. Returns true when this
34
40
  * delivery wrote it; false for a redelivery or a record that is gone.
@@ -47,12 +53,21 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
47
53
  }));
48
54
  return true;
49
55
  });
50
- if (written && input.hostId) {
51
- await deps.notify(input.hostId, {
52
- type: input.notificationType,
53
- title: paymentRiskTitle(input.signal.kind),
54
- body: `${input.subjectLabel}. ${paymentRiskAdvice(input.signal.kind)}` + (input.signal.detail ? ` Stripe says: ${input.signal.detail.replace(/_/g, ' ')}.` : '') + ` ${NOTHING_MOVED}`,
55
- link: input.link
56
+ if (written && (input.hostId || input.orgId)) {
57
+ var _input_noticeKind, _input_orgId, _input_amount, _input_evidenceDueByMs;
58
+ await deps.notifyRisk({
59
+ kind: (_input_noticeKind = input.noticeKind) != null ? _input_noticeKind : PAYMENT_RISK_NOTICE_KINDS[input.signal.kind],
60
+ orgId: (_input_orgId = input.orgId) != null ? _input_orgId : null,
61
+ hostId: input.hostId || null,
62
+ item: {
63
+ label: input.subjectLabel,
64
+ path: input.link
65
+ },
66
+ occurredAtMs: input.signal.atMs,
67
+ amount: (_input_amount = input.amount) != null ? _input_amount : null,
68
+ evidenceDueByMs: (_input_evidenceDueByMs = input.evidenceDueByMs) != null ? _input_evidenceDueByMs : null,
69
+ dedupeKey: `sale:${input.signal.stripeObjectId}`,
70
+ staffEvidence: input.signal.detail ? `Stripe says: ${input.signal.detail.replace(/_/g, ' ')}.` : null
56
71
  }).catch(()=>undefined);
57
72
  }
58
73
  return written;
@@ -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 once. 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 NOTHING_MOVED,\n PAYMENT_RISK_FIELD,\n type PaymentRisk,\n type PaymentRiskSignal,\n paymentRiskAdvice,\n paymentRiskTitle,\n settlePaymentRiskDispute,\n} from '@aglyn/aglyn/app-utils/payment-risk'\nimport type { AglynNotificationType } from '@aglyn/aglyn/app-utils/notifications'\n\n/** The notifier's shape: `notifyHostManagers` from the admin barrel. */\nexport type PaymentRiskNotifier = (\n hostId: string,\n payload: {\n type: AglynNotificationType\n title: string\n body: string\n link: string\n },\n) => Promise<void>\n\nexport interface RecordPaymentRiskInput {\n ref: FirebaseFirestore.DocumentReference\n signal: PaymentRiskSignal\n /** The site whose managers are told. Empty tells nobody. */\n hostId: string\n /** What the record is, as the merchant calls it: `Order 1042`, `Booking`. */\n subjectLabel: string\n /** The console path the notification opens. */\n link: string\n /** The plugin's own notification type: `content.order`, `content.booking`. */\n notificationType: AglynNotificationType\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 notify: 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) {\n await deps\n .notify(input.hostId, {\n type: input.notificationType,\n title: paymentRiskTitle(input.signal.kind),\n body:\n `${input.subjectLabel}. ${paymentRiskAdvice(input.signal.kind)}` +\n (input.signal.detail\n ? ` Stripe says: ${input.signal.detail.replace(/_/g, ' ')}.`\n : '') +\n ` ${NOTHING_MOVED}`,\n link: input.link,\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","NOTHING_MOVED","PAYMENT_RISK_FIELD","paymentRiskAdvice","paymentRiskTitle","settlePaymentRiskDispute","recordPaymentRiskOnRecord","input","deps","written","firestore","runTransaction","transaction","fresh","get","ref","exists","data","next","signal","update","extraUpdate","hostId","notify","type","notificationType","title","kind","body","subjectLabel","detail","replace","link","catch","undefined","settlePaymentRiskDisputeOnRecord","disputeId","outcome"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;2CAc2C,GAE3C,SACEA,oBAAoB,EACpBC,aAAa,EACbC,kBAAkB,EAGlBC,iBAAiB,EACjBC,gBAAgB,EAChBC,wBAAwB,QACnB,sCAAqC;AAgC5C;;;;;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,CAACf,mBAAmB,EACxBK,MAAMY,MAAM;QAEd,IAAI,CAACD,MAAM,OAAO;QAClBN,YAAYQ,MAAM,CAACb,MAAMQ,GAAG,EAAE,aACxBR,MAAMc,WAAW,GAAGd,MAAMc,WAAW,CAACJ,QAAQ,CAAC;YACnD,CAACf,mBAAmB,EAAEgB;;QAExB,OAAO;IACT;IACA,IAAIT,WAAWF,MAAMe,MAAM,EAAE;QAC3B,MAAMd,KACHe,MAAM,CAAChB,MAAMe,MAAM,EAAE;YACpBE,MAAMjB,MAAMkB,gBAAgB;YAC5BC,OAAOtB,iBAAiBG,MAAMY,MAAM,CAACQ,IAAI;YACzCC,MACE,GAAGrB,MAAMsB,YAAY,CAAC,EAAE,EAAE1B,kBAAkBI,MAAMY,MAAM,CAACQ,IAAI,GAAG,GAC/DpB,CAAAA,MAAMY,MAAM,CAACW,MAAM,GAChB,CAAC,cAAc,EAAEvB,MAAMY,MAAM,CAACW,MAAM,CAACC,OAAO,CAAC,MAAM,KAAK,CAAC,CAAC,GAC1D,EAAC,IACL,CAAC,CAAC,EAAE9B,eAAe;YACrB+B,MAAMzB,MAAMyB,IAAI;QAClB,GACCC,KAAK,CAAC,IAAMC;IACjB;IACA,OAAOzB;AACT;AAEA;;;CAGC,GACD,OAAO,eAAe0B,iCACpBpB,GAAwC,EACxCqB,SAAiB,EACjBC,OAAe,EACf7B,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,OAAOb,yBACXQ,MAAMC,GAAG,CAACZ,qBACVkC,WACAC;QAEF,IAAI,CAACnB,MAAM,OAAO;QAClBN,YAAYQ,MAAM,CAACL,KAAK;YAAE,CAACb,mBAAmB,EAAEgB;QAAK;QACrD,OAAO;IACT;AACF"}
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 { RiskEventKind } 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 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","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","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;AAY5C,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,OAAOjB,qBACXgB,IAAI,CAACf,mBAAmB,EACxBK,MAAMY,MAAM;QAEd,IAAI,CAACD,MAAM,OAAO;QAClBN,YAAYQ,MAAM,CAACb,MAAMQ,GAAG,EAAE,aACxBR,MAAMc,WAAW,GAAGd,MAAMc,WAAW,CAACJ,QAAQ,CAAC;YACnD,CAACf,mBAAmB,EAAEgB;;QAExB,OAAO;IACT;IACA,IAAIT,WAAYF,CAAAA,MAAMe,MAAM,IAAIf,MAAMgB,KAAK,AAAD,GAAI;YAGlChB,mBACCA,cAICA,eACSA;QARrB,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,eAAe,GAAE5B,yBAAAA,MAAM4B,eAAe,YAArB5B,yBAAyB;YAC1C6B,WAAW,CAAC,KAAK,EAAE7B,MAAMY,MAAM,CAACkB,cAAc,EAAE;YAChDC,eAAe/B,MAAMY,MAAM,CAACoB,MAAM,GAC9B,CAAC,aAAa,EAAEhC,MAAMY,MAAM,CAACoB,MAAM,CAACC,OAAO,CAAC,MAAM,KAAK,CAAC,CAAC,GACzD;QACN,GACCC,KAAK,CAAC,IAAMC;IACjB;IACA,OAAOjC;AACT;AAEA;;;CAGC,GACD,OAAO,eAAekC,iCACpB5B,GAAwC,EACxC6B,SAAiB,EACjBC,OAAe,EACfrC,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,OAAOf,yBACXU,MAAMC,GAAG,CAACZ,qBACV0C,WACAC;QAEF,IAAI,CAAC3B,MAAM,OAAO;QAClBN,YAAYQ,MAAM,CAACL,KAAK;YAAE,CAACb,mBAAmB,EAAEgB;QAAK;QACrD,OAAO;IACT;AACF"}
@@ -92,6 +92,9 @@ export declare function loadSystemEmail(templateKey: string): Promise<LoadedSyst
92
92
  * marketing site, or whose blocks could not be read. The chrome is in the
93
93
  * brand of the send — the org's email logo rides in its header, so a
94
94
  * white-label mail carries exactly one logo.
95
+ *
96
+ * Either way, a band the design draws itself is left out of the chrome
97
+ * (AGL-3372): a design that opens with its own Header gets the footer only.
95
98
  */
96
99
  export declare function renderLoadedSystemEmail(loaded: LoadedSystemEmail, merge?: Record<string, string>, options?: SystemEmailBrandOptions): RenderedSystemEmail | null;
97
100
  /**
@@ -15,7 +15,7 @@ var _process_env_NEXT_PUBLIC_CONSOLE_URL;
15
15
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
16
  * See the License for the specific language governing permissions and
17
17
  * limitations under the License.
18
- */ import { EMAIL_NODE_ROOT_ID, SYSTEM_EMAIL_COLLECTION, buildDefaultEmailNodeMap, getSystemEmailTemplate, renderEmailHtml, substituteMergeTokens } from "@aglyn/shared-util-email";
18
+ */ import { EMAIL_NODE_ROOT_ID, PLATFORM_EMAIL_PALETTE, chromeForDesign, SYSTEM_EMAIL_COLLECTION, buildEmailPalette, buildDefaultEmailNodeMap, getSystemEmailTemplate, renderEmailHtml, substituteMergeTokens } from "@aglyn/shared-util-email";
19
19
  import { firebaseAdmin } from "./firebase-admin.js";
20
20
  import { platformMarketingHostId } from "./platform-marketing-consent.js";
21
21
  import { brandMergeTokens, collectReferencedComponentIds, composeReusableComponentNodes, decodeStoredNodes, resolveBrandingProfile, sanitizeAuthorHtml } from "@aglyn/aglyn/server";
@@ -168,6 +168,21 @@ export { isPlatformBrandedSend };
168
168
  return catalogDefault(definition);
169
169
  }
170
170
  }
171
+ /**
172
+ * The theme a system email is drawn in (AGL-3370): the console's palette,
173
+ * or, for a white-label org that set a brand color, the same palette with
174
+ * that color as its primary. Buttons use the console's corner radius.
175
+ */ function systemEmailTheme(options) {
176
+ var _options_brandPrimaryColor;
177
+ const primaryColor = String((_options_brandPrimaryColor = options.brandPrimaryColor) != null ? _options_brandPrimaryColor : '').trim();
178
+ return {
179
+ palette: primaryColor ? buildEmailPalette({
180
+ base: 'platform',
181
+ primaryColor
182
+ }) : PLATFORM_EMAIL_PALETTE,
183
+ buttonRadius: 4
184
+ };
185
+ }
171
186
  /**
172
187
  * Renders a loaded email for one recipient's merge values (AGL-768). No
173
188
  * Firestore access — call {@link loadSystemEmail} once, then this per
@@ -186,12 +201,29 @@ export { isPlatformBrandedSend };
186
201
  * marketing site, or whose blocks could not be read. The chrome is in the
187
202
  * brand of the send — the org's email logo rides in its header, so a
188
203
  * white-label mail carries exactly one logo.
204
+ *
205
+ * Either way, a band the design draws itself is left out of the chrome
206
+ * (AGL-3372): a design that opens with its own Header gets the footer only.
189
207
  */ export function renderLoadedSystemEmail(loaded, merge = {}, options = {}) {
190
208
  var _rendered_text;
191
209
  const merged = _extends({}, PLATFORM_BRAND_MERGE_TOKENS, merge);
192
210
  const blocks = drawsPlatformBlocks(loaded, merged, options);
211
+ const nodes = blocks ? composeReusableComponentNodes(loaded.nodes, loaded.components) : loaded.nodes;
212
+ // The brand's chrome around every copy, less any band the design draws
213
+ // itself (AGL-3372). Unexpanded placements draw nothing in this send, so
214
+ // they only count when the blocks are drawn.
215
+ const chrome = chromeForDesign({
216
+ stored: blocks ? loaded.nodes : null,
217
+ composed: nodes,
218
+ chrome: buildSystemEmailChrome({
219
+ definition: loaded.definition,
220
+ merged,
221
+ brandLogoUrl: options.brandLogoUrl,
222
+ brandHomeUrl: options.brandHomeUrl
223
+ })
224
+ });
193
225
  const rendered = renderEmailHtml(_extends({
194
- nodes: blocks ? composeReusableComponentNodes(loaded.nodes, loaded.components) : loaded.nodes,
226
+ nodes: nodes,
195
227
  // Besigner maps are rooted at '_@_', not renderEmailHtml's default
196
228
  // 'root' — without this a designed template rendered empty and the send
197
229
  // fell back to built-in copy, so designing did nothing (AGL-765).
@@ -208,14 +240,9 @@ export { isPlatformBrandedSend };
208
240
  // which is a broken-image box in the recipient's inbox (AGL-1224). The
209
241
  // chrome's logo resolves against it too.
210
242
  mediaOrigin: CONSOLE_ORIGIN
211
- }, blocks ? {} : {
212
- chrome: buildSystemEmailChrome({
213
- definition: loaded.definition,
214
- merged,
215
- brandLogoUrl: options.brandLogoUrl,
216
- brandHomeUrl: options.brandHomeUrl
217
- })
218
- }));
243
+ }, systemEmailTheme(options), chrome ? {
244
+ chrome
245
+ } : {}));
219
246
  if (!(rendered == null ? void 0 : rendered.html)) return null;
220
247
  return {
221
248
  subject: blankUnresolvedTokens(substituteMergeTokens(loaded.subjectTemplate, merged)),
@@ -265,7 +292,8 @@ export { isPlatformBrandedSend };
265
292
  merge: brandMergeTokens(branding),
266
293
  options: {
267
294
  brandLogoUrl: branding.emailLogoUrl,
268
- brandHomeUrl: branding.homeUrl
295
+ brandHomeUrl: branding.homeUrl,
296
+ brandPrimaryColor: branding.primaryColor
269
297
  },
270
298
  fromName: branding.fromName
271
299
  });