Skip to content

Commit 9543b54

Browse files
authored
feat: sync early fraud warnings (#145)
1 parent 67ca0ac commit 9543b54

File tree

9 files changed

+168
-13
lines changed

9 files changed

+168
-13
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ This server synchronizes your Stripe account to a Postgres database. It can be a
100100
- [x] `product.created` 🟢
101101
- [x] `product.deleted` 🟢
102102
- [x] `product.updated` 🟢
103+
- [x] `radar.early_fraud_warning.created` 🟢
104+
- [x] `radar.early_fraud_warning.updated` 🟢
103105
- [x] `setup_intent.canceled` 🟢
104106
- [x] `setup_intent.created` 🟢
105107
- [x] `setup_intent.requires_action` 🟢

packages/fastify-app/src/test/invoices.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { vitest, beforeAll, describe, test, expect } from 'vitest'
44
import { runMigrations } from '@supabase/stripe-sync-engine'
55
import { getConfig } from '../utils/config'
66
import { mockStripe } from './helpers/mockStripe'
7+
import { logger } from '../logger'
78

89
let stripeSync: StripeSync
910

@@ -15,7 +16,7 @@ beforeAll(async () => {
1516
await runMigrations({
1617
databaseUrl: config.databaseUrl,
1718
schema: config.schema,
18-
logger: config.logger,
19+
logger,
1920
})
2021

2122
stripeSync = new StripeSync(config)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"id": "evt_1KJrGtJDPojXS6LN15fcthM3",
3+
"object": "event",
4+
"api_version": "2020-03-02",
5+
"created": 1642649111,
6+
"data": {
7+
"object": {
8+
"id": "issfr_1NnrwHBw2dPENLoi9lnhV3RQ",
9+
"object": "radar.early_fraud_warning",
10+
"actionable": true,
11+
"charge": "ch_1234",
12+
"created": 123456789,
13+
"fraud_type": "misc",
14+
"livemode": false
15+
}
16+
},
17+
"livemode": false,
18+
"pending_webhooks": 3,
19+
"request": {
20+
"id": null,
21+
"idempotency_key": null
22+
},
23+
"type": "radar.early_fraud_warning.created"
24+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"id": "evt_1KJrGtJDPojXS6LN15fcthM3",
3+
"object": "event",
4+
"api_version": "2020-03-02",
5+
"created": 1642649111,
6+
"data": {
7+
"object": {
8+
"id": "issfr_1NnrwHBw2dPENLoi9lnhV3RQ",
9+
"object": "radar.early_fraud_warning",
10+
"actionable": true,
11+
"charge": "ch_1234",
12+
"created": 123456789,
13+
"fraud_type": "misc",
14+
"livemode": false
15+
}
16+
},
17+
"livemode": false,
18+
"pending_webhooks": 3,
19+
"request": {
20+
"id": null,
21+
"idempotency_key": null
22+
},
23+
"type": "radar.early_fraud_warning.updated"
24+
}

packages/fastify-app/src/test/webhooks.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ describe('POST /webhooks', () => {
2020
await runMigrations({
2121
databaseUrl: config.databaseUrl,
2222
schema: config.schema,
23-
logger: config.logger,
23+
logger,
2424
})
2525

2626
process.env.AUTO_EXPAND_LISTS = 'false'
@@ -96,6 +96,8 @@ describe('POST /webhooks', () => {
9696
'credit_note_created',
9797
'credit_note_updated',
9898
'credit_note_voided',
99+
'early_fraud_warning_created',
100+
'early_fraud_warning_updated',
99101
])('process event %s', async (jsonFile) => {
100102
const eventBody = await import(`./stripe/${jsonFile}`).then(({ default: myData }) => myData)
101103
const signature = createHmac('sha256', stripeWebhookSecret)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
create table
2+
if not exists "stripe"."early_fraud_warnings" (
3+
"id" text primary key,
4+
object text,
5+
actionable boolean,
6+
charge text,
7+
created integer,
8+
fraud_type text,
9+
livemode boolean,
10+
payment_intent text,
11+
updated_at timestamptz default timezone('utc'::text, now()) not null
12+
);
13+
14+
create index stripe_early_fraud_warnings_charge_idx on "stripe"."early_fraud_warnings" using btree (charge);
15+
16+
create index stripe_early_fraud_warnings_payment_intent_idx on "stripe"."early_fraud_warnings" using btree (payment_intent);
17+
18+
create trigger handle_updated_at
19+
before update
20+
on stripe.early_fraud_warnings
21+
for each row
22+
execute procedure set_updated_at();
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { EntitySchema } from './types'
2+
3+
export const earlyFraudWarningSchema: EntitySchema = {
4+
properties: [
5+
'id',
6+
'object',
7+
'actionable',
8+
'charge',
9+
'created',
10+
'fraud_type',
11+
'livemode',
12+
'payment_intent',
13+
],
14+
} as const

packages/sync-engine/src/stripeSync.ts

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ import { subscriptionItemSchema } from './schemas/subscription_item'
1717
import { subscriptionScheduleSchema } from './schemas/subscription_schedules'
1818
import { subscriptionSchema } from './schemas/subscription'
1919
import { StripeSyncConfig, Sync, SyncBackfill, SyncBackfillParams } from './types'
20+
import { earlyFraudWarningSchema } from './schemas/early_fraud_warning'
2021

21-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
22-
function getUniqueIds(entries: any[], key: string): string[] {
22+
function getUniqueIds<T>(entries: T[], key: string): string[] {
2323
const set = new Set(
2424
entries
25-
.map((subscription) => subscription?.[key]?.toString())
25+
.map((subscription) => subscription?.[key as keyof T]?.toString())
2626
.filter((it): it is string => Boolean(it))
2727
)
2828

@@ -367,6 +367,22 @@ export class StripeSync {
367367
break
368368
}
369369

370+
case 'radar.early_fraud_warning.created':
371+
case 'radar.early_fraud_warning.updated': {
372+
const earlyFraudWarning = await this.fetchOrUseWebhookData(
373+
event.data.object as Stripe.Radar.EarlyFraudWarning,
374+
(id) => this.stripe.radar.earlyFraudWarnings.retrieve(id)
375+
)
376+
377+
this.config.logger?.info(
378+
`Received webhook ${event.id}: ${event.type} for earlyFraudWarning ${earlyFraudWarning.id}`
379+
)
380+
381+
await this.upsertEarlyFraudWarning([earlyFraudWarning])
382+
383+
break
384+
}
385+
370386
default:
371387
throw new Error('Unhandled webhook event')
372388
}
@@ -420,6 +436,10 @@ export class StripeSync {
420436
return this.stripe.taxIds.retrieve(stripeId).then((it) => this.upsertTaxIds([it]))
421437
} else if (stripeId.startsWith('cn_')) {
422438
return this.stripe.creditNotes.retrieve(stripeId).then((it) => this.upsertCreditNotes([it]))
439+
} else if (stripeId.startsWith('issfr_')) {
440+
return this.stripe.radar.earlyFraudWarnings
441+
.retrieve(stripeId)
442+
.then((it) => this.upsertEarlyFraudWarning([it]))
423443
}
424444
}
425445

@@ -438,7 +458,8 @@ export class StripeSync {
438458
paymentIntents,
439459
plans,
440460
taxIds,
441-
creditNotes
461+
creditNotes,
462+
earlyFraudWarnings
442463

443464
switch (object) {
444465
case 'all':
@@ -456,6 +477,7 @@ export class StripeSync {
456477
taxIds = await this.syncTaxIds(params)
457478
creditNotes = await this.syncCreditNotes(params)
458479
disputes = await this.syncDisputes(params)
480+
earlyFraudWarnings = await this.syncEarlyFraudWarnings(params)
459481
break
460482
case 'customer':
461483
customers = await this.syncCustomers(params)
@@ -498,6 +520,9 @@ export class StripeSync {
498520
case 'credit_note':
499521
creditNotes = await this.syncCreditNotes(params)
500522
break
523+
case 'early_fraud_warning':
524+
earlyFraudWarnings = await this.syncEarlyFraudWarnings(params)
525+
break
501526
default:
502527
break
503528
}
@@ -517,6 +542,7 @@ export class StripeSync {
517542
plans,
518543
taxIds,
519544
creditNotes,
545+
earlyFraudWarnings,
520546
}
521547
}
522548

@@ -701,6 +727,18 @@ export class StripeSync {
701727
)
702728
}
703729

730+
async syncEarlyFraudWarnings(syncParams?: SyncBackfillParams): Promise<Sync> {
731+
this.config.logger?.info('Syncing early fraud warnings')
732+
733+
const params: Stripe.Radar.EarlyFraudWarningListParams = { limit: 100 }
734+
if (syncParams?.created) params.created = syncParams.created
735+
736+
return this.fetchAndUpsert(
737+
() => this.stripe.radar.earlyFraudWarnings.list(params),
738+
(items) => this.upsertEarlyFraudWarning(items, syncParams?.backfillRelatedEntities)
739+
)
740+
}
741+
704742
async syncCreditNotes(syncParams?: SyncBackfillParams): Promise<Sync> {
705743
this.config.logger?.info('Syncing credit notes')
706744

@@ -749,8 +787,6 @@ export class StripeSync {
749787
])
750788
}
751789

752-
// Stripe only sends the first 10 refunds by default, the option will actively fetch all refunds
753-
754790
await this.expandEntity(charges, 'refunds', (id) =>
755791
this.stripe.refunds.list({ charge: id, limit: 100 })
756792
)
@@ -766,6 +802,17 @@ export class StripeSync {
766802
).then((charges) => this.upsertCharges(charges))
767803
}
768804

805+
private async backfillPaymentIntents(paymentIntentIds: string[]) {
806+
const missingIds = await this.postgresClient.findMissingEntries(
807+
'payment_intents',
808+
paymentIntentIds
809+
)
810+
811+
await this.fetchMissingEntities(missingIds, (id) =>
812+
this.stripe.paymentIntents.retrieve(id)
813+
).then((paymentIntents) => this.upsertPaymentIntents(paymentIntents))
814+
}
815+
769816
private async upsertCreditNotes(
770817
creditNotes: Stripe.CreditNote[],
771818
backfillRelatedEntities?: boolean
@@ -777,14 +824,31 @@ export class StripeSync {
777824
])
778825
}
779826

780-
// Stripe only sends the first 10 line items by default, the option will actively fetch all line items
781827
await this.expandEntity(creditNotes, 'lines', (id) =>
782828
this.stripe.creditNotes.listLineItems(id, { limit: 100 })
783829
)
784830

785831
return this.postgresClient.upsertMany(creditNotes, 'credit_notes', creditNoteSchema)
786832
}
787833

834+
private async upsertEarlyFraudWarning(
835+
earlyFraudWarnings: Stripe.Radar.EarlyFraudWarning[],
836+
backfillRelatedEntities?: boolean
837+
): Promise<Stripe.Radar.EarlyFraudWarning[]> {
838+
if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) {
839+
await Promise.all([
840+
this.backfillPaymentIntents(getUniqueIds(earlyFraudWarnings, 'payment_intent')),
841+
this.backfillCharges(getUniqueIds(earlyFraudWarnings, 'charge')),
842+
])
843+
}
844+
845+
return this.postgresClient.upsertMany(
846+
earlyFraudWarnings,
847+
'early_fraud_warnings',
848+
earlyFraudWarningSchema
849+
)
850+
}
851+
788852
async upsertCustomers(
789853
customers: (Stripe.Customer | Stripe.DeletedCustomer)[]
790854
): Promise<(Stripe.Customer | Stripe.DeletedCustomer)[]> {
@@ -830,8 +894,6 @@ export class StripeSync {
830894
])
831895
}
832896

833-
// Stripe only sends the first 10 line items by default, the option will actively fetch all line items
834-
835897
await this.expandEntity(invoices, 'lines', (id) =>
836898
this.stripe.invoices.listLineItems(id, { limit: 100 })
837899
)
@@ -1022,7 +1084,6 @@ export class StripeSync {
10221084
await this.backfillCustomers(customerIds)
10231085
}
10241086

1025-
// Stripe only sends the first 10 items by default, the option will actively fetch all items
10261087
await this.expandEntity(subscriptions, 'items', (id) =>
10271088
this.stripe.subscriptionItems.list({ subscription: id, limit: 100 })
10281089
)
@@ -1076,9 +1137,12 @@ export class StripeSync {
10761137
).then((subscriptionSchedules) => this.upsertSubscriptionSchedules(subscriptionSchedules))
10771138
}
10781139

1140+
/**
1141+
* Stripe only sends the first 10 entries by default, the option will actively fetch all entries.
1142+
*/
10791143
private async expandEntity<
10801144
K,
1081-
P extends string,
1145+
P extends keyof T,
10821146
T extends { id?: string } & { [key in P]?: Stripe.ApiList<K> | null },
10831147
>(entities: T[], property: P, listFn: (id: string) => Stripe.ApiListPromise<K>) {
10841148
if (!this.config.autoExpandLists) return

packages/sync-engine/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export type SyncObject =
5656
| 'plan'
5757
| 'tax_id'
5858
| 'credit_note'
59+
| 'early_fraud_warning'
5960

6061
export interface Sync {
6162
synced: number
@@ -76,6 +77,7 @@ export interface SyncBackfill {
7677
charges?: Sync
7778
taxIds?: Sync
7879
creditNotes?: Sync
80+
earlyFraudWarnings?: Sync
7981
}
8082

8183
export interface SyncBackfillParams {

0 commit comments

Comments
 (0)