Skip to content

Commit dedb345

Browse files
authored
feat: support refunds (#147)
1 parent 04b8e1b commit dedb345

File tree

9 files changed

+232
-2
lines changed

9 files changed

+232
-2
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ This server synchronizes your Stripe account to a Postgres database. It can be a
3131
- [x] `charge.failed` 🟢
3232
- [x] `charge.pending` 🟢
3333
- [x] `charge.refunded` 🟢
34+
- [x] `charge.refund.updated` 🟡 - For updates on all refunds, listen to `refund.updated` instead
3435
- [x] `charge.succeeded` 🟢
3536
- [x] `charge.updated` 🟢
3637
- [x] `charge.dispute.closed` 🟢
@@ -70,7 +71,7 @@ This server synchronizes your Stripe account to a Postgres database. It can be a
7071
- [x] `invoice.payment_failed` 🟢
7172
- [x] `invoice.payment_succeeded` 🟢
7273
- [x] `invoice.sent` 🟢
73-
- [x] `invoice.upcoming` 🔴 - Event has no id and cannot be processed
74+
- [ ] `invoice.upcoming` 🔴 - Event has no id and cannot be processed
7475
- [x] `invoice.updated` 🟢
7576
- [x] `invoice.overdue` 🟢
7677
- [x] `invoice.overpaid` 🟢
@@ -102,6 +103,9 @@ This server synchronizes your Stripe account to a Postgres database. It can be a
102103
- [x] `product.updated` 🟢
103104
- [x] `radar.early_fraud_warning.created` 🟢
104105
- [x] `radar.early_fraud_warning.updated` 🟢
106+
- [x] `refund.created` 🟢
107+
- [x] `refund.failed` 🟢
108+
- [x] `refund.updated` 🟢
105109
- [x] `review.opened` 🟢
106110
- [x] `review.closed` 🟢
107111
- [x] `setup_intent.canceled` 🟢
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"id": "evt_1KJrGtJDPojXS6LN15fcthM3",
3+
"object": "event",
4+
"api_version": "2020-03-02",
5+
"created": 1642649111,
6+
"data": {
7+
"object": {
8+
"id": "re_1Nispe2eZvKYlo2Cd31jOCgZ",
9+
"object": "refund",
10+
"amount": 1000,
11+
"balance_transaction": "txn_1Nispe2eZvKYlo2CYezqFhEx",
12+
"charge": "ch_1NirD82eZvKYlo2CIvbtLWuY",
13+
"created": 1692942318,
14+
"currency": "usd",
15+
"destination_details": {
16+
"card": {
17+
"reference": "123456789012",
18+
"reference_status": "available",
19+
"reference_type": "acquirer_reference_number",
20+
"type": "refund"
21+
},
22+
"type": "card"
23+
},
24+
"metadata": {},
25+
"payment_intent": "pi_1GszsK2eZvKYlo2CfhZyoZLp",
26+
"reason": null,
27+
"receipt_number": null,
28+
"source_transfer_reversal": null,
29+
"status": "succeeded",
30+
"transfer_reversal": null
31+
}
32+
},
33+
"livemode": false,
34+
"pending_webhooks": 3,
35+
"request": {
36+
"id": null,
37+
"idempotency_key": null
38+
},
39+
"type": "refund.created"
40+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"id": "evt_1KJrGtJDPojXS6LN15fcthM3",
3+
"object": "event",
4+
"api_version": "2020-03-02",
5+
"created": 1642649111,
6+
"data": {
7+
"object": {
8+
"id": "re_1Nispe2eZvKYlo2Cd31jOCgZ",
9+
"object": "refund",
10+
"amount": 1000,
11+
"balance_transaction": "txn_1Nispe2eZvKYlo2CYezqFhEx",
12+
"charge": "ch_1NirD82eZvKYlo2CIvbtLWuY",
13+
"created": 1692942318,
14+
"currency": "usd",
15+
"destination_details": {
16+
"card": {
17+
"reference": "123456789012",
18+
"reference_status": "available",
19+
"reference_type": "acquirer_reference_number",
20+
"type": "refund"
21+
},
22+
"type": "card"
23+
},
24+
"metadata": {},
25+
"payment_intent": "pi_1GszsK2eZvKYlo2CfhZyoZLp",
26+
"reason": null,
27+
"receipt_number": null,
28+
"source_transfer_reversal": null,
29+
"status": "succeeded",
30+
"transfer_reversal": null
31+
}
32+
},
33+
"livemode": false,
34+
"pending_webhooks": 3,
35+
"request": {
36+
"id": null,
37+
"idempotency_key": null
38+
},
39+
"type": "refund.failed"
40+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"id": "evt_1KJrGtJDPojXS6LN15fcthM3",
3+
"object": "event",
4+
"api_version": "2020-03-02",
5+
"created": 1642649111,
6+
"data": {
7+
"object": {
8+
"id": "re_1Nispe2eZvKYlo2Cd31jOCgZ",
9+
"object": "refund",
10+
"amount": 1000,
11+
"balance_transaction": "txn_1Nispe2eZvKYlo2CYezqFhEx",
12+
"charge": "ch_1NirD82eZvKYlo2CIvbtLWuY",
13+
"created": 1692942318,
14+
"currency": "usd",
15+
"destination_details": {
16+
"card": {
17+
"reference": "123456789012",
18+
"reference_status": "available",
19+
"reference_type": "acquirer_reference_number",
20+
"type": "refund"
21+
},
22+
"type": "card"
23+
},
24+
"metadata": {},
25+
"payment_intent": "pi_1GszsK2eZvKYlo2CfhZyoZLp",
26+
"reason": null,
27+
"receipt_number": null,
28+
"source_transfer_reversal": null,
29+
"status": "succeeded",
30+
"transfer_reversal": null
31+
}
32+
},
33+
"livemode": false,
34+
"pending_webhooks": 3,
35+
"request": {
36+
"id": null,
37+
"idempotency_key": null
38+
},
39+
"type": "refund.updated"
40+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ describe('POST /webhooks', () => {
100100
'early_fraud_warning_updated',
101101
'review_closed',
102102
'review_opened',
103+
'refund_created',
104+
'refund_failed',
105+
'refund_updated',
103106
])('process event %s', async (jsonFile) => {
104107
const eventBody = await import(`./stripe/${jsonFile}`).then(({ default: myData }) => myData)
105108
const signature = createHmac('sha256', stripeWebhookSecret)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
create table
2+
if not exists "stripe"."refunds" (
3+
"id" text primary key,
4+
object text,
5+
amount integer,
6+
balance_transaction text,
7+
charge text,
8+
created integer,
9+
currency text,
10+
destination_details jsonb,
11+
metadata jsonb,
12+
payment_intent text,
13+
reason text,
14+
receipt_number text,
15+
source_transfer_reversal text,
16+
status text,
17+
transfer_reversal text,
18+
updated_at timestamptz default timezone('utc'::text, now()) not null
19+
);
20+
21+
create index stripe_refunds_charge_idx on "stripe"."refunds" using btree (charge);
22+
23+
create index stripe_refunds_payment_intent_idx on "stripe"."refunds" using btree (payment_intent);
24+
25+
create trigger handle_updated_at
26+
before update
27+
on stripe.refunds
28+
for each row
29+
execute procedure set_updated_at();
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { EntitySchema } from './types'
2+
3+
export const refundSchema: EntitySchema = {
4+
properties: [
5+
'id',
6+
'object',
7+
'amount',
8+
'balance_transaction',
9+
'charge',
10+
'created',
11+
'currency',
12+
'destination_details',
13+
'metadata',
14+
'payment_intent',
15+
'reason',
16+
'receipt_number',
17+
'source_transfer_reversal',
18+
'status',
19+
'transfer_reversal',
20+
],
21+
} as const

packages/sync-engine/src/stripeSync.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { subscriptionSchema } from './schemas/subscription'
1919
import { StripeSyncConfig, Sync, SyncBackfill, SyncBackfillParams } from './types'
2020
import { earlyFraudWarningSchema } from './schemas/early_fraud_warning'
2121
import { reviewSchema } from './schemas/review'
22+
import { refundSchema } from './schemas/refund'
2223

2324
function getUniqueIds<T>(entries: T[], key: string): string[] {
2425
const set = new Set(
@@ -384,6 +385,22 @@ export class StripeSync {
384385
break
385386
}
386387

388+
case 'refund.created':
389+
case 'refund.failed':
390+
case 'refund.updated':
391+
case 'charge.refund.updated': {
392+
const refund = await this.fetchOrUseWebhookData(event.data.object as Stripe.Refund, (id) =>
393+
this.stripe.refunds.retrieve(id)
394+
)
395+
396+
this.config.logger?.info(
397+
`Received webhook ${event.id}: ${event.type} for refund ${refund.id}`
398+
)
399+
400+
await this.upsertRefunds([refund])
401+
break
402+
}
403+
387404
case 'review.closed':
388405
case 'review.opened': {
389406
const review = await this.fetchOrUseWebhookData(event.data.object as Stripe.Review, (id) =>
@@ -458,6 +475,8 @@ export class StripeSync {
458475
.then((it) => this.upsertEarlyFraudWarning([it]))
459476
} else if (stripeId.startsWith('prv_')) {
460477
return this.stripe.reviews.retrieve(stripeId).then((it) => this.upsertReviews([it]))
478+
} else if (stripeId.startsWith('re_')) {
479+
return this.stripe.refunds.retrieve(stripeId).then((it) => this.upsertRefunds([it]))
461480
}
462481
}
463482

@@ -477,7 +496,8 @@ export class StripeSync {
477496
plans,
478497
taxIds,
479498
creditNotes,
480-
earlyFraudWarnings
499+
earlyFraudWarnings,
500+
refunds
481501

482502
switch (object) {
483503
case 'all':
@@ -496,6 +516,7 @@ export class StripeSync {
496516
creditNotes = await this.syncCreditNotes(params)
497517
disputes = await this.syncDisputes(params)
498518
earlyFraudWarnings = await this.syncEarlyFraudWarnings(params)
519+
refunds = await this.syncRefunds(params)
499520
break
500521
case 'customer':
501522
customers = await this.syncCustomers(params)
@@ -541,6 +562,9 @@ export class StripeSync {
541562
case 'early_fraud_warning':
542563
earlyFraudWarnings = await this.syncEarlyFraudWarnings(params)
543564
break
565+
case 'refund':
566+
refunds = await this.syncRefunds(params)
567+
break
544568
default:
545569
break
546570
}
@@ -561,6 +585,7 @@ export class StripeSync {
561585
taxIds,
562586
creditNotes,
563587
earlyFraudWarnings,
588+
refunds,
564589
}
565590
}
566591

@@ -757,6 +782,18 @@ export class StripeSync {
757782
)
758783
}
759784

785+
async syncRefunds(syncParams?: SyncBackfillParams): Promise<Sync> {
786+
this.config.logger?.info('Syncing refunds')
787+
788+
const params: Stripe.RefundListParams = { limit: 100 }
789+
if (syncParams?.created) params.created = syncParams.created
790+
791+
return this.fetchAndUpsert(
792+
() => this.stripe.refunds.list(params),
793+
(items) => this.upsertRefunds(items, syncParams?.backfillRelatedEntities)
794+
)
795+
}
796+
760797
async syncCreditNotes(syncParams?: SyncBackfillParams): Promise<Sync> {
761798
this.config.logger?.info('Syncing credit notes')
762799

@@ -867,6 +904,20 @@ export class StripeSync {
867904
)
868905
}
869906

907+
async upsertRefunds(
908+
refunds: Stripe.Refund[],
909+
backfillRelatedEntities?: boolean
910+
): Promise<Stripe.Refund[]> {
911+
if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) {
912+
await Promise.all([
913+
this.backfillPaymentIntents(getUniqueIds(refunds, 'payment_intent')),
914+
this.backfillCharges(getUniqueIds(refunds, 'charge')),
915+
])
916+
}
917+
918+
return this.postgresClient.upsertMany(refunds, 'refunds', refundSchema)
919+
}
920+
870921
async upsertReviews(
871922
reviews: Stripe.Review[],
872923
backfillRelatedEntities?: boolean

packages/sync-engine/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export type SyncObject =
5757
| 'tax_id'
5858
| 'credit_note'
5959
| 'early_fraud_warning'
60+
| 'refund'
6061

6162
export interface Sync {
6263
synced: number
@@ -78,6 +79,7 @@ export interface SyncBackfill {
7879
taxIds?: Sync
7980
creditNotes?: Sync
8081
earlyFraudWarnings?: Sync
82+
refunds?: Sync
8183
}
8284

8385
export interface SyncBackfillParams {

0 commit comments

Comments
 (0)