@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.
- package/package.json +7 -7
- package/src/lib/server/ad-audience-sync-run.d.ts +134 -0
- package/src/lib/server/ad-audience-sync-run.js +364 -0
- package/src/lib/server/ad-audience-sync-run.js.map +1 -0
- package/src/lib/server/ad-audience-sync.d.ts +250 -0
- package/src/lib/server/ad-audience-sync.js +446 -0
- package/src/lib/server/ad-audience-sync.js.map +1 -0
- package/src/lib/server/consent-by-address.d.ts +101 -0
- package/src/lib/server/consent-by-address.js +178 -0
- package/src/lib/server/consent-by-address.js.map +1 -0
- package/src/lib/server/notifications.d.ts +23 -0
- package/src/lib/server/notifications.js +35 -16
- package/src/lib/server/notifications.js.map +1 -1
- 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
|
@@ -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. */
|