@aglyn/tenant-data-admin 1.0.0-beta.247 → 1.0.0-beta.250
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 +7 -7
- package/src/lib/server/payment-fraud-hold.d.ts +163 -0
- package/src/lib/server/payment-fraud-hold.js +283 -0
- package/src/lib/server/payment-fraud-hold.js.map +1 -0
- package/src/lib/server/payment-fraud-signal.d.ts +25 -0
- package/src/lib/server/payment-fraud-signal.js +58 -18
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/tenant-data-admin",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.250",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,12 +25,12 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-util-fbserver": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-util-first-touch": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.250",
|
|
29
|
+
"@aglyn/shared-util-email": "1.0.0-beta.250",
|
|
30
|
+
"@aglyn/shared-util-fbserver": "1.0.0-beta.250",
|
|
31
|
+
"@aglyn/shared-util-first-touch": "1.0.0-beta.250",
|
|
32
|
+
"@aglyn/shared-util-http": "1.0.0-beta.250",
|
|
33
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.250",
|
|
34
34
|
"@msgpack/msgpack": "^3.1.3",
|
|
35
35
|
"@swc/helpers": "0.5.23",
|
|
36
36
|
"sharp": "^0.35.5",
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/** One document per workspace. Admin SDK only; no client rule opens it. */
|
|
18
|
+
export declare const PAYMENT_FRAUD_HOLD_COLLECTION = "paymentFraudHolds";
|
|
19
|
+
/** The actor every automatic payment-fraud lock's audit rows name. */
|
|
20
|
+
export declare const PAYMENT_FRAUD_HOLD_ACTOR_UID = "system:payment-fraud";
|
|
21
|
+
/** The lock reason the hold places. Never `abuse`: see the module header. */
|
|
22
|
+
export declare const PAYMENT_FRAUD_HOLD_LOCK_REASON = "security";
|
|
23
|
+
export declare const PAYMENT_FRAUD_HOLD: {
|
|
24
|
+
/** Distinct card fingerprints failing on one workspace… */
|
|
25
|
+
readonly cyclingMinCards: 3;
|
|
26
|
+
/** …within this window. */
|
|
27
|
+
readonly cyclingWindowMs: number;
|
|
28
|
+
/** A workspace this many days old or older gets an alert, not a lock. */
|
|
29
|
+
readonly youngDays: 14;
|
|
30
|
+
/** How long a failed charge is kept on the workspace's document. */
|
|
31
|
+
readonly retainMs: number;
|
|
32
|
+
/** And at most this many of them. */
|
|
33
|
+
readonly maxFailures: 30;
|
|
34
|
+
/** After an alert-only decision, how long before another may be raised. */
|
|
35
|
+
readonly alertWindowMs: number;
|
|
36
|
+
/** How long one run may hold a claim before another may take it over. */
|
|
37
|
+
readonly claimLeaseMs: number;
|
|
38
|
+
};
|
|
39
|
+
/** Why a hold was asked for. */
|
|
40
|
+
export type PaymentFraudHoldTrigger = 'radar-blocked' | 'stripe-reported-fraud' | 'card-cycling' | 'early-fraud-warning' | 'radar-review';
|
|
41
|
+
export declare const PAYMENT_FRAUD_HOLD_TRIGGER_LABELS: Record<PaymentFraudHoldTrigger, string>;
|
|
42
|
+
/** What one charge says about the card, kept as evidence. */
|
|
43
|
+
export interface PaymentFraudChargeEvidence {
|
|
44
|
+
chargeId: string;
|
|
45
|
+
paymentIntentId: string | null;
|
|
46
|
+
invoiceId: string | null;
|
|
47
|
+
/** The card's fingerprint: the same card number always has the same one. */
|
|
48
|
+
fingerprint: string | null;
|
|
49
|
+
cardCountry: string | null;
|
|
50
|
+
cardBrand: string | null;
|
|
51
|
+
last4: string | null;
|
|
52
|
+
/** `outcome.type`: `blocked`, `issuer_declined`, `authorized`… */
|
|
53
|
+
outcomeType: string | null;
|
|
54
|
+
/** `outcome.reason`: `highest_risk_level`, `rule`… */
|
|
55
|
+
outcomeReason: string | null;
|
|
56
|
+
riskLevel: string | null;
|
|
57
|
+
riskScore: number | null;
|
|
58
|
+
/** `fraud_details.stripe_report`. */
|
|
59
|
+
stripeReport: string | null;
|
|
60
|
+
failureCode: string | null;
|
|
61
|
+
declineCode: string | null;
|
|
62
|
+
postalCheck: string | null;
|
|
63
|
+
cvcCheck: string | null;
|
|
64
|
+
amountCents: number | null;
|
|
65
|
+
currency: string;
|
|
66
|
+
atMs: number;
|
|
67
|
+
}
|
|
68
|
+
/** A Stripe Charge object → the evidence kept about it. Pure. */
|
|
69
|
+
export declare function chargeEvidence(charge: any, atMs: number): PaymentFraudChargeEvidence;
|
|
70
|
+
/**
|
|
71
|
+
* Does this ONE charge ask for a hold? Stripe's own fraud report first —
|
|
72
|
+
* it is the stronger statement — then a Radar block at `highest` risk. A
|
|
73
|
+
* block at `elevated` risk, or by a custom rule, is not enough on its own:
|
|
74
|
+
* those catch real customers too, and card cycling is what catches the
|
|
75
|
+
* tester behind them.
|
|
76
|
+
*/
|
|
77
|
+
export declare function chargeFraudTrigger(evidence: Pick<PaymentFraudChargeEvidence, 'stripeReport' | 'outcomeType' | 'riskLevel'>): PaymentFraudHoldTrigger | null;
|
|
78
|
+
/** Whether the failures show card cycling at `nowMs`. Pure. */
|
|
79
|
+
export declare function cardCyclingPattern(failures: readonly PaymentFraudChargeEvidence[], nowMs: number): {
|
|
80
|
+
matched: boolean;
|
|
81
|
+
fingerprints: string[];
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* The guards (AGL-3767): a workspace 14+ days old, or one that has paid an
|
|
85
|
+
* invoice other than with the flagged charges, is alerted about and never
|
|
86
|
+
* locked automatically. An unreadable creation date is an existing customer,
|
|
87
|
+
* as everywhere else on the platform (`isYoungWorkspaceAge`). Pure.
|
|
88
|
+
*/
|
|
89
|
+
export declare function paymentFraudHoldGuard(input: {
|
|
90
|
+
ageDays: number | null;
|
|
91
|
+
hasPaidInvoice: boolean;
|
|
92
|
+
}): {
|
|
93
|
+
lock: boolean;
|
|
94
|
+
reason: string | null;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Has this workspace paid an invoice with any charge OTHER than the flagged
|
|
98
|
+
* ones? Read off `platformRevenue`, which holds a row for every paid
|
|
99
|
+
* subscription invoice (AGL-1811). The flagged charges are excluded because
|
|
100
|
+
* a stolen card that went through has "paid" too — an early fraud warning
|
|
101
|
+
* only ever arrives on a charge that succeeded.
|
|
102
|
+
*/
|
|
103
|
+
export declare function hasPaidOtherInvoice(firestore: FirebaseFirestore.Firestore, orgId: string, flagged: {
|
|
104
|
+
chargeIds: readonly string[];
|
|
105
|
+
paymentIntentIds: readonly string[];
|
|
106
|
+
}): Promise<boolean>;
|
|
107
|
+
/**
|
|
108
|
+
* - `watching` — failures recorded, nothing asked for.
|
|
109
|
+
* - `placing` — a run claimed the hold.
|
|
110
|
+
* - `held` — locks placed. Never placed again automatically.
|
|
111
|
+
* - `alerted` — a guard made it an alert only; another alert waits out the window.
|
|
112
|
+
* - `skipped` — never held: the house, a staff-owned workspace, one that is gone.
|
|
113
|
+
*/
|
|
114
|
+
export type PaymentFraudHoldState = 'watching' | 'placing' | 'held' | 'alerted' | 'skipped';
|
|
115
|
+
export interface PaymentFraudHoldRecord {
|
|
116
|
+
orgId: string;
|
|
117
|
+
state: PaymentFraudHoldState;
|
|
118
|
+
failures: PaymentFraudChargeEvidence[];
|
|
119
|
+
claimedAtMs?: number | null;
|
|
120
|
+
heldAtMs?: number | null;
|
|
121
|
+
alertedAtMs?: number | null;
|
|
122
|
+
trigger?: PaymentFraudHoldTrigger | null;
|
|
123
|
+
reviewId?: string | null;
|
|
124
|
+
outcome?: Record<string, unknown> | null;
|
|
125
|
+
updatedAtMs?: number;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Keep one failed charge on its workspace's document and say whether the
|
|
129
|
+
* failures now show card cycling. Idempotent on the charge id: a redelivery
|
|
130
|
+
* adds nothing and reports `added: false`.
|
|
131
|
+
*/
|
|
132
|
+
export declare function recordWorkspaceChargeFailure(firestore: FirebaseFirestore.Firestore, orgId: string, evidence: PaymentFraudChargeEvidence, nowMs?: number): Promise<{
|
|
133
|
+
added: boolean;
|
|
134
|
+
cycling: {
|
|
135
|
+
matched: boolean;
|
|
136
|
+
fingerprints: string[];
|
|
137
|
+
};
|
|
138
|
+
/** The failures inside the cycling window, oldest first, this one included. */
|
|
139
|
+
recent: PaymentFraudChargeEvidence[];
|
|
140
|
+
state: PaymentFraudHoldState;
|
|
141
|
+
}>;
|
|
142
|
+
/**
|
|
143
|
+
* Claim the workspace's hold for this run. Answers `claimed: false` with the
|
|
144
|
+
* reason when the hold is already placed, another run is placing it inside
|
|
145
|
+
* its lease, or a guard already alerted inside the alert window — which is
|
|
146
|
+
* what makes a redelivery, or the next card in a burst, a no-op.
|
|
147
|
+
*/
|
|
148
|
+
export declare function claimPaymentFraudHold(firestore: FirebaseFirestore.Firestore, orgId: string, trigger: PaymentFraudHoldTrigger, nowMs?: number): Promise<{
|
|
149
|
+
claimed: boolean;
|
|
150
|
+
/** Why not, when `claimed` is false. */
|
|
151
|
+
reason: 'held' | 'placing' | 'alerted' | 'skipped' | null;
|
|
152
|
+
}>;
|
|
153
|
+
/** Record what the run did with a claimed hold. */
|
|
154
|
+
export declare function settlePaymentFraudHold(firestore: FirebaseFirestore.Firestore, orgId: string, state: 'held' | 'alerted' | 'skipped' | 'watching', fields: {
|
|
155
|
+
reviewId?: string | null;
|
|
156
|
+
outcome?: Record<string, unknown> | null;
|
|
157
|
+
}, nowMs?: number): Promise<void>;
|
|
158
|
+
/**
|
|
159
|
+
* Is this workspace on an automatic payment-fraud hold? What collapses the
|
|
160
|
+
* per-attempt `staff.paymentFailed` pings: once a workspace is held, the
|
|
161
|
+
* hold's one alert is the news, and each further declined card is not.
|
|
162
|
+
*/
|
|
163
|
+
export declare function isPaymentFraudHeld(firestore: FirebaseFirestore.Firestore, orgId: string): Promise<boolean>;
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ /*==========================================
|
|
18
|
+
* THE PAYMENT-FRAUD HOLD: THE DURABLE HALF (AGL-3767).
|
|
19
|
+
*
|
|
20
|
+
* When Stripe says a workspace's OWN subscription payment is fraud, the
|
|
21
|
+
* workspace is put on an automatic `security` hold: the workspace and each
|
|
22
|
+
* of its sites locked as a takedown, its subscriptions canceled, staff
|
|
23
|
+
* alerted once. Never `abuse`: that is a permanent ban, and Stripe's verdict
|
|
24
|
+
* is a strong signal about a card, not a decision about a person. Staff
|
|
25
|
+
* re-place the hold as `abuse` on Staff → Lockdown, or lift it.
|
|
26
|
+
*
|
|
27
|
+
* Three triggers, all on charges that resolve to a workspace through the
|
|
28
|
+
* `stripeCustomers` index — never a site's sale on a connected account:
|
|
29
|
+
*
|
|
30
|
+
* 1. one failed charge Radar BLOCKED at `highest` risk, or that Stripe
|
|
31
|
+
* itself reported as `fraudulent` ({@link chargeFraudTrigger});
|
|
32
|
+
* 2. CARD CYCLING: failed charges on {@link PAYMENT_FRAUD_HOLD}'s
|
|
33
|
+
* `cyclingMinCards` or more distinct card fingerprints within its
|
|
34
|
+
* `cyclingWindowMs` ({@link cardCyclingPattern}). A real customer whose
|
|
35
|
+
* card is declined tries it again, or tries one other card; three
|
|
36
|
+
* different cards inside half an hour on one invoice is a card tester
|
|
37
|
+
* working through a list. The workspace that prompted this (2026-10-13)
|
|
38
|
+
* put four cards on one $56 invoice in two minutes;
|
|
39
|
+
* 3. an issuer's early fraud warning or a Radar review on the workspace's
|
|
40
|
+
* charge — filed by `payment-fraud-signal.ts`, which asks for the hold.
|
|
41
|
+
*
|
|
42
|
+
* Two guards turn the hold into an alert only: a workspace 14 or more days
|
|
43
|
+
* old, or one that has paid an invoice with any charge other than the
|
|
44
|
+
* flagged ones. Those are customers with a history, and a person decides.
|
|
45
|
+
*
|
|
46
|
+
* This module is the state: one `paymentFraudHolds/{orgId}` document holding
|
|
47
|
+
* the workspace's recent failed charges (what card cycling counts) and the
|
|
48
|
+
* hold's own state, claimed in a transaction so a Stripe redelivery, or two
|
|
49
|
+
* events about one attempt arriving together, place one hold and raise one
|
|
50
|
+
* alert. The locks themselves are the console's
|
|
51
|
+
* (`apps/console/utils/server/payment-fraud-hold.ts`), because only the
|
|
52
|
+
* console holds the lockdown cores.
|
|
53
|
+
*=========================================*/ /** One document per workspace. Admin SDK only; no client rule opens it. */ export const PAYMENT_FRAUD_HOLD_COLLECTION = 'paymentFraudHolds';
|
|
54
|
+
/** The actor every automatic payment-fraud lock's audit rows name. */ export const PAYMENT_FRAUD_HOLD_ACTOR_UID = 'system:payment-fraud';
|
|
55
|
+
/** The lock reason the hold places. Never `abuse`: see the module header. */ export const PAYMENT_FRAUD_HOLD_LOCK_REASON = 'security';
|
|
56
|
+
export const PAYMENT_FRAUD_HOLD = {
|
|
57
|
+
/** Distinct card fingerprints failing on one workspace… */ cyclingMinCards: 3,
|
|
58
|
+
/** …within this window. */ cyclingWindowMs: 30 * 60000,
|
|
59
|
+
/** A workspace this many days old or older gets an alert, not a lock. */ youngDays: 14,
|
|
60
|
+
/** How long a failed charge is kept on the workspace's document. */ retainMs: 24 * 60 * 60000,
|
|
61
|
+
/** And at most this many of them. */ maxFailures: 30,
|
|
62
|
+
/** After an alert-only decision, how long before another may be raised. */ alertWindowMs: 24 * 60 * 60000,
|
|
63
|
+
/** How long one run may hold a claim before another may take it over. */ claimLeaseMs: 10 * 60000
|
|
64
|
+
};
|
|
65
|
+
export const PAYMENT_FRAUD_HOLD_TRIGGER_LABELS = {
|
|
66
|
+
'radar-blocked': 'Radar blocked a payment at highest risk',
|
|
67
|
+
'stripe-reported-fraud': 'Stripe reported a payment as fraudulent',
|
|
68
|
+
'card-cycling': `${PAYMENT_FRAUD_HOLD.cyclingMinCards} or more different cards failed within ${PAYMENT_FRAUD_HOLD.cyclingWindowMs / 60000} minutes`,
|
|
69
|
+
'early-fraud-warning': 'An issuer sent an early fraud warning',
|
|
70
|
+
'radar-review': 'Radar opened a review'
|
|
71
|
+
};
|
|
72
|
+
function text(value) {
|
|
73
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
74
|
+
}
|
|
75
|
+
function refOf(value) {
|
|
76
|
+
if (typeof value === 'string') return value || null;
|
|
77
|
+
return text(value == null ? void 0 : value.id);
|
|
78
|
+
}
|
|
79
|
+
/** A Stripe Charge object → the evidence kept about it. Pure. */ export function chargeEvidence(charge, atMs) {
|
|
80
|
+
var _ref, _ref1, _ref2, _ref3;
|
|
81
|
+
var _charge_payment_method_details, _charge_fraud_details, _card_checks, _card_checks1;
|
|
82
|
+
const card = (_ref = charge == null ? void 0 : (_charge_payment_method_details = charge.payment_method_details) == null ? void 0 : _charge_payment_method_details.card) != null ? _ref : {};
|
|
83
|
+
const outcome = (_ref1 = charge == null ? void 0 : charge.outcome) != null ? _ref1 : {};
|
|
84
|
+
const amount = Number(charge == null ? void 0 : charge.amount);
|
|
85
|
+
const score = Number(outcome == null ? void 0 : outcome.risk_score);
|
|
86
|
+
return {
|
|
87
|
+
chargeId: String((_ref2 = charge == null ? void 0 : charge.id) != null ? _ref2 : ''),
|
|
88
|
+
paymentIntentId: refOf(charge == null ? void 0 : charge.payment_intent),
|
|
89
|
+
invoiceId: refOf(charge == null ? void 0 : charge.invoice),
|
|
90
|
+
fingerprint: text(card == null ? void 0 : card.fingerprint),
|
|
91
|
+
cardCountry: text(card == null ? void 0 : card.country),
|
|
92
|
+
cardBrand: text(card == null ? void 0 : card.brand),
|
|
93
|
+
last4: text(card == null ? void 0 : card.last4),
|
|
94
|
+
outcomeType: text(outcome == null ? void 0 : outcome.type),
|
|
95
|
+
outcomeReason: text(outcome == null ? void 0 : outcome.reason),
|
|
96
|
+
riskLevel: text(outcome == null ? void 0 : outcome.risk_level),
|
|
97
|
+
riskScore: (outcome == null ? void 0 : outcome.risk_score) == null || !Number.isFinite(score) ? null : score,
|
|
98
|
+
stripeReport: text(charge == null ? void 0 : (_charge_fraud_details = charge.fraud_details) == null ? void 0 : _charge_fraud_details.stripe_report),
|
|
99
|
+
failureCode: text(charge == null ? void 0 : charge.failure_code),
|
|
100
|
+
declineCode: text(outcome == null ? void 0 : outcome.network_decline_code),
|
|
101
|
+
postalCheck: text(card == null ? void 0 : (_card_checks = card.checks) == null ? void 0 : _card_checks.address_postal_code_check),
|
|
102
|
+
cvcCheck: text(card == null ? void 0 : (_card_checks1 = card.checks) == null ? void 0 : _card_checks1.cvc_check),
|
|
103
|
+
amountCents: Number.isFinite(amount) ? amount : null,
|
|
104
|
+
currency: String((_ref3 = charge == null ? void 0 : charge.currency) != null ? _ref3 : 'usd'),
|
|
105
|
+
atMs
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Does this ONE charge ask for a hold? Stripe's own fraud report first —
|
|
110
|
+
* it is the stronger statement — then a Radar block at `highest` risk. A
|
|
111
|
+
* block at `elevated` risk, or by a custom rule, is not enough on its own:
|
|
112
|
+
* those catch real customers too, and card cycling is what catches the
|
|
113
|
+
* tester behind them.
|
|
114
|
+
*/ export function chargeFraudTrigger(evidence) {
|
|
115
|
+
if (evidence.stripeReport === 'fraudulent') return 'stripe-reported-fraud';
|
|
116
|
+
if (evidence.outcomeType === 'blocked' && evidence.riskLevel === 'highest') return 'radar-blocked';
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
/** Whether the failures show card cycling at `nowMs`. Pure. */ export function cardCyclingPattern(failures, nowMs) {
|
|
120
|
+
const since = nowMs - PAYMENT_FRAUD_HOLD.cyclingWindowMs;
|
|
121
|
+
const fingerprints = [
|
|
122
|
+
...new Set(failures.filter((entry)=>entry.atMs >= since && entry.atMs <= nowMs && entry.fingerprint).map((entry)=>entry.fingerprint))
|
|
123
|
+
];
|
|
124
|
+
return {
|
|
125
|
+
matched: fingerprints.length >= PAYMENT_FRAUD_HOLD.cyclingMinCards,
|
|
126
|
+
fingerprints
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The guards (AGL-3767): a workspace 14+ days old, or one that has paid an
|
|
131
|
+
* invoice other than with the flagged charges, is alerted about and never
|
|
132
|
+
* locked automatically. An unreadable creation date is an existing customer,
|
|
133
|
+
* as everywhere else on the platform (`isYoungWorkspaceAge`). Pure.
|
|
134
|
+
*/ export function paymentFraudHoldGuard(input) {
|
|
135
|
+
if (input.hasPaidInvoice) {
|
|
136
|
+
return {
|
|
137
|
+
lock: false,
|
|
138
|
+
reason: 'The workspace has paid an invoice before, so a person decides.'
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (typeof input.ageDays !== 'number' || !Number.isFinite(input.ageDays) || input.ageDays >= PAYMENT_FRAUD_HOLD.youngDays) {
|
|
142
|
+
return {
|
|
143
|
+
lock: false,
|
|
144
|
+
reason: input.ageDays === null || !Number.isFinite(input.ageDays) ? 'The workspace has no readable creation date, so it is treated as an existing customer.' : `The workspace is ${Math.floor(input.ageDays)} days old (${PAYMENT_FRAUD_HOLD.youngDays} or more), so a person decides.`
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
lock: true,
|
|
149
|
+
reason: null
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Has this workspace paid an invoice with any charge OTHER than the flagged
|
|
154
|
+
* ones? Read off `platformRevenue`, which holds a row for every paid
|
|
155
|
+
* subscription invoice (AGL-1811). The flagged charges are excluded because
|
|
156
|
+
* a stolen card that went through has "paid" too — an early fraud warning
|
|
157
|
+
* only ever arrives on a charge that succeeded.
|
|
158
|
+
*/ export async function hasPaidOtherInvoice(firestore, orgId, flagged) {
|
|
159
|
+
const rows = await firestore.collection('platformRevenue').where('orgId', '==', orgId).limit(25).get();
|
|
160
|
+
return rows.docs.some((row)=>{
|
|
161
|
+
if (!(Number(row.get('grossCents')) > 0)) return false;
|
|
162
|
+
const chargeId = text(row.get('chargeId'));
|
|
163
|
+
const paymentIntentId = text(row.get('paymentIntentId'));
|
|
164
|
+
if (chargeId && flagged.chargeIds.includes(chargeId)) return false;
|
|
165
|
+
if (paymentIntentId && flagged.paymentIntentIds.includes(paymentIntentId)) return false;
|
|
166
|
+
return true;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
function holdRef(firestore, orgId) {
|
|
170
|
+
return firestore.collection(PAYMENT_FRAUD_HOLD_COLLECTION).doc(orgId);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Keep one failed charge on its workspace's document and say whether the
|
|
174
|
+
* failures now show card cycling. Idempotent on the charge id: a redelivery
|
|
175
|
+
* adds nothing and reports `added: false`.
|
|
176
|
+
*/ export async function recordWorkspaceChargeFailure(firestore, orgId, evidence, nowMs = Date.now()) {
|
|
177
|
+
const ref = holdRef(firestore, orgId);
|
|
178
|
+
return firestore.runTransaction(async (transaction)=>{
|
|
179
|
+
var _snapshot_data, _stored_state;
|
|
180
|
+
const snapshot = await transaction.get(ref);
|
|
181
|
+
const stored = snapshot.exists ? (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {} : {};
|
|
182
|
+
const previous = Array.isArray(stored.failures) ? stored.failures : [];
|
|
183
|
+
const state = (_stored_state = stored.state) != null ? _stored_state : 'watching';
|
|
184
|
+
const added = !previous.some((entry)=>entry.chargeId === evidence.chargeId);
|
|
185
|
+
const failures = added ? [
|
|
186
|
+
...previous.filter((entry)=>entry.atMs >= nowMs - PAYMENT_FRAUD_HOLD.retainMs),
|
|
187
|
+
evidence
|
|
188
|
+
].slice(-PAYMENT_FRAUD_HOLD.maxFailures) : previous;
|
|
189
|
+
if (added) {
|
|
190
|
+
transaction.set(ref, _extends({
|
|
191
|
+
orgId,
|
|
192
|
+
failures
|
|
193
|
+
}, snapshot.exists ? {} : {
|
|
194
|
+
state: 'watching'
|
|
195
|
+
}, {
|
|
196
|
+
updatedAtMs: nowMs
|
|
197
|
+
}), {
|
|
198
|
+
merge: true
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
added,
|
|
203
|
+
cycling: cardCyclingPattern(failures, nowMs),
|
|
204
|
+
recent: failures.filter((entry)=>entry.atMs >= nowMs - PAYMENT_FRAUD_HOLD.cyclingWindowMs),
|
|
205
|
+
state
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Claim the workspace's hold for this run. Answers `claimed: false` with the
|
|
211
|
+
* reason when the hold is already placed, another run is placing it inside
|
|
212
|
+
* its lease, or a guard already alerted inside the alert window — which is
|
|
213
|
+
* what makes a redelivery, or the next card in a burst, a no-op.
|
|
214
|
+
*/ export async function claimPaymentFraudHold(firestore, orgId, trigger, nowMs = Date.now()) {
|
|
215
|
+
const ref = holdRef(firestore, orgId);
|
|
216
|
+
return firestore.runTransaction(async (transaction)=>{
|
|
217
|
+
var _snapshot_data, _record_state;
|
|
218
|
+
const snapshot = await transaction.get(ref);
|
|
219
|
+
const record = snapshot.exists ? (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {} : {};
|
|
220
|
+
const state = (_record_state = record.state) != null ? _record_state : 'watching';
|
|
221
|
+
if (state === 'held') return {
|
|
222
|
+
claimed: false,
|
|
223
|
+
reason: 'held'
|
|
224
|
+
};
|
|
225
|
+
if (state === 'skipped') return {
|
|
226
|
+
claimed: false,
|
|
227
|
+
reason: 'skipped'
|
|
228
|
+
};
|
|
229
|
+
if (state === 'placing' && typeof record.claimedAtMs === 'number' && nowMs - record.claimedAtMs < PAYMENT_FRAUD_HOLD.claimLeaseMs) {
|
|
230
|
+
return {
|
|
231
|
+
claimed: false,
|
|
232
|
+
reason: 'placing'
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (state === 'alerted' && typeof record.alertedAtMs === 'number' && nowMs - record.alertedAtMs < PAYMENT_FRAUD_HOLD.alertWindowMs) {
|
|
236
|
+
return {
|
|
237
|
+
claimed: false,
|
|
238
|
+
reason: 'alerted'
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
transaction.set(ref, {
|
|
242
|
+
orgId,
|
|
243
|
+
state: 'placing',
|
|
244
|
+
claimedAtMs: nowMs,
|
|
245
|
+
trigger,
|
|
246
|
+
updatedAtMs: nowMs
|
|
247
|
+
}, {
|
|
248
|
+
merge: true
|
|
249
|
+
});
|
|
250
|
+
return {
|
|
251
|
+
claimed: true,
|
|
252
|
+
reason: null
|
|
253
|
+
};
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
/** Record what the run did with a claimed hold. */ export async function settlePaymentFraudHold(firestore, orgId, state, fields, nowMs = Date.now()) {
|
|
257
|
+
await holdRef(firestore, orgId).set(_extends({
|
|
258
|
+
state
|
|
259
|
+
}, state === 'held' ? {
|
|
260
|
+
heldAtMs: nowMs
|
|
261
|
+
} : {}, state === 'alerted' ? {
|
|
262
|
+
alertedAtMs: nowMs
|
|
263
|
+
} : {}, fields.reviewId !== undefined ? {
|
|
264
|
+
reviewId: fields.reviewId
|
|
265
|
+
} : {}, fields.outcome !== undefined ? {
|
|
266
|
+
outcome: fields.outcome
|
|
267
|
+
} : {}, {
|
|
268
|
+
claimedAtMs: null,
|
|
269
|
+
updatedAtMs: nowMs
|
|
270
|
+
}), {
|
|
271
|
+
merge: true
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Is this workspace on an automatic payment-fraud hold? What collapses the
|
|
276
|
+
* per-attempt `staff.paymentFailed` pings: once a workspace is held, the
|
|
277
|
+
* hold's one alert is the news, and each further declined card is not.
|
|
278
|
+
*/ export async function isPaymentFraudHeld(firestore, orgId) {
|
|
279
|
+
const snapshot = await holdRef(firestore, orgId).get();
|
|
280
|
+
return snapshot.exists && snapshot.get('state') === 'held';
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
//# sourceMappingURL=payment-fraud-hold.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/payment-fraud-hold.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 * THE PAYMENT-FRAUD HOLD: THE DURABLE HALF (AGL-3767).\n *\n * When Stripe says a workspace's OWN subscription payment is fraud, the\n * workspace is put on an automatic `security` hold: the workspace and each\n * of its sites locked as a takedown, its subscriptions canceled, staff\n * alerted once. Never `abuse`: that is a permanent ban, and Stripe's verdict\n * is a strong signal about a card, not a decision about a person. Staff\n * re-place the hold as `abuse` on Staff → Lockdown, or lift it.\n *\n * Three triggers, all on charges that resolve to a workspace through the\n * `stripeCustomers` index — never a site's sale on a connected account:\n *\n * 1. one failed charge Radar BLOCKED at `highest` risk, or that Stripe\n * itself reported as `fraudulent` ({@link chargeFraudTrigger});\n * 2. CARD CYCLING: failed charges on {@link PAYMENT_FRAUD_HOLD}'s\n * `cyclingMinCards` or more distinct card fingerprints within its\n * `cyclingWindowMs` ({@link cardCyclingPattern}). A real customer whose\n * card is declined tries it again, or tries one other card; three\n * different cards inside half an hour on one invoice is a card tester\n * working through a list. The workspace that prompted this (2026-10-13)\n * put four cards on one $56 invoice in two minutes;\n * 3. an issuer's early fraud warning or a Radar review on the workspace's\n * charge — filed by `payment-fraud-signal.ts`, which asks for the hold.\n *\n * Two guards turn the hold into an alert only: a workspace 14 or more days\n * old, or one that has paid an invoice with any charge other than the\n * flagged ones. Those are customers with a history, and a person decides.\n *\n * This module is the state: one `paymentFraudHolds/{orgId}` document holding\n * the workspace's recent failed charges (what card cycling counts) and the\n * hold's own state, claimed in a transaction so a Stripe redelivery, or two\n * events about one attempt arriving together, place one hold and raise one\n * alert. The locks themselves are the console's\n * (`apps/console/utils/server/payment-fraud-hold.ts`), because only the\n * console holds the lockdown cores.\n *=========================================*/\n\n/** One document per workspace. Admin SDK only; no client rule opens it. */\nexport const PAYMENT_FRAUD_HOLD_COLLECTION = 'paymentFraudHolds'\n\n/** The actor every automatic payment-fraud lock's audit rows name. */\nexport const PAYMENT_FRAUD_HOLD_ACTOR_UID = 'system:payment-fraud'\n\n/** The lock reason the hold places. Never `abuse`: see the module header. */\nexport const PAYMENT_FRAUD_HOLD_LOCK_REASON = 'security'\n\nexport const PAYMENT_FRAUD_HOLD = {\n /** Distinct card fingerprints failing on one workspace… */\n cyclingMinCards: 3,\n /** …within this window. */\n cyclingWindowMs: 30 * 60_000,\n /** A workspace this many days old or older gets an alert, not a lock. */\n youngDays: 14,\n /** How long a failed charge is kept on the workspace's document. */\n retainMs: 24 * 60 * 60_000,\n /** And at most this many of them. */\n maxFailures: 30,\n /** After an alert-only decision, how long before another may be raised. */\n alertWindowMs: 24 * 60 * 60_000,\n /** How long one run may hold a claim before another may take it over. */\n claimLeaseMs: 10 * 60_000,\n} as const\n\n/** Why a hold was asked for. */\nexport type PaymentFraudHoldTrigger =\n | 'radar-blocked'\n | 'stripe-reported-fraud'\n | 'card-cycling'\n | 'early-fraud-warning'\n | 'radar-review'\n\nexport const PAYMENT_FRAUD_HOLD_TRIGGER_LABELS: Record<PaymentFraudHoldTrigger, string> = {\n 'radar-blocked': 'Radar blocked a payment at highest risk',\n 'stripe-reported-fraud': 'Stripe reported a payment as fraudulent',\n 'card-cycling': `${PAYMENT_FRAUD_HOLD.cyclingMinCards} or more different cards failed within ${\n PAYMENT_FRAUD_HOLD.cyclingWindowMs / 60_000\n } minutes`,\n 'early-fraud-warning': 'An issuer sent an early fraud warning',\n 'radar-review': 'Radar opened a review',\n}\n\n/** What one charge says about the card, kept as evidence. */\nexport interface PaymentFraudChargeEvidence {\n chargeId: string\n paymentIntentId: string | null\n invoiceId: string | null\n /** The card's fingerprint: the same card number always has the same one. */\n fingerprint: string | null\n cardCountry: string | null\n cardBrand: string | null\n last4: string | null\n /** `outcome.type`: `blocked`, `issuer_declined`, `authorized`… */\n outcomeType: string | null\n /** `outcome.reason`: `highest_risk_level`, `rule`… */\n outcomeReason: string | null\n riskLevel: string | null\n riskScore: number | null\n /** `fraud_details.stripe_report`. */\n stripeReport: string | null\n failureCode: string | null\n declineCode: string | null\n postalCheck: string | null\n cvcCheck: string | null\n amountCents: number | null\n currency: string\n atMs: number\n}\n\nfunction text(value: unknown): string | null {\n return typeof value === 'string' && value.trim() ? value.trim() : null\n}\n\nfunction refOf(value: unknown): string | null {\n if (typeof value === 'string') return value || null\n return text((value as { id?: unknown } | null)?.id)\n}\n\n/** A Stripe Charge object → the evidence kept about it. Pure. */\nexport function chargeEvidence(charge: any, atMs: number): PaymentFraudChargeEvidence {\n const card = charge?.payment_method_details?.card ?? {}\n const outcome = charge?.outcome ?? {}\n const amount = Number(charge?.amount)\n const score = Number(outcome?.risk_score)\n return {\n chargeId: String(charge?.id ?? ''),\n paymentIntentId: refOf(charge?.payment_intent),\n invoiceId: refOf(charge?.invoice),\n fingerprint: text(card?.fingerprint),\n cardCountry: text(card?.country),\n cardBrand: text(card?.brand),\n last4: text(card?.last4),\n outcomeType: text(outcome?.type),\n outcomeReason: text(outcome?.reason),\n riskLevel: text(outcome?.risk_level),\n riskScore: outcome?.risk_score == null || !Number.isFinite(score) ? null : score,\n stripeReport: text(charge?.fraud_details?.stripe_report),\n failureCode: text(charge?.failure_code),\n declineCode: text(outcome?.network_decline_code),\n postalCheck: text(card?.checks?.address_postal_code_check),\n cvcCheck: text(card?.checks?.cvc_check),\n amountCents: Number.isFinite(amount) ? amount : null,\n currency: String(charge?.currency ?? 'usd'),\n atMs,\n }\n}\n\n/**\n * Does this ONE charge ask for a hold? Stripe's own fraud report first —\n * it is the stronger statement — then a Radar block at `highest` risk. A\n * block at `elevated` risk, or by a custom rule, is not enough on its own:\n * those catch real customers too, and card cycling is what catches the\n * tester behind them.\n */\nexport function chargeFraudTrigger(\n evidence: Pick<PaymentFraudChargeEvidence, 'stripeReport' | 'outcomeType' | 'riskLevel'>,\n): PaymentFraudHoldTrigger | null {\n if (evidence.stripeReport === 'fraudulent') return 'stripe-reported-fraud'\n if (evidence.outcomeType === 'blocked' && evidence.riskLevel === 'highest') return 'radar-blocked'\n return null\n}\n\n/** Whether the failures show card cycling at `nowMs`. Pure. */\nexport function cardCyclingPattern(\n failures: readonly PaymentFraudChargeEvidence[],\n nowMs: number,\n): { matched: boolean; fingerprints: string[] } {\n const since = nowMs - PAYMENT_FRAUD_HOLD.cyclingWindowMs\n const fingerprints = [\n ...new Set(\n failures\n .filter((entry) => entry.atMs >= since && entry.atMs <= nowMs && entry.fingerprint)\n .map((entry) => entry.fingerprint as string),\n ),\n ]\n return { matched: fingerprints.length >= PAYMENT_FRAUD_HOLD.cyclingMinCards, fingerprints }\n}\n\n/**\n * The guards (AGL-3767): a workspace 14+ days old, or one that has paid an\n * invoice other than with the flagged charges, is alerted about and never\n * locked automatically. An unreadable creation date is an existing customer,\n * as everywhere else on the platform (`isYoungWorkspaceAge`). Pure.\n */\nexport function paymentFraudHoldGuard(input: {\n ageDays: number | null\n hasPaidInvoice: boolean\n}): { lock: boolean; reason: string | null } {\n if (input.hasPaidInvoice) {\n return { lock: false, reason: 'The workspace has paid an invoice before, so a person decides.' }\n }\n if (\n typeof input.ageDays !== 'number' ||\n !Number.isFinite(input.ageDays) ||\n input.ageDays >= PAYMENT_FRAUD_HOLD.youngDays\n ) {\n return {\n lock: false,\n reason:\n input.ageDays === null || !Number.isFinite(input.ageDays)\n ? 'The workspace has no readable creation date, so it is treated as an existing customer.'\n : `The workspace is ${Math.floor(input.ageDays)} days old (${PAYMENT_FRAUD_HOLD.youngDays} or more), so a person decides.`,\n }\n }\n return { lock: true, reason: null }\n}\n\n/**\n * Has this workspace paid an invoice with any charge OTHER than the flagged\n * ones? Read off `platformRevenue`, which holds a row for every paid\n * subscription invoice (AGL-1811). The flagged charges are excluded because\n * a stolen card that went through has \"paid\" too — an early fraud warning\n * only ever arrives on a charge that succeeded.\n */\nexport async function hasPaidOtherInvoice(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n flagged: { chargeIds: readonly string[]; paymentIntentIds: readonly string[] },\n): Promise<boolean> {\n const rows = await firestore\n .collection('platformRevenue')\n .where('orgId', '==', orgId)\n .limit(25)\n .get()\n return rows.docs.some((row) => {\n if (!(Number(row.get('grossCents')) > 0)) return false\n const chargeId = text(row.get('chargeId'))\n const paymentIntentId = text(row.get('paymentIntentId'))\n if (chargeId && flagged.chargeIds.includes(chargeId)) return false\n if (paymentIntentId && flagged.paymentIntentIds.includes(paymentIntentId)) return false\n return true\n })\n}\n\n/**\n * - `watching` — failures recorded, nothing asked for.\n * - `placing` — a run claimed the hold.\n * - `held` — locks placed. Never placed again automatically.\n * - `alerted` — a guard made it an alert only; another alert waits out the window.\n * - `skipped` — never held: the house, a staff-owned workspace, one that is gone.\n */\nexport type PaymentFraudHoldState = 'watching' | 'placing' | 'held' | 'alerted' | 'skipped'\n\nexport interface PaymentFraudHoldRecord {\n orgId: string\n state: PaymentFraudHoldState\n failures: PaymentFraudChargeEvidence[]\n claimedAtMs?: number | null\n heldAtMs?: number | null\n alertedAtMs?: number | null\n trigger?: PaymentFraudHoldTrigger | null\n reviewId?: string | null\n outcome?: Record<string, unknown> | null\n updatedAtMs?: number\n}\n\nfunction holdRef(firestore: FirebaseFirestore.Firestore, orgId: string) {\n return firestore.collection(PAYMENT_FRAUD_HOLD_COLLECTION).doc(orgId)\n}\n\n/**\n * Keep one failed charge on its workspace's document and say whether the\n * failures now show card cycling. Idempotent on the charge id: a redelivery\n * adds nothing and reports `added: false`.\n */\nexport async function recordWorkspaceChargeFailure(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n evidence: PaymentFraudChargeEvidence,\n nowMs: number = Date.now(),\n): Promise<{\n added: boolean\n cycling: { matched: boolean; fingerprints: string[] }\n /** The failures inside the cycling window, oldest first, this one included. */\n recent: PaymentFraudChargeEvidence[]\n state: PaymentFraudHoldState\n}> {\n const ref = holdRef(firestore, orgId)\n return firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const stored = (snapshot.exists ? (snapshot.data() ?? {}) : {}) as Partial<PaymentFraudHoldRecord>\n const previous = Array.isArray(stored.failures) ? stored.failures : []\n const state = (stored.state ?? 'watching') as PaymentFraudHoldState\n const added = !previous.some((entry) => entry.chargeId === evidence.chargeId)\n const failures = added\n ? [...previous.filter((entry) => entry.atMs >= nowMs - PAYMENT_FRAUD_HOLD.retainMs), evidence].slice(\n -PAYMENT_FRAUD_HOLD.maxFailures,\n )\n : previous\n if (added) {\n transaction.set(\n ref,\n {\n orgId,\n failures,\n ...(snapshot.exists ? {} : { state: 'watching' }),\n updatedAtMs: nowMs,\n },\n { merge: true },\n )\n }\n return {\n added,\n cycling: cardCyclingPattern(failures, nowMs),\n recent: failures.filter((entry) => entry.atMs >= nowMs - PAYMENT_FRAUD_HOLD.cyclingWindowMs),\n state,\n }\n })\n}\n\n/**\n * Claim the workspace's hold for this run. Answers `claimed: false` with the\n * reason when the hold is already placed, another run is placing it inside\n * its lease, or a guard already alerted inside the alert window — which is\n * what makes a redelivery, or the next card in a burst, a no-op.\n */\nexport async function claimPaymentFraudHold(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n trigger: PaymentFraudHoldTrigger,\n nowMs: number = Date.now(),\n): Promise<{\n claimed: boolean\n /** Why not, when `claimed` is false. */\n reason: 'held' | 'placing' | 'alerted' | 'skipped' | null\n}> {\n const ref = holdRef(firestore, orgId)\n return firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const record = (snapshot.exists ? (snapshot.data() ?? {}) : {}) as Partial<PaymentFraudHoldRecord>\n const state = (record.state ?? 'watching') as PaymentFraudHoldState\n if (state === 'held') return { claimed: false as const, reason: 'held' as const }\n if (state === 'skipped') return { claimed: false as const, reason: 'skipped' as const }\n if (\n state === 'placing' &&\n typeof record.claimedAtMs === 'number' &&\n nowMs - record.claimedAtMs < PAYMENT_FRAUD_HOLD.claimLeaseMs\n ) {\n return { claimed: false as const, reason: 'placing' as const }\n }\n if (\n state === 'alerted' &&\n typeof record.alertedAtMs === 'number' &&\n nowMs - record.alertedAtMs < PAYMENT_FRAUD_HOLD.alertWindowMs\n ) {\n return { claimed: false as const, reason: 'alerted' as const }\n }\n transaction.set(\n ref,\n { orgId, state: 'placing', claimedAtMs: nowMs, trigger, updatedAtMs: nowMs },\n { merge: true },\n )\n return { claimed: true, reason: null }\n })\n}\n\n/** Record what the run did with a claimed hold. */\nexport async function settlePaymentFraudHold(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n state: 'held' | 'alerted' | 'skipped' | 'watching',\n fields: { reviewId?: string | null; outcome?: Record<string, unknown> | null },\n nowMs: number = Date.now(),\n): Promise<void> {\n await holdRef(firestore, orgId).set(\n {\n state,\n ...(state === 'held' ? { heldAtMs: nowMs } : {}),\n ...(state === 'alerted' ? { alertedAtMs: nowMs } : {}),\n ...(fields.reviewId !== undefined ? { reviewId: fields.reviewId } : {}),\n ...(fields.outcome !== undefined ? { outcome: fields.outcome } : {}),\n claimedAtMs: null,\n updatedAtMs: nowMs,\n },\n { merge: true },\n )\n}\n\n/**\n * Is this workspace on an automatic payment-fraud hold? What collapses the\n * per-attempt `staff.paymentFailed` pings: once a workspace is held, the\n * hold's one alert is the news, and each further declined card is not.\n */\nexport async function isPaymentFraudHeld(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<boolean> {\n const snapshot = await holdRef(firestore, orgId).get()\n return snapshot.exists && snapshot.get('state') === 'held'\n}\n"],"names":["PAYMENT_FRAUD_HOLD_COLLECTION","PAYMENT_FRAUD_HOLD_ACTOR_UID","PAYMENT_FRAUD_HOLD_LOCK_REASON","PAYMENT_FRAUD_HOLD","cyclingMinCards","cyclingWindowMs","youngDays","retainMs","maxFailures","alertWindowMs","claimLeaseMs","PAYMENT_FRAUD_HOLD_TRIGGER_LABELS","text","value","trim","refOf","id","chargeEvidence","charge","atMs","card","payment_method_details","outcome","amount","Number","score","risk_score","chargeId","String","paymentIntentId","payment_intent","invoiceId","invoice","fingerprint","cardCountry","country","cardBrand","brand","last4","outcomeType","type","outcomeReason","reason","riskLevel","risk_level","riskScore","isFinite","stripeReport","fraud_details","stripe_report","failureCode","failure_code","declineCode","network_decline_code","postalCheck","checks","address_postal_code_check","cvcCheck","cvc_check","amountCents","currency","chargeFraudTrigger","evidence","cardCyclingPattern","failures","nowMs","since","fingerprints","Set","filter","entry","map","matched","length","paymentFraudHoldGuard","input","hasPaidInvoice","lock","ageDays","Math","floor","hasPaidOtherInvoice","firestore","orgId","flagged","rows","collection","where","limit","get","docs","some","row","chargeIds","includes","paymentIntentIds","holdRef","doc","recordWorkspaceChargeFailure","Date","now","ref","runTransaction","transaction","snapshot","stored","exists","data","previous","Array","isArray","state","added","slice","set","updatedAtMs","merge","cycling","recent","claimPaymentFraudHold","trigger","record","claimed","claimedAtMs","alertedAtMs","settlePaymentFraudHold","fields","heldAtMs","reviewId","undefined","isPaymentFraudHeld"],"mappings":"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"}
|
|
@@ -21,6 +21,21 @@ import type { RiskEventInput } from './risk-notice';
|
|
|
21
21
|
* handle, so the webhook's spec substitutes it with the barrel.
|
|
22
22
|
*/
|
|
23
23
|
export type RiskNotifier = (input: RiskEventInput) => Promise<unknown>;
|
|
24
|
+
/**
|
|
25
|
+
* What the platform did, by itself, about a workspace's flagged payment
|
|
26
|
+
* (AGL-3767): `summary` is the line staff read on the row, `record` the
|
|
27
|
+
* structured half kept beside it.
|
|
28
|
+
*/
|
|
29
|
+
export interface WorkspaceSignalResponse {
|
|
30
|
+
summary: string;
|
|
31
|
+
record: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
/** The console's answer to a new signal on a workspace's own charge. */
|
|
34
|
+
export type WorkspaceSignalResponder = (input: {
|
|
35
|
+
signal: PaymentFraudSignal;
|
|
36
|
+
reviewId: string;
|
|
37
|
+
reference: string;
|
|
38
|
+
}) => Promise<WorkspaceSignalResponse | null>;
|
|
24
39
|
/** Which Stripe signal this is. */
|
|
25
40
|
export type PaymentFraudSignalKind = 'early-fraud-warning' | 'radar-review' | 'dispute';
|
|
26
41
|
/** The Stripe object type each signal arrives as, for the queue's text. */
|
|
@@ -144,11 +159,21 @@ export declare function recordPaymentFraudSignal(signal: PaymentFraudSignal, dep
|
|
|
144
159
|
firestore: FirebaseFirestore.Firestore;
|
|
145
160
|
notifyRisk: RiskNotifier;
|
|
146
161
|
nowMs?: number;
|
|
162
|
+
/**
|
|
163
|
+
* What the platform does about a NEW early fraud warning or Radar review
|
|
164
|
+
* on a workspace's own charge (AGL-3767): the console's refund and
|
|
165
|
+
* automatic security hold. Called once per signal, never for a
|
|
166
|
+
* redelivery; whatever it answers is written onto the row as
|
|
167
|
+
* `automatedResponse`, with its `summary` appended to the row's text.
|
|
168
|
+
* Absent, the row is filed exactly as before.
|
|
169
|
+
*/
|
|
170
|
+
respond?: WorkspaceSignalResponder;
|
|
147
171
|
}): Promise<{
|
|
148
172
|
reviewId: string;
|
|
149
173
|
reference: string;
|
|
150
174
|
first: boolean;
|
|
151
175
|
reopened: boolean;
|
|
176
|
+
response: WorkspaceSignalResponse | null;
|
|
152
177
|
}>;
|
|
153
178
|
export declare const SELLER_FRAUD_PATTERN: {
|
|
154
179
|
/** Distinct charges with an early fraud warning or a dispute. */
|
|
@@ -26,10 +26,13 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
26
26
|
* Each signal now files an urgent row in the abuse queue that names the
|
|
27
27
|
* workspace, the charge and the amount, links the staff Subscription card
|
|
28
28
|
* on the org page (AGL-3359, where billing is canceled), and notifies staff
|
|
29
|
-
* once.
|
|
30
|
-
* cancel.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
29
|
+
* once. THIS MODULE DOES NOTHING TO THE MONEY OR THE SUBSCRIPTION: no
|
|
30
|
+
* refund, no cancel. What the platform does about a WORKSPACE's own flagged
|
|
31
|
+
* payment (AGL-3767) — refund an actionable early fraud warning before it
|
|
32
|
+
* becomes a dispute, cancel the subscription, place an automatic security
|
|
33
|
+
* hold — is the console's, handed in as `deps.respond` and recorded on the
|
|
34
|
+
* row. A site's sale on a connected account is never refunded by the
|
|
35
|
+
* platform; its row recommends the refund instead.
|
|
33
36
|
*
|
|
34
37
|
* ONE ROW PER CHARGE (AGL-3490). The three signals are separate Stripe
|
|
35
38
|
* objects about the same payment, and one fraudulent charge routinely draws
|
|
@@ -229,7 +232,7 @@ const KIND_LABEL = {
|
|
|
229
232
|
const reference = paymentFraudSignalReference(reviewId);
|
|
230
233
|
const ref = deps.firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId);
|
|
231
234
|
const atMs = (_deps_nowMs = deps.nowMs) != null ? _deps_nowMs : Date.now();
|
|
232
|
-
const { first, reopened } = await deps.firestore.runTransaction(async (transaction)=>{
|
|
235
|
+
const { first, reopened, added, details } = await deps.firestore.runTransaction(async (transaction)=>{
|
|
233
236
|
var _snapshot_data, _existing_status, _ref, _signal_orgId, _signal_amountCents, _signal_checks, _ref1;
|
|
234
237
|
const snapshot = await transaction.get(ref);
|
|
235
238
|
const existing = snapshot.exists ? (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {} : null;
|
|
@@ -242,7 +245,9 @@ const KIND_LABEL = {
|
|
|
242
245
|
});
|
|
243
246
|
return {
|
|
244
247
|
first: false,
|
|
245
|
-
reopened: false
|
|
248
|
+
reopened: false,
|
|
249
|
+
added: false,
|
|
250
|
+
details: ''
|
|
246
251
|
};
|
|
247
252
|
}
|
|
248
253
|
const status = existing ? String((_existing_status = existing['status']) != null ? _existing_status : 'open') : 'open';
|
|
@@ -268,6 +273,14 @@ const KIND_LABEL = {
|
|
|
268
273
|
amountCents: (_signal_amountCents = signal.amountCents) != null ? _signal_amountCents : typeof (previous == null ? void 0 : previous['amountCents']) === 'number' ? previous['amountCents'] : null,
|
|
269
274
|
checks: (_signal_checks = signal.checks) != null ? _signal_checks : (_ref1 = previous == null ? void 0 : previous['checks']) != null ? _ref1 : null
|
|
270
275
|
});
|
|
276
|
+
const rowDetails = describePaymentFraudSignal(merged, {
|
|
277
|
+
history: nextHistory,
|
|
278
|
+
closed: wasClosed ? {
|
|
279
|
+
status,
|
|
280
|
+
resolution
|
|
281
|
+
} : null,
|
|
282
|
+
reopened: reopen
|
|
283
|
+
});
|
|
271
284
|
transaction.set(ref, _extends({
|
|
272
285
|
reference,
|
|
273
286
|
// "Phishing or fraud" — the queue's urgent fraud category.
|
|
@@ -278,14 +291,7 @@ const KIND_LABEL = {
|
|
|
278
291
|
reportedHostname: null,
|
|
279
292
|
hostId: null,
|
|
280
293
|
orgId,
|
|
281
|
-
details:
|
|
282
|
-
history: nextHistory,
|
|
283
|
-
closed: wasClosed ? {
|
|
284
|
-
status,
|
|
285
|
-
resolution
|
|
286
|
-
} : null,
|
|
287
|
-
reopened: reopen
|
|
288
|
-
}),
|
|
294
|
+
details: rowDetails,
|
|
289
295
|
reporterEmail: null,
|
|
290
296
|
reporterName: null,
|
|
291
297
|
dmca: null,
|
|
@@ -316,11 +322,43 @@ const KIND_LABEL = {
|
|
|
316
322
|
});
|
|
317
323
|
return {
|
|
318
324
|
first: !existing,
|
|
319
|
-
reopened: reopen
|
|
325
|
+
reopened: reopen,
|
|
326
|
+
added: true,
|
|
327
|
+
details: rowDetails
|
|
320
328
|
};
|
|
321
329
|
});
|
|
330
|
+
// The platform's own answer to a workspace's flagged payment (AGL-3767),
|
|
331
|
+
// once per new signal. Never able to fail the filing: the row is written.
|
|
332
|
+
let response = null;
|
|
333
|
+
if (added && deps.respond && signal.orgId && (signal.kind === 'early-fraud-warning' || signal.kind === 'radar-review')) {
|
|
334
|
+
try {
|
|
335
|
+
response = await deps.respond({
|
|
336
|
+
signal,
|
|
337
|
+
reviewId,
|
|
338
|
+
reference
|
|
339
|
+
});
|
|
340
|
+
} catch (error) {
|
|
341
|
+
var _ref;
|
|
342
|
+
console.error('[payment-fraud-signal] the automated response failed', reviewId, error);
|
|
343
|
+
response = {
|
|
344
|
+
summary: `The automated response did not complete: ${(_ref = error == null ? void 0 : error.message) != null ? _ref : String(error)}. Check the workspace by hand.`,
|
|
345
|
+
record: {
|
|
346
|
+
failed: true
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
if (response) {
|
|
351
|
+
await ref.set({
|
|
352
|
+
automatedResponse: response.record,
|
|
353
|
+
details: `${details.replace('This alert refunded and canceled nothing.', 'The alert itself refunded and canceled nothing; what the platform did on its own follows.')}\n${response.summary}`.slice(0, 5000),
|
|
354
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
355
|
+
}, {
|
|
356
|
+
merge: true
|
|
357
|
+
}).catch((error)=>console.error('[payment-fraud-signal] row not annotated', reviewId, error));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
322
360
|
if (first || reopened) {
|
|
323
|
-
var _RISK_PAYMENT_EVENTS_signal_kind,
|
|
361
|
+
var _RISK_PAYMENT_EVENTS_signal_kind, _ref1, _signal_chargeId;
|
|
324
362
|
// The workspace's owners are told their subscription payment was
|
|
325
363
|
// flagged and how to confirm it; staff get the row with the evidence.
|
|
326
364
|
await deps.notifyRisk({
|
|
@@ -337,14 +375,15 @@ const KIND_LABEL = {
|
|
|
337
375
|
// a fraud check. The notice says which.
|
|
338
376
|
paymentEvent: (_RISK_PAYMENT_EVENTS_signal_kind = RISK_PAYMENT_EVENTS[signal.kind]) != null ? _RISK_PAYMENT_EVENTS_signal_kind : null,
|
|
339
377
|
stripeUrl: stripeSignalDashboardUrl(signal),
|
|
340
|
-
staffEvidence: `${KIND_LABEL[signal.kind]} (${signal.stripeObjectId}) on charge ` + `${(
|
|
378
|
+
staffEvidence: `${KIND_LABEL[signal.kind]} (${signal.stripeObjectId}) on charge ` + `${(_ref1 = (_signal_chargeId = signal.chargeId) != null ? _signal_chargeId : signal.paymentIntentId) != null ? _ref1 : 'not named'}` + (signal.detail ? `; Stripe says: ${signal.detail}` : '') + (signal.livemode ? '' : ' — TEST MODE') + (reopened ? '; reopened a row staff had dismissed' : '') + '.'
|
|
341
379
|
}).catch(()=>undefined);
|
|
342
380
|
}
|
|
343
381
|
return {
|
|
344
382
|
reviewId,
|
|
345
383
|
reference,
|
|
346
384
|
first,
|
|
347
|
-
reopened
|
|
385
|
+
reopened,
|
|
386
|
+
response
|
|
348
387
|
};
|
|
349
388
|
}
|
|
350
389
|
/*==========================================
|
|
@@ -484,6 +523,7 @@ const WINDOW_DAYS = SELLER_FRAUD_PATTERN.windowMs / 86400000;
|
|
|
484
523
|
return `• ${KIND_LABEL[entry.kind]} ${entry.stripeObjectId} on ${(_entry_chargeId = entry.chargeId) != null ? _entry_chargeId : 'an unnamed charge'} · ${formatSignalAmount(entry.amountCents, entry.currency)}` + (entry.detail ? ` · ${entry.detail}` : '');
|
|
485
524
|
}),
|
|
486
525
|
'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.',
|
|
526
|
+
'Nothing here is refunded automatically: these are the seller\u2019s sales on its connected account, not the platform\u2019s own billing (AGL-3767). Recommended: refund each charge that drew an early fraud warning and has not been disputed yet \u2014 a fully refunded charge cannot then be disputed for fraud, which keeps it off the dispute rate.',
|
|
487
527
|
`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.`
|
|
488
528
|
].join('\n').slice(0, 5000);
|
|
489
529
|
await deps.firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId).set({
|
|
@@ -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 * ONE ROW PER CHARGE (AGL-3490). The three signals are separate Stripe\n * objects about the same payment, and one fraudulent charge routinely draws\n * two of them — a warning and then a dispute, in either order: the issuer's\n * fraud report reached Stripe a day AFTER the 9/26 charge's chargeback had\n * been accepted. Keyed by signal, each one opened its own urgent row and told\n * the owners and staff again. So the row is keyed by the charge, and a later\n * signal joins it: added to `paymentSignals`, never a second row or a second\n * notice. A row staff have closed stays closed — the later signal is written\n * onto it, beside what they decided — with one exception: a DISPUTE on a row\n * staff DISMISSED reopens it and tells them, because the bank now says the\n * cardholder did not make the payment staff judged genuine, and the evidence\n * deadline is running.\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 entry 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/**\n * The row id: one per CHARGE (AGL-3490), so every signal about one payment\n * lands on the same row. The payment intent stands in for a signal that\n * names no charge, and the signal itself for one that names neither. Hex,\n * so the admin route's id pattern addresses it.\n */\nexport function paymentFraudSignalReviewId(\n signal: Pick<\n PaymentFraudSignal,\n 'kind' | 'stripeObjectId' | 'chargeId' | 'paymentIntentId'\n >,\n): string {\n const payment = signal.chargeId || signal.paymentIntentId\n return createHash('sha256')\n .update(\n payment\n ? `stripe-fraud:payment:${payment}`\n : `stripe-fraud:${signal.kind}:${signal.stripeObjectId}`,\n )\n .digest('hex')\n .slice(0, 40)\n}\n\n/** One signal as the row's history keeps it (AGL-3490). */\nexport interface PaymentFraudSignalEntry {\n kind: PaymentFraudSignalKind\n stripeObjectId: string\n detail: string\n atMs: number\n /**\n * The row's status when this signal arrived, if staff had already closed\n * it — `actioned` or `dismissed` — else null.\n */\n arrivedAfter: string | null\n}\n\n/** The row's history is a short list; a charge draws at most a few signals. */\nconst SIGNAL_HISTORY_MAX = 10\n\n/** The statuses that mean staff have decided the row. */\nconst CLOSED_STATUSES: ReadonlySet<string> = new Set(['actioned', 'dismissed'])\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/**\n * The row's prose: what arrived, about which workspace, and what to do.\n *\n * `history` is every signal on the charge, this one last; `closed` is what\n * staff had decided before it arrived, if anything. The closing line says\n * what THIS ALERT did — nothing to the money or the subscription — and\n * never claims nothing was done at all: by the time a late warning lands,\n * staff may have canceled, locked and accepted the chargeback (AGL-3490).\n */\nexport function describePaymentFraudSignal(\n signal: PaymentFraudSignal,\n context: {\n history?: readonly PaymentFraudSignalEntry[]\n closed?: { status: string; resolution: string | null } | null\n reopened?: boolean\n } = {},\n): 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 const history = context.history ?? []\n if (history.length > 1) {\n lines.push(\n `Every signal on this charge, oldest first: ${history\n .map(\n (entry) =>\n `${KIND_LABEL[entry.kind]} (${entry.stripeObjectId}` +\n (entry.arrivedAfter ? `, after the row was ${entry.arrivedAfter}` : '') +\n ')',\n )\n .join('; ')}.`,\n )\n }\n const resolution = context.closed?.resolution\n ? `: “${context.closed.resolution}”`\n : '.'\n if (context.reopened && context.closed) {\n lines.push(\n `REOPENED. Staff had dismissed this row${resolution} The cardholder’s bank has now disputed the payment. Answer or accept the dispute in Stripe before its evidence deadline, then close this row again with what you did.`,\n )\n } else if (context.closed) {\n lines.push(\n `This signal arrived after the row was closed as ${context.closed.status}${resolution} The row stays closed and nobody was notified again. Reopen it only if that decision no longer holds.`,\n )\n } else {\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 'This alert refunded and canceled nothing. Decide on the org’s Subscription card, lock the workspace if it is fraud, and close this row with what you did.',\n )\n }\n return lines.join('\\n').slice(0, 5000)\n}\n\n/** The history a stored row carries, oldest first. */\nfunction signalHistoryOf(\n row: Record<string, unknown> | null,\n fallbackAtMs: number,\n): PaymentFraudSignalEntry[] {\n if (!row) return []\n const kinds = Object.keys(KIND_LABEL)\n const stored = Array.isArray(row['paymentSignals']) ? row['paymentSignals'] : []\n const entries = stored.flatMap((value): PaymentFraudSignalEntry[] => {\n const entry = (value ?? {}) as Record<string, unknown>\n const kind = String(entry['kind'] ?? '')\n const stripeObjectId = String(entry['stripeObjectId'] ?? '')\n if (!kinds.includes(kind) || !stripeObjectId) return []\n return [\n {\n kind: kind as PaymentFraudSignalKind,\n stripeObjectId,\n detail: String(entry['detail'] ?? ''),\n atMs: Number(entry['atMs']) || 0,\n arrivedAfter:\n typeof entry['arrivedAfter'] === 'string' ? entry['arrivedAfter'] : null,\n },\n ]\n })\n if (entries.length) return entries\n // A row filed before the history existed carries its one signal as\n // `paymentSignal`, which is where a redelivery of it is recognised.\n const only = (row['paymentSignal'] ?? null) as Record<string, unknown> | null\n const kind = String(only?.['kind'] ?? '')\n const stripeObjectId = String(only?.['stripeObjectId'] ?? '')\n return kinds.includes(kind) && stripeObjectId\n ? [\n {\n kind: kind as PaymentFraudSignalKind,\n stripeObjectId,\n detail: String(only?.['detail'] ?? ''),\n atMs: fallbackAtMs,\n arrivedAfter: null,\n },\n ]\n : []\n}\n\n/**\n * File one signal on its charge's row, and tell the owners and staff once\n * per charge (AGL-3490).\n *\n * The first signal on a charge opens the row. A later one — a different\n * Stripe object about the same payment — is added to `paymentSignals`, and\n * `paymentSignal` becomes it, so the row's Stripe link follows a dispute.\n * The status is staff's from the moment they touch it and is left alone,\n * except that a dispute on a DISMISSED row reopens it and notifies again. A\n * redelivery of a signal already on the row adds nothing. Read and written\n * in one transaction, so a warning and a dispute arriving together still\n * make one row. 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 nowMs?: number\n },\n): Promise<{\n reviewId: string\n reference: string\n first: boolean\n reopened: boolean\n}> {\n const reviewId = paymentFraudSignalReviewId(signal)\n const reference = paymentFraudSignalReference(reviewId)\n const ref = deps.firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const atMs = deps.nowMs ?? Date.now()\n const { first, reopened } = await deps.firestore.runTransaction(\n async (transaction) => {\n const snapshot = await transaction.get(ref)\n const existing = snapshot.exists\n ? ((snapshot.data() ?? {}) as Record<string, unknown>)\n : null\n const history = signalHistoryOf(existing, atMs)\n if (\n existing &&\n history.some((entry) => entry.stripeObjectId === signal.stripeObjectId)\n ) {\n transaction.set(\n ref,\n { updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n return { first: false, reopened: false }\n }\n const status = existing ? String(existing['status'] ?? 'open') : 'open'\n const wasClosed = CLOSED_STATUSES.has(status)\n const reopen = status === 'dismissed' && signal.kind === 'dispute'\n const resolution =\n typeof existing?.['resolution'] === 'string' &&\n existing['resolution'].trim()\n ? existing['resolution'].trim()\n : null\n const nextHistory = [\n ...history,\n {\n kind: signal.kind,\n stripeObjectId: signal.stripeObjectId,\n detail: signal.detail,\n atMs,\n arrivedAfter: wasClosed ? status : null,\n },\n ].slice(-SIGNAL_HISTORY_MAX)\n // A later signal whose charge read failed keeps what the first one\n // learned rather than erasing it.\n const previous = (existing?.['paymentSignal'] ?? null) as Record<\n string,\n unknown\n > | null\n const orgId =\n signal.orgId ??\n (typeof existing?.['orgId'] === 'string' ? existing['orgId'] : null)\n const merged: PaymentFraudSignal = {\n ...signal,\n orgId,\n amountCents:\n signal.amountCents ??\n (typeof previous?.['amountCents'] === 'number'\n ? previous['amountCents']\n : null),\n checks:\n signal.checks ??\n ((previous?.['checks'] ?? null) as PaymentFraudCardChecks | null),\n }\n transaction.set(\n ref,\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,\n details: describePaymentFraudSignal(merged, {\n history: nextHistory,\n closed: wasClosed ? { status, resolution } : null,\n reopened: reopen,\n }),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: nextHistory.length,\n paymentSignals: nextHistory,\n paymentSignal: {\n kind: merged.kind,\n stripeObjectId: merged.stripeObjectId,\n chargeId: merged.chargeId,\n paymentIntentId: merged.paymentIntentId,\n amountCents: merged.amountCents,\n currency: merged.currency,\n detail: merged.detail,\n checks: merged.checks,\n livemode: merged.livemode,\n subscriptionCard: orgId ? staffSubscriptionCardPath(orgId) : null,\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(existing\n ? {}\n : { status: 'open', createdAt: FieldValue.serverTimestamp() }),\n ...(reopen\n ? {\n status: 'open',\n reopenedAt: FieldValue.serverTimestamp(),\n resolvedAt: null,\n }\n : {}),\n },\n { merge: true },\n )\n return { first: !existing, reopened: reopen }\n },\n )\n if (first || reopened) {\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 (reopened ? '; reopened a row staff had dismissed' : '') +\n '.',\n })\n .catch(() => undefined)\n }\n return { reviewId, reference, first, reopened }\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","signal","payment","chargeId","paymentIntentId","update","kind","stripeObjectId","digest","slice","SIGNAL_HISTORY_MAX","CLOSED_STATUSES","Set","paymentFraudSignalReference","reviewId","toUpperCase","staffSubscriptionCardPath","orgId","encodeURIComponent","stripeSignalDashboardUrl","base","livemode","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","context","lines","detail","push","history","length","map","entry","arrivedAfter","join","resolution","closed","reopened","status","signalHistoryOf","row","fallbackAtMs","kinds","Object","keys","stored","Array","isArray","entries","flatMap","includes","atMs","only","recordPaymentFraudSignal","deps","reference","ref","firestore","collection","doc","nowMs","Date","now","first","runTransaction","transaction","snapshot","existing","get","exists","data","some","set","updatedAt","serverTimestamp","merge","wasClosed","has","reopen","trim","nextHistory","previous","merged","category","severity","source","url","reportedHostname","hostId","details","reporterEmail","reporterName","dmca","reportCount","paymentSignals","paymentSignal","subscriptionCard","createdAt","reopenedAt","resolvedAt","notifyRisk","item","label","path","amount","paymentEvent","stripeUrl","staffEvidence","catch","undefined","SELLER_FRAUD_PATTERN","minDistinctCharges","windowMs","retainMs","WINDOW_DAYS","SELLER_FRAUD_LEDGER_COLLECTION","sellerFraudPattern","since","chargeIds","filter","matched","stripeConnectedAccountUrl","accountId","recordSellerFraudSignal","input","hostIds","ledgerRef","outcome","fresh","fileAtMs","lastFiledAtMs","updatedAtMs","filed","inWindow","orgIds","sellerPattern","stripeAccountUrl","threshold","windowDays"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2CAkC2C,GAE3C,SAASA,UAAU,QAAQ,SAAQ;AACnC,SAASC,uBAAuB,QAAQ,sCAAqC;AAC7E,SAASC,mBAAmB,QAAQ,+CAA8C;AAClF,SAASC,UAAU,QAAQ,2BAA0B;AAgBrD,MAAMC,aAAqD;IACzD,uBAAuB;IACvB,gBAAgB;IAChBC,SAAS;AACX;AAEA,yEAAyE,GACzE,OAAO,MAAMC,8BAA8BF,WAAU;AA4BrD;;;;;CAKC,GACD,OAAO,SAASG,2BACdC,MAGC;IAED,MAAMC,UAAUD,OAAOE,QAAQ,IAAIF,OAAOG,eAAe;IACzD,OAAOX,WAAW,UACfY,MAAM,CACLH,UACI,CAAC,qBAAqB,EAAEA,SAAS,GACjC,CAAC,aAAa,EAAED,OAAOK,IAAI,CAAC,CAAC,EAAEL,OAAOM,cAAc,EAAE,EAE3DC,MAAM,CAAC,OACPC,KAAK,CAAC,GAAG;AACd;AAeA,6EAA6E,GAC7E,MAAMC,qBAAqB;AAE3B,uDAAuD,GACvD,MAAMC,kBAAuC,IAAIC,IAAI;IAAC;IAAY;CAAY;AAE9E,kFAAkF,GAClF,OAAO,SAASC,4BAA4BC,QAAgB;IAC1D,OAAO,CAAC,GAAG,EAAEA,SAASL,KAAK,CAAC,GAAG,IAAIM,WAAW,IAAI;AACpD;AAEA,uEAAuE,GACvE,OAAO,SAASC,0BAA0BC,KAAa;IACrD,OAAO,CAAC,YAAY,EAAEC,mBAAmBD,OAAO,aAAa,CAAC;AAChE;AAEA;;;CAGC,GACD,OAAO,SAASE,yBAAyBlB,MAMxC;IACC,MAAMmB,OAAO,CAAC,6BAA6B,EAAEnB,OAAOoB,QAAQ,GAAG,KAAK,SAAS;IAC7E,IAAIpB,OAAOK,IAAI,KAAK,aAAaL,OAAOM,cAAc,EAAE;QACtD,OAAO,GAAGa,KAAK,SAAS,EAAEF,mBAAmBjB,OAAOM,cAAc,GAAG;IACvE;IACA,MAAML,UAAUD,OAAOG,eAAe,IAAIH,OAAOE,QAAQ;IACzD,OAAOD,UAAU,GAAGkB,KAAK,SAAS,EAAEF,mBAAmBhB,UAAU,GAAG;AACtE;AAEA,gEAAgE,GAChE,OAAO,SAASoB,mBACdC,WAA0B,EAC1BC,QAAgB;IAEhB,IAAID,gBAAgB,QAAQ,CAACE,OAAOC,QAAQ,CAACH,cAAc;QACzD,OAAO;IACT;IACA,OAAO,GAAG,AAACA,CAAAA,cAAc,GAAE,EAAGI,OAAO,CAAC,GAAG,CAAC,EAAEC,OAAOJ,YAAY,OAAOT,WAAW,IAAI;AACvF;AAEA;;;;;;CAMC,GACD,OAAO,eAAec,0BACpB1B,QAAgB,EAChB2B,OAAoE;IAepE,IAAI,CAAC3B,YAAY,CAAC2B,QAAQC,SAAS,EAAE,OAAO;IAC5C,IAAI;YACsBD;YAMXE,gCAUMC,cACcA,eAEbD,iBACGC,sBAEOD;QAtB9B,MAAME,WAAW,MAAM,EAACJ,qBAAAA,QAAQK,SAAS,YAAjBL,qBAAqBM,OAC3C,CAAC,kCAAkC,EAAElB,mBAAmBf,WAAW,EACnE;YAAEkC,SAAS;gBAAEC,eAAe,CAAC,OAAO,EAAER,QAAQC,SAAS,EAAE;YAAC;QAAE;QAE9D,IAAI,CAACG,SAASK,EAAE,EAAE,OAAO;QACzB,MAAMP,SAAU,MAAME,SAASM,IAAI;QACnC,MAAMP,eAAOD,2BAAAA,iCAAAA,MAAQ,CAAC,yBAAyB,qBAAlCA,8BAAoC,CAAC,OAAO,mBAAI,CAAC;QAC9D,MAAMS,OAAO,CAACC,QACZ,OAAOA,UAAU,YAAYA,QAAQA,QAAQ;QAC/C,OAAO;YACLnB,aAAaE,OAAOC,QAAQ,CAACD,OAAOO,0BAAAA,MAAQ,CAAC,SAAS,KAClDP,OAAOO,MAAM,CAAC,SAAS,IACvB;YACJR,UAAUI,gBAAOI,0BAAAA,MAAQ,CAAC,WAAW,oBAAI;YACzCW,YAAYF,KAAKT,0BAAAA,MAAQ,CAAC,WAAW;YACrCY,QAAQ;gBACNC,UAAUJ,KAAKR,yBAAAA,eAAAA,IAAM,CAAC,SAAS,qBAAhBA,YAAkB,CAAC,YAAY;gBAC9Ca,wBAAwBL,KAAKR,yBAAAA,gBAAAA,IAAM,CAAC,SAAS,qBAAhBA,aAAkB,CAAC,4BAA4B;gBAC5Ec,aAAaN,KAAKR,wBAAAA,IAAM,CAAC,UAAU;gBACnCe,WAAWP,KAAKT,2BAAAA,kBAAAA,MAAQ,CAAC,UAAU,qBAAnBA,eAAqB,CAAC,aAAa;gBACnDiB,cAAcR,KAAKR,yBAAAA,uBAAAA,IAAM,CAAC,iBAAiB,qBAAxBA,oBAA0B,CAAC,SAAS;YACzD;YACAiB,iBAAiBC,WAAWnB,2BAAAA,wBAAAA,MAAQ,CAAC,gBAAgB,qBAAzBA,qBAA2B,CAAC,cAAc;QACxE;IACF,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA,mEAAmE,GACnE,SAASmB,WAAWT,KAAc;;IAChC,MAAMU,KACJ,OAAOV,UAAU,WACbA,QACAd,eAAQc,yBAAD,AAACA,MAAmCU,EAAE,mBAAI;IACvD,OAAOA,GAAGC,UAAU,CAAC,WAAWD,KAAK;AACvC;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASE,2BACdrD,MAA0B,EAC1BsD,UAII,CAAC,CAAC;QAMUtD,eACHA,MAAAA,kBAWGsD;QAaGA;IA7BnB,MAAMC,QAAQ;QACZ,GAAG3D,UAAU,CAACI,OAAOK,IAAI,CAAC,CAAC,cAAc,EAAEL,OAAOM,cAAc,CAAC,CAAC,CAAC,GAChEN,CAAAA,OAAOoB,QAAQ,GAAG,KAAK,cAAa,IACrC;QACF,CAAC,WAAW,GAAEpB,gBAAAA,OAAOgB,KAAK,YAAZhB,gBAAgB,gEAAgE,CAAC,CAAC;QAChG,CAAC,QAAQ,GAAEA,QAAAA,mBAAAA,OAAOE,QAAQ,YAAfF,mBAAmBA,OAAOG,eAAe,YAAzCH,OAA6C,YAAY,GAAG,EAAEqB,mBAAmBrB,OAAOsB,WAAW,EAAEtB,OAAOuB,QAAQ,EAAE,CAAC,CAAC;KACpI;IACD,IAAIvB,OAAOwD,MAAM,EAAED,MAAME,IAAI,CAAC,CAAC,aAAa,EAAEzD,OAAOwD,MAAM,CAAC,CAAC,CAAC;IAC9D,MAAMb,SAAS3C,OAAO2C,MAAM;IAC5B,IAAIA,QAAQ;YAEKA,kBAA6CA,gCAC3CA,qBAAwCA,sBACvCA;QAHlBY,MAAME,IAAI,CACR,CAAC,UAAU,GAAEd,mBAAAA,OAAOC,QAAQ,YAAfD,mBAAmB,UAAU,cAAc,GAAEA,iCAAAA,OAAOE,sBAAsB,YAA7BF,iCAAiC,UAAU,EAAE,CAAC,GACtG,CAAC,UAAU,GAAEA,sBAAAA,OAAOG,WAAW,YAAlBH,sBAAsB,UAAU,MAAM,GAAEA,uBAAAA,OAAOK,YAAY,YAAnBL,uBAAuB,WAAW,EAAE,CAAC,GAC1F,CAAC,WAAW,GAAEA,oBAAAA,OAAOI,SAAS,YAAhBJ,oBAAoB,UAAU,CAAC,CAAC;IAEpD;IACA,MAAMe,WAAUJ,mBAAAA,QAAQI,OAAO,YAAfJ,mBAAmB,EAAE;IACrC,IAAII,QAAQC,MAAM,GAAG,GAAG;QACtBJ,MAAME,IAAI,CACR,CAAC,2CAA2C,EAAEC,QAC3CE,GAAG,CACF,CAACC,QACC,GAAGjE,UAAU,CAACiE,MAAMxD,IAAI,CAAC,CAAC,EAAE,EAAEwD,MAAMvD,cAAc,EAAE,GACnDuD,CAAAA,MAAMC,YAAY,GAAG,CAAC,oBAAoB,EAAED,MAAMC,YAAY,EAAE,GAAG,EAAC,IACrE,KAEHC,IAAI,CAAC,MAAM,CAAC,CAAC;IAEpB;IACA,MAAMC,aAAaV,EAAAA,kBAAAA,QAAQW,MAAM,qBAAdX,gBAAgBU,UAAU,IACzC,CAAC,GAAG,EAAEV,QAAQW,MAAM,CAACD,UAAU,CAAC,CAAC,CAAC,GAClC;IACJ,IAAIV,QAAQY,QAAQ,IAAIZ,QAAQW,MAAM,EAAE;QACtCV,MAAME,IAAI,CACR,CAAC,sCAAsC,EAAEO,WAAW,sKAAsK,CAAC;IAE/N,OAAO,IAAIV,QAAQW,MAAM,EAAE;QACzBV,MAAME,IAAI,CACR,CAAC,gDAAgD,EAAEH,QAAQW,MAAM,CAACE,MAAM,GAAGH,WAAW,qGAAqG,CAAC;IAEhM,OAAO;QACLT,MAAME,IAAI,CACRzD,OAAOK,IAAI,KAAK,iBACZ,kEACAL,OAAOK,IAAI,KAAK,YACd,+DACA,gFACN;IAEJ;IACA,OAAOkD,MAAMQ,IAAI,CAAC,MAAMvD,KAAK,CAAC,GAAG;AACnC;AAEA,oDAAoD,GACpD,SAAS4D,gBACPC,GAAmC,EACnCC,YAAoB;QAwBND;IAtBd,IAAI,CAACA,KAAK,OAAO,EAAE;IACnB,MAAME,QAAQC,OAAOC,IAAI,CAAC7E;IAC1B,MAAM8E,SAASC,MAAMC,OAAO,CAACP,GAAG,CAAC,iBAAiB,IAAIA,GAAG,CAAC,iBAAiB,GAAG,EAAE;IAChF,MAAMQ,UAAUH,OAAOI,OAAO,CAAC,CAACrC;YAEVoB,aACUA,uBAMXA;QARnB,MAAMA,QAASpB,gBAAAA,QAAS,CAAC;QACzB,MAAMpC,OAAOsB,QAAOkC,cAAAA,KAAK,CAAC,OAAO,YAAbA,cAAiB;QACrC,MAAMvD,iBAAiBqB,QAAOkC,wBAAAA,KAAK,CAAC,iBAAiB,YAAvBA,wBAA2B;QACzD,IAAI,CAACU,MAAMQ,QAAQ,CAAC1E,SAAS,CAACC,gBAAgB,OAAO,EAAE;QACvD,OAAO;YACL;gBACED,MAAMA;gBACNC;gBACAkD,QAAQ7B,QAAOkC,gBAAAA,KAAK,CAAC,SAAS,YAAfA,gBAAmB;gBAClCmB,MAAMxD,OAAOqC,KAAK,CAAC,OAAO,KAAK;gBAC/BC,cACE,OAAOD,KAAK,CAAC,eAAe,KAAK,WAAWA,KAAK,CAAC,eAAe,GAAG;YACxE;SACD;IACH;IACA,IAAIgB,QAAQlB,MAAM,EAAE,OAAOkB;IAC3B,mEAAmE;IACnE,oEAAoE;IACpE,MAAMI,QAAQZ,qBAAAA,GAAG,CAAC,gBAAgB,YAApBA,qBAAwB;IACtC,MAAMhE,OAAOsB,eAAOsD,wBAAAA,IAAM,CAAC,OAAO,mBAAI;IACtC,MAAM3E,iBAAiBqB,gBAAOsD,wBAAAA,IAAM,CAAC,iBAAiB,oBAAI;IAC1D,OAAOV,MAAMQ,QAAQ,CAAC1E,SAASC,iBAC3B;QACE;YACED,MAAMA;YACNC;YACAkD,QAAQ7B,gBAAOsD,wBAAAA,IAAM,CAAC,SAAS,oBAAI;YACnCD,MAAMV;YACNR,cAAc;QAChB;KACD,GACD,EAAE;AACR;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,eAAeoB,yBACpBlF,MAA0B,EAC1BmF,IAIC;QAUYA;IAHb,MAAMtE,WAAWd,2BAA2BC;IAC5C,MAAMoF,YAAYxE,4BAA4BC;IAC9C,MAAMwE,MAAMF,KAAKG,SAAS,CAACC,UAAU,CAAC9F,yBAAyB+F,GAAG,CAAC3E;IACnE,MAAMmE,QAAOG,cAAAA,KAAKM,KAAK,YAAVN,cAAcO,KAAKC,GAAG;IACnC,MAAM,EAAEC,KAAK,EAAE1B,QAAQ,EAAE,GAAG,MAAMiB,KAAKG,SAAS,CAACO,cAAc,CAC7D,OAAOC;YAGCC,gBAc2BC,wBAyB/BhG,eAMEA,qBAKAA;QApDJ,MAAM+F,WAAW,MAAMD,YAAYG,GAAG,CAACZ;QACvC,MAAMW,WAAWD,SAASG,MAAM,IAC1BH,iBAAAA,SAASI,IAAI,cAAbJ,iBAAmB,CAAC,IACtB;QACJ,MAAMrC,UAAUU,gBAAgB4B,UAAUhB;QAC1C,IACEgB,YACAtC,QAAQ0C,IAAI,CAAC,CAACvC,QAAUA,MAAMvD,cAAc,KAAKN,OAAOM,cAAc,GACtE;YACAwF,YAAYO,GAAG,CACbhB,KACA;gBAAEiB,WAAW3G,WAAW4G,eAAe;YAAG,GAC1C;gBAAEC,OAAO;YAAK;YAEhB,OAAO;gBAAEZ,OAAO;gBAAO1B,UAAU;YAAM;QACzC;QACA,MAAMC,SAAS6B,WAAWrE,QAAOqE,mBAAAA,QAAQ,CAAC,SAAS,YAAlBA,mBAAsB,UAAU;QACjE,MAAMS,YAAY/F,gBAAgBgG,GAAG,CAACvC;QACtC,MAAMwC,SAASxC,WAAW,eAAenE,OAAOK,IAAI,KAAK;QACzD,MAAM2D,aACJ,QAAOgC,4BAAAA,QAAU,CAAC,aAAa,MAAK,YACpCA,QAAQ,CAAC,aAAa,CAACY,IAAI,KACvBZ,QAAQ,CAAC,aAAa,CAACY,IAAI,KAC3B;QACN,MAAMC,cAAc;eACfnD;YACH;gBACErD,MAAML,OAAOK,IAAI;gBACjBC,gBAAgBN,OAAOM,cAAc;gBACrCkD,QAAQxD,OAAOwD,MAAM;gBACrBwB;gBACAlB,cAAc2C,YAAYtC,SAAS;YACrC;SACD,CAAC3D,KAAK,CAAC,CAACC;QACT,mEAAmE;QACnE,kCAAkC;QAClC,MAAMqG,mBAAYd,4BAAAA,QAAU,CAAC,gBAAgB,mBAAI;QAIjD,MAAMhF,SACJhB,gBAAAA,OAAOgB,KAAK,YAAZhB,gBACC,QAAOgG,4BAAAA,QAAU,CAAC,QAAQ,MAAK,WAAWA,QAAQ,CAAC,QAAQ,GAAG;QACjE,MAAMe,SAA6B,aAC9B/G;YACHgB;YACAM,WAAW,GACTtB,sBAAAA,OAAOsB,WAAW,YAAlBtB,sBACC,QAAO8G,4BAAAA,QAAU,CAAC,cAAc,MAAK,WAClCA,QAAQ,CAAC,cAAc,GACvB;YACNnE,MAAM,GACJ3C,iBAAAA,OAAO2C,MAAM,YAAb3C,0BACE8G,4BAAAA,QAAU,CAAC,SAAS,oBAAI;;QAE9BhB,YAAYO,GAAG,CACbhB,KACA;YACED;YACA,2DAA2D;YAC3D4B,UAAU;YACVC,UAAU;YACVC,QAAQ;YACRC,KAAK;YACLC,kBAAkB;YAClBC,QAAQ;YACRrG;YACAsG,SAASjE,2BAA2B0D,QAAQ;gBAC1CrD,SAASmD;gBACT5C,QAAQwC,YAAY;oBAAEtC;oBAAQH;gBAAW,IAAI;gBAC7CE,UAAUyC;YACZ;YACAY,eAAe;YACfC,cAAc;YACdC,MAAM;YACNC,aAAab,YAAYlD,MAAM;YAC/BgE,gBAAgBd;YAChBe,eAAe;gBACbvH,MAAM0G,OAAO1G,IAAI;gBACjBC,gBAAgByG,OAAOzG,cAAc;gBACrCJ,UAAU6G,OAAO7G,QAAQ;gBACzBC,iBAAiB4G,OAAO5G,eAAe;gBACvCmB,aAAayF,OAAOzF,WAAW;gBAC/BC,UAAUwF,OAAOxF,QAAQ;gBACzBiC,QAAQuD,OAAOvD,MAAM;gBACrBb,QAAQoE,OAAOpE,MAAM;gBACrBvB,UAAU2F,OAAO3F,QAAQ;gBACzByG,kBAAkB7G,QAAQD,0BAA0BC,SAAS;YAC/D;YACAsF,WAAW3G,WAAW4G,eAAe;WACjCP,WACA,CAAC,IACD;YAAE7B,QAAQ;YAAQ2D,WAAWnI,WAAW4G,eAAe;QAAG,GAC1DI,SACA;YACExC,QAAQ;YACR4D,YAAYpI,WAAW4G,eAAe;YACtCyB,YAAY;QACd,IACA,CAAC,IAEP;YAAExB,OAAO;QAAK;QAEhB,OAAO;YAAEZ,OAAO,CAACI;YAAU9B,UAAUyC;QAAO;IAC9C;IAEF,IAAIf,SAAS1B,UAAU;YAaHxE,kCAITM,MAAAA;QAhBT,iEAAiE;QACjE,sEAAsE;QACtE,MAAMmF,KACH8C,UAAU,CAAC;YACV5H,MAAM;YACNW,OAAOhB,OAAOgB,KAAK;YACnBH;YACAuE;YACA8C,MAAM;gBAAEC,OAAO;gBAA6BC,MAAM;YAAwB;YAC1EC,QAAQhH,mBAAmBrB,OAAOsB,WAAW,EAAEtB,OAAOuB,QAAQ;YAC9D,oEAAoE;YACpE,wCAAwC;YACxC+G,YAAY,GAAE5I,mCAAAA,mBAAmB,CAACM,OAAOK,IAAI,CAAC,YAAhCX,mCAAoC;YAClD6I,WAAWrH,yBAAyBlB;YACpCwI,eACE,GAAG5I,UAAU,CAACI,OAAOK,IAAI,CAAC,CAAC,EAAE,EAAEL,OAAOM,cAAc,CAAC,YAAY,CAAC,GAClE,IAAGN,QAAAA,mBAAAA,OAAOE,QAAQ,YAAfF,mBAAmBA,OAAOG,eAAe,YAAzCH,OAA6C,aAAa,GAC5DA,CAAAA,OAAOwD,MAAM,GAAG,CAAC,eAAe,EAAExD,OAAOwD,MAAM,EAAE,GAAG,EAAC,IACrDxD,CAAAA,OAAOoB,QAAQ,GAAG,KAAK,cAAa,IACpC8C,CAAAA,WAAW,yCAAyC,EAAC,IACtD;QACJ,GACCuE,KAAK,CAAC,IAAMC;IACjB;IACA,OAAO;QAAE7H;QAAUuE;QAAWQ;QAAO1B;IAAS;AAChD;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2CAoC2C,GAE3C,OAAO,MAAMyE,uBAAuB;IAClC,+DAA+D,GAC/DC,oBAAoB;IACpB,yBAAyB,GACzBC,UAAU,IAAI,KAAK,KAAK,KAAK;IAC7B,+CAA+C,GAC/CC,UAAU,KAAK,KAAK,KAAK,KAAK;AAChC,EAAU;AAEV,MAAMC,cAAcJ,qBAAqBE,QAAQ,GAAG;AAEpD,4DAA4D,GAC5D,OAAO,MAAMG,iCAAiC,qBAAoB;AAclE,kEAAkE,GAClE,OAAO,SAASC,mBACdpE,OAA0C,EAC1CY,KAAa;IAEb,MAAMyD,QAAQzD,QAAQkD,qBAAqBE,QAAQ;IACnD,MAAMM,YAAY;WACb,IAAIxI,IACLkE,QACGuE,MAAM,CACL,CAACvF,QACC,AAACA,CAAAA,MAAMxD,IAAI,KAAK,yBAAyBwD,MAAMxD,IAAI,KAAK,SAAQ,KAChEwD,MAAMmB,IAAI,IAAIkE,OAEjBtF,GAAG,CAAC,CAACC,QAAUA,MAAM3D,QAAQ,IAAI2D,MAAMvD,cAAc;KAE3D;IACD,OAAO;QACL+I,SAASF,UAAUxF,MAAM,IAAIgF,qBAAqBC,kBAAkB;QACpEO;IACF;AACF;AAEA,0DAA0D,GAC1D,OAAO,SAASG,0BACdC,SAAiB,EACjBnI,QAAiB;IAEjB,OAAO,CAAC,6BAA6B,EAAEA,WAAW,KAAK,QAAQ,iBAAiB,EAAEH,mBAAmBsI,YAAY;AACnH;AAEA;;;;CAIC,GACD,OAAO,eAAeC,wBACpBC,KAOC,EACDtE,IAIC;QAMaA,aA6DAsE,gBA8BAC,WA8BFA;IAzHZ,MAAMjE,SAAQN,cAAAA,KAAKM,KAAK,YAAVN,cAAcO,KAAKC,GAAG;IACpC,MAAM,EAAE3F,MAAM,EAAEiD,eAAe,EAAE,GAAGwG;IACpC,MAAME,YAAYxE,KAAKG,SAAS,CAC7BC,UAAU,CAACyD,gCACXxD,GAAG,CAACvC;IACP,MAAM2G,UAAU,MAAMzE,KAAKG,SAAS,CAACO,cAAc,CAAC,OAAOC;YAwB5B+D;QAvB7B,MAAMA,QAAQ,MAAM/D,YAAYG,GAAG,CAAC0D;QACpC,MAAMjF,SACJC,MAAMC,OAAO,CAACiF,MAAM5D,GAAG,CAAC,cAAc4D,MAAM5D,GAAG,CAAC,aAAa,EAAE;QAEjE,IAAIvB,OAAO0B,IAAI,CAAC,CAACvC,QAAUA,MAAMvD,cAAc,KAAKN,OAAOM,cAAc,GAAG;YAC1E,OAAO;gBAAEsF,OAAO;gBAAOyD,SAAS;gBAAOS,UAAU;gBAAGjF,SAASH;YAAO;QACtE;QACA,MAAMG,UAAoC;eACrCH,OAAO0E,MAAM,CACd,CAACvF,QAAUA,MAAMmB,IAAI,IAAIS,QAAQkD,qBAAqBG,QAAQ;YAEhE;gBACEzI,MAAML,OAAOK,IAAI;gBACjBC,gBAAgBN,OAAOM,cAAc;gBACrCJ,UAAUF,OAAOE,QAAQ;gBACzBoB,aAAatB,OAAOsB,WAAW;gBAC/BC,UAAUvB,OAAOuB,QAAQ;gBACzBiC,QAAQxD,OAAOwD,MAAM;gBACrBkG,SAASD,MAAMC,OAAO;gBACtB1E,MAAMS;YACR;SACD;QACD,MAAM,EAAE4D,OAAO,EAAE,GAAGJ,mBAAmBpE,SAASY;QAChD,MAAMsE,gBAAgBvI,QAAOqI,aAAAA,MAAM5D,GAAG,CAAC,4BAAV4D,aAA8B;QAC3D,MAAMC,WACJT,WACA,CAAEU,CAAAA,iBAAiBtE,QAAQsE,gBAAgBpB,qBAAqBE,QAAQ,AAAD,IACnEpD,QACA;QACNK,YAAYO,GAAG,CACbsD,WACA;YACE1G;YACA4B;YACAzD,UAAUpB,OAAOoB,QAAQ;YACzB4I,aAAavE;WACTqE,WAAW;YAAEC,eAAeD;QAAS,IAAI,CAAC,IAEhD;YAAEtD,OAAO;QAAK;QAEhB,OAAO;YAAEZ,OAAO;YAAMyD;YAASS;YAAUjF;QAAQ;IACnD;IACA,IAAI,CAAC+E,QAAQE,QAAQ,EAAE;QACrB,OAAO;YAAElE,OAAOgE,QAAQhE,KAAK;YAAEyD,SAASO,QAAQP,OAAO;YAAEY,OAAO;QAAK;IACvE;IAEA,MAAM,EAAEd,SAAS,EAAE,GAAGF,mBAAmBW,QAAQ/E,OAAO,EAAEY;IAC1D,MAAMyE,WAAWN,QAAQ/E,OAAO,CAACuE,MAAM,CACrC,CAACvF,QAAUA,MAAMmB,IAAI,IAAIS,QAAQkD,qBAAqBE,QAAQ;IAEhE,MAAMhI,WAAWrB,WAAW,UACzBY,MAAM,CAAC,CAAC,sBAAsB,EAAE6C,gBAAgB,CAAC,EAAE2G,QAAQE,QAAQ,EAAE,EACrEvJ,MAAM,CAAC,OACPC,KAAK,CAAC,GAAG;IACZ,MAAM4E,YAAYxE,4BAA4BC;IAC9C,MAAMG,SAAQyI,iBAAAA,MAAMU,MAAM,CAAC,EAAE,YAAfV,iBAAmB;IACjC,MAAMC,UAAU;WAAI,IAAI/I,IAAIuJ,SAASpF,OAAO,CAAC,CAACjB;gBAAUA;oBAAAA,iBAAAA,MAAM6F,OAAO,YAAb7F,iBAAiB,EAAE;;KAAG;IAC9E,MAAM0E,YAAYe,0BAA0BrG,iBAAiBjD,OAAOoB,QAAQ;IAC5E,MAAMkG,UAAU;QACd,GAAG6B,UAAUxF,MAAM,CAAC,6CAA6C,EAAEV,gBAAgB,kDAAkD,EAAE8F,YAAY,KAAK,CAAC,GACtJ/I,CAAAA,OAAOoB,QAAQ,GAAG,KAAK,cAAa,IACrC;QACF,CAAC,cAAc,EAAEqI,MAAMU,MAAM,CAACxG,MAAM,GAAG8F,MAAMU,MAAM,CAACpG,IAAI,CAAC,QAAQ,eAAe,WAAW,EAAE2F,QAAQ/F,MAAM,GAAG+F,QAAQ3F,IAAI,CAAC,QAAQ,eAAe,CAAC,CAAC;WACjJmG,SAAStG,GAAG,CACb,CAACC;gBAC2DA;mBAA1D,CAAC,EAAE,EAAEjE,UAAU,CAACiE,MAAMxD,IAAI,CAAC,CAAC,CAAC,EAAEwD,MAAMvD,cAAc,CAAC,IAAI,GAAEuD,kBAAAA,MAAM3D,QAAQ,YAAd2D,kBAAkB,oBAAoB,GAAG,EAAExC,mBAAmBwC,MAAMvC,WAAW,EAAEuC,MAAMtC,QAAQ,GAAG,GAC3JsC,CAAAA,MAAML,MAAM,GAAG,CAAC,GAAG,EAAEK,MAAML,MAAM,EAAE,GAAG,EAAC;;QAE5C;QACA,CAAC,4IAA4I,EAAE+E,UAAU,yCAAyC,CAAC;KACpM,CACExE,IAAI,CAAC,MACLvD,KAAK,CAAC,GAAG;IACZ,MAAM2E,KAAKG,SAAS,CACjBC,UAAU,CAAC9F,yBACX+F,GAAG,CAAC3E,UACJwF,GAAG,CACF;QACEjB;QACA,2DAA2D;QAC3D4B,UAAU;QACVC,UAAU;QACVC,QAAQ;QACRC,KAAK;QACLC,kBAAkB;QAClBC,MAAM,GAAEqC,YAAAA,OAAO,CAAC,EAAE,YAAVA,YAAc;QACtB1I;QACAsG;QACAC,eAAe;QACfC,cAAc;QACdC,MAAM;QACNC,aAAa;QACb0C,eAAe;YACbnH;YACAoH,kBAAkB9B;YAClBY;YACAgB,QAAQV,MAAMU,MAAM;YACpBT;YACAY,WAAW3B,qBAAqBC,kBAAkB;YAClD2B,YAAYxB;YACZ3H,UAAUpB,OAAOoB,QAAQ;QAC3B;QACA+C,QAAQ;QACR2D,WAAWnI,WAAW4G,eAAe;QACrCD,WAAW3G,WAAW4G,eAAe;IACvC,GACA;QAAEC,OAAO;IAAK;IAElB,yEAAyE;IACzE,yEAAyE;IACzE,wCAAwC;IACxC,MAAMrB,KACH8C,UAAU,CAAC;QACV5H,MAAM;QACNW;QACAqG,MAAM,GAAEqC,aAAAA,OAAO,CAAC,EAAE,YAAVA,aAAc;QACtB7I;QACAuE;QACA8C,MAAM;YAAEC,OAAO;YAAsCC,MAAM;QAAsB;QACjFG;QACAC,eACE,CAAC,kBAAkB,EAAEvF,gBAAgB,oCAAoC,CAAC,GAC1E,GAAGkG,UAAUxF,MAAM,CAAC,sBAAsB,EAAEoF,YAAY,KAAK,CAAC,GAC7D/I,CAAAA,OAAOoB,QAAQ,GAAG,KAAK,cAAa,IACrC;IACJ,GACCqH,KAAK,CAAC,IAAMC;IACf,OAAO;QAAE9C,OAAO;QAAMyD,SAAS;QAAMY,OAAO;YAAEpJ;YAAUuE;QAAU;IAAE;AACtE"}
|
|
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. THIS MODULE DOES NOTHING TO THE MONEY OR THE SUBSCRIPTION: no\n * refund, no cancel. What the platform does about a WORKSPACE's own flagged\n * payment (AGL-3767) — refund an actionable early fraud warning before it\n * becomes a dispute, cancel the subscription, place an automatic security\n * hold — is the console's, handed in as `deps.respond` and recorded on the\n * row. A site's sale on a connected account is never refunded by the\n * platform; its row recommends the refund instead.\n *\n * ONE ROW PER CHARGE (AGL-3490). The three signals are separate Stripe\n * objects about the same payment, and one fraudulent charge routinely draws\n * two of them — a warning and then a dispute, in either order: the issuer's\n * fraud report reached Stripe a day AFTER the 9/26 charge's chargeback had\n * been accepted. Keyed by signal, each one opened its own urgent row and told\n * the owners and staff again. So the row is keyed by the charge, and a later\n * signal joins it: added to `paymentSignals`, never a second row or a second\n * notice. A row staff have closed stays closed — the later signal is written\n * onto it, beside what they decided — with one exception: a DISPUTE on a row\n * staff DISMISSED reopens it and tells them, because the bank now says the\n * cardholder did not make the payment staff judged genuine, and the evidence\n * deadline is running.\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/**\n * What the platform did, by itself, about a workspace's flagged payment\n * (AGL-3767): `summary` is the line staff read on the row, `record` the\n * structured half kept beside it.\n */\nexport interface WorkspaceSignalResponse {\n summary: string\n record: Record<string, unknown>\n}\n\n/** The console's answer to a new signal on a workspace's own charge. */\nexport type WorkspaceSignalResponder = (input: {\n signal: PaymentFraudSignal\n reviewId: string\n reference: string\n}) => Promise<WorkspaceSignalResponse | null>\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 entry 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/**\n * The row id: one per CHARGE (AGL-3490), so every signal about one payment\n * lands on the same row. The payment intent stands in for a signal that\n * names no charge, and the signal itself for one that names neither. Hex,\n * so the admin route's id pattern addresses it.\n */\nexport function paymentFraudSignalReviewId(\n signal: Pick<\n PaymentFraudSignal,\n 'kind' | 'stripeObjectId' | 'chargeId' | 'paymentIntentId'\n >,\n): string {\n const payment = signal.chargeId || signal.paymentIntentId\n return createHash('sha256')\n .update(\n payment\n ? `stripe-fraud:payment:${payment}`\n : `stripe-fraud:${signal.kind}:${signal.stripeObjectId}`,\n )\n .digest('hex')\n .slice(0, 40)\n}\n\n/** One signal as the row's history keeps it (AGL-3490). */\nexport interface PaymentFraudSignalEntry {\n kind: PaymentFraudSignalKind\n stripeObjectId: string\n detail: string\n atMs: number\n /**\n * The row's status when this signal arrived, if staff had already closed\n * it — `actioned` or `dismissed` — else null.\n */\n arrivedAfter: string | null\n}\n\n/** The row's history is a short list; a charge draws at most a few signals. */\nconst SIGNAL_HISTORY_MAX = 10\n\n/** The statuses that mean staff have decided the row. */\nconst CLOSED_STATUSES: ReadonlySet<string> = new Set(['actioned', 'dismissed'])\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/**\n * The row's prose: what arrived, about which workspace, and what to do.\n *\n * `history` is every signal on the charge, this one last; `closed` is what\n * staff had decided before it arrived, if anything. The closing line says\n * what THIS ALERT did — nothing to the money or the subscription — and\n * never claims nothing was done at all: by the time a late warning lands,\n * staff may have canceled, locked and accepted the chargeback (AGL-3490).\n */\nexport function describePaymentFraudSignal(\n signal: PaymentFraudSignal,\n context: {\n history?: readonly PaymentFraudSignalEntry[]\n closed?: { status: string; resolution: string | null } | null\n reopened?: boolean\n } = {},\n): 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 const history = context.history ?? []\n if (history.length > 1) {\n lines.push(\n `Every signal on this charge, oldest first: ${history\n .map(\n (entry) =>\n `${KIND_LABEL[entry.kind]} (${entry.stripeObjectId}` +\n (entry.arrivedAfter ? `, after the row was ${entry.arrivedAfter}` : '') +\n ')',\n )\n .join('; ')}.`,\n )\n }\n const resolution = context.closed?.resolution\n ? `: “${context.closed.resolution}”`\n : '.'\n if (context.reopened && context.closed) {\n lines.push(\n `REOPENED. Staff had dismissed this row${resolution} The cardholder’s bank has now disputed the payment. Answer or accept the dispute in Stripe before its evidence deadline, then close this row again with what you did.`,\n )\n } else if (context.closed) {\n lines.push(\n `This signal arrived after the row was closed as ${context.closed.status}${resolution} The row stays closed and nobody was notified again. Reopen it only if that decision no longer holds.`,\n )\n } else {\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 'This alert refunded and canceled nothing. Decide on the org’s Subscription card, lock the workspace if it is fraud, and close this row with what you did.',\n )\n }\n return lines.join('\\n').slice(0, 5000)\n}\n\n/** The history a stored row carries, oldest first. */\nfunction signalHistoryOf(\n row: Record<string, unknown> | null,\n fallbackAtMs: number,\n): PaymentFraudSignalEntry[] {\n if (!row) return []\n const kinds = Object.keys(KIND_LABEL)\n const stored = Array.isArray(row['paymentSignals']) ? row['paymentSignals'] : []\n const entries = stored.flatMap((value): PaymentFraudSignalEntry[] => {\n const entry = (value ?? {}) as Record<string, unknown>\n const kind = String(entry['kind'] ?? '')\n const stripeObjectId = String(entry['stripeObjectId'] ?? '')\n if (!kinds.includes(kind) || !stripeObjectId) return []\n return [\n {\n kind: kind as PaymentFraudSignalKind,\n stripeObjectId,\n detail: String(entry['detail'] ?? ''),\n atMs: Number(entry['atMs']) || 0,\n arrivedAfter:\n typeof entry['arrivedAfter'] === 'string' ? entry['arrivedAfter'] : null,\n },\n ]\n })\n if (entries.length) return entries\n // A row filed before the history existed carries its one signal as\n // `paymentSignal`, which is where a redelivery of it is recognised.\n const only = (row['paymentSignal'] ?? null) as Record<string, unknown> | null\n const kind = String(only?.['kind'] ?? '')\n const stripeObjectId = String(only?.['stripeObjectId'] ?? '')\n return kinds.includes(kind) && stripeObjectId\n ? [\n {\n kind: kind as PaymentFraudSignalKind,\n stripeObjectId,\n detail: String(only?.['detail'] ?? ''),\n atMs: fallbackAtMs,\n arrivedAfter: null,\n },\n ]\n : []\n}\n\n/**\n * File one signal on its charge's row, and tell the owners and staff once\n * per charge (AGL-3490).\n *\n * The first signal on a charge opens the row. A later one — a different\n * Stripe object about the same payment — is added to `paymentSignals`, and\n * `paymentSignal` becomes it, so the row's Stripe link follows a dispute.\n * The status is staff's from the moment they touch it and is left alone,\n * except that a dispute on a DISMISSED row reopens it and notifies again. A\n * redelivery of a signal already on the row adds nothing. Read and written\n * in one transaction, so a warning and a dispute arriving together still\n * make one row. 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 nowMs?: number\n /**\n * What the platform does about a NEW early fraud warning or Radar review\n * on a workspace's own charge (AGL-3767): the console's refund and\n * automatic security hold. Called once per signal, never for a\n * redelivery; whatever it answers is written onto the row as\n * `automatedResponse`, with its `summary` appended to the row's text.\n * Absent, the row is filed exactly as before.\n */\n respond?: WorkspaceSignalResponder\n },\n): Promise<{\n reviewId: string\n reference: string\n first: boolean\n reopened: boolean\n response: WorkspaceSignalResponse | null\n}> {\n const reviewId = paymentFraudSignalReviewId(signal)\n const reference = paymentFraudSignalReference(reviewId)\n const ref = deps.firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const atMs = deps.nowMs ?? Date.now()\n const { first, reopened, added, details } = await deps.firestore.runTransaction(\n async (transaction) => {\n const snapshot = await transaction.get(ref)\n const existing = snapshot.exists\n ? ((snapshot.data() ?? {}) as Record<string, unknown>)\n : null\n const history = signalHistoryOf(existing, atMs)\n if (\n existing &&\n history.some((entry) => entry.stripeObjectId === signal.stripeObjectId)\n ) {\n transaction.set(\n ref,\n { updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n return { first: false, reopened: false, added: false, details: '' }\n }\n const status = existing ? String(existing['status'] ?? 'open') : 'open'\n const wasClosed = CLOSED_STATUSES.has(status)\n const reopen = status === 'dismissed' && signal.kind === 'dispute'\n const resolution =\n typeof existing?.['resolution'] === 'string' &&\n existing['resolution'].trim()\n ? existing['resolution'].trim()\n : null\n const nextHistory = [\n ...history,\n {\n kind: signal.kind,\n stripeObjectId: signal.stripeObjectId,\n detail: signal.detail,\n atMs,\n arrivedAfter: wasClosed ? status : null,\n },\n ].slice(-SIGNAL_HISTORY_MAX)\n // A later signal whose charge read failed keeps what the first one\n // learned rather than erasing it.\n const previous = (existing?.['paymentSignal'] ?? null) as Record<\n string,\n unknown\n > | null\n const orgId =\n signal.orgId ??\n (typeof existing?.['orgId'] === 'string' ? existing['orgId'] : null)\n const merged: PaymentFraudSignal = {\n ...signal,\n orgId,\n amountCents:\n signal.amountCents ??\n (typeof previous?.['amountCents'] === 'number'\n ? previous['amountCents']\n : null),\n checks:\n signal.checks ??\n ((previous?.['checks'] ?? null) as PaymentFraudCardChecks | null),\n }\n const rowDetails = describePaymentFraudSignal(merged, {\n history: nextHistory,\n closed: wasClosed ? { status, resolution } : null,\n reopened: reopen,\n })\n transaction.set(\n ref,\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,\n details: rowDetails,\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: nextHistory.length,\n paymentSignals: nextHistory,\n paymentSignal: {\n kind: merged.kind,\n stripeObjectId: merged.stripeObjectId,\n chargeId: merged.chargeId,\n paymentIntentId: merged.paymentIntentId,\n amountCents: merged.amountCents,\n currency: merged.currency,\n detail: merged.detail,\n checks: merged.checks,\n livemode: merged.livemode,\n subscriptionCard: orgId ? staffSubscriptionCardPath(orgId) : null,\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(existing\n ? {}\n : { status: 'open', createdAt: FieldValue.serverTimestamp() }),\n ...(reopen\n ? {\n status: 'open',\n reopenedAt: FieldValue.serverTimestamp(),\n resolvedAt: null,\n }\n : {}),\n },\n { merge: true },\n )\n return { first: !existing, reopened: reopen, added: true, details: rowDetails }\n },\n )\n // The platform's own answer to a workspace's flagged payment (AGL-3767),\n // once per new signal. Never able to fail the filing: the row is written.\n let response: WorkspaceSignalResponse | null = null\n if (\n added &&\n deps.respond &&\n signal.orgId &&\n (signal.kind === 'early-fraud-warning' || signal.kind === 'radar-review')\n ) {\n try {\n response = await deps.respond({ signal, reviewId, reference })\n } catch (error) {\n console.error('[payment-fraud-signal] the automated response failed', reviewId, error)\n response = {\n summary: `The automated response did not complete: ${(error as Error)?.message ?? String(error)}. Check the workspace by hand.`,\n record: { failed: true },\n }\n }\n if (response) {\n await ref\n .set(\n {\n automatedResponse: response.record,\n details: `${details.replace(\n 'This alert refunded and canceled nothing.',\n 'The alert itself refunded and canceled nothing; what the platform did on its own follows.',\n )}\\n${response.summary}`.slice(0, 5000),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n .catch((error: unknown) =>\n console.error('[payment-fraud-signal] row not annotated', reviewId, error),\n )\n }\n }\n if (first || reopened) {\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 (reopened ? '; reopened a row staff had dismissed' : '') +\n '.',\n })\n .catch(() => undefined)\n }\n return { reviewId, reference, first, reopened, response }\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 here is refunded automatically: these are the seller\\u2019s sales on its connected account, not the platform\\u2019s own billing (AGL-3767). Recommended: refund each charge that drew an early fraud warning and has not been disputed yet \\u2014 a fully refunded charge cannot then be disputed for fraud, which keeps it off the dispute rate.',\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","signal","payment","chargeId","paymentIntentId","update","kind","stripeObjectId","digest","slice","SIGNAL_HISTORY_MAX","CLOSED_STATUSES","Set","paymentFraudSignalReference","reviewId","toUpperCase","staffSubscriptionCardPath","orgId","encodeURIComponent","stripeSignalDashboardUrl","base","livemode","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","context","lines","detail","push","history","length","map","entry","arrivedAfter","join","resolution","closed","reopened","status","signalHistoryOf","row","fallbackAtMs","kinds","Object","keys","stored","Array","isArray","entries","flatMap","includes","atMs","only","recordPaymentFraudSignal","deps","reference","ref","firestore","collection","doc","nowMs","Date","now","first","added","details","runTransaction","transaction","snapshot","existing","get","exists","data","some","set","updatedAt","serverTimestamp","merge","wasClosed","has","reopen","trim","nextHistory","previous","merged","rowDetails","category","severity","source","url","reportedHostname","hostId","reporterEmail","reporterName","dmca","reportCount","paymentSignals","paymentSignal","subscriptionCard","createdAt","reopenedAt","resolvedAt","respond","error","console","summary","message","record","failed","automatedResponse","replace","catch","notifyRisk","item","label","path","amount","paymentEvent","stripeUrl","staffEvidence","undefined","SELLER_FRAUD_PATTERN","minDistinctCharges","windowMs","retainMs","WINDOW_DAYS","SELLER_FRAUD_LEDGER_COLLECTION","sellerFraudPattern","since","chargeIds","filter","matched","stripeConnectedAccountUrl","accountId","recordSellerFraudSignal","input","hostIds","ledgerRef","outcome","fresh","fileAtMs","lastFiledAtMs","updatedAtMs","filed","inWindow","orgIds","sellerPattern","stripeAccountUrl","threshold","windowDays"],"mappings":"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"}
|