@aglyn/tenant-data-admin 1.0.0-beta.249 → 1.0.0-beta.252

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.
@@ -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. */