Skip to content

Commit 871a698

Browse files
authored
feat: add support for disputes (#67)
1 parent e2e598d commit 871a698

13 files changed

Lines changed: 542 additions & 2 deletions

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ This server synchronizes your Stripe account to a Postgres database. It can be a
3535
- [x] `charge.refunded` 🟢
3636
- [x] `charge.succeeded` 🟢
3737
- [x] `charge.updated` 🟢
38-
- [ ] `charge.dispute.created`
38+
- [x] `charge.dispute.closed` 🟢
39+
- [x] `charge.dispute.created` 🟢
40+
- [x] `charge.dispute.funds_reinstated` 🟢
41+
- [x] `charge.dispute.funds_withdrawn` 🟢
42+
- [x] `charge.dispute.updated` 🟢
3943
- [ ] `checkout.session.async_payment_failed`
4044
- [ ] `checkout.session.async_payment_succeeded`
4145
- [ ] `checkout.session.completed`
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
ALTER TABLE "stripe"."disputes" ADD COLUMN IF NOT EXISTS payment_intent TEXT;
2+
3+
CREATE INDEX IF NOT EXISTS stripe_dispute_created_idx ON "stripe"."disputes" USING btree (created);

src/lib/disputes.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import Stripe from 'stripe'
2+
import { query } from '../utils/PostgresConnection'
3+
import { pg as sql } from 'yesql'
4+
import { getConfig } from '../utils/config'
5+
import { stripe } from '../utils/StripeClientManager'
6+
import { cleanseArrayField, constructUpsertSql } from '../utils/helpers'
7+
import { upsertCharge } from './charges'
8+
import { disputeSchema } from '../schemas/dispute'
9+
10+
const config = getConfig()
11+
12+
export const upsertDispute = async (charge: Stripe.Dispute): Promise<Stripe.Dispute[]> => {
13+
// Backfill charge if it doesn't already exist
14+
const chargeId = charge?.charge?.toString()
15+
if (chargeId && !(await verifyChargeExists(chargeId))) {
16+
await fetchAndInsertCharge(chargeId)
17+
}
18+
19+
// Create the SQL
20+
const upsertString = constructUpsertSql(config.SCHEMA || 'stripe', 'disputes', disputeSchema)
21+
22+
// Inject the values
23+
const cleansed = cleanseArrayField(charge)
24+
const prepared = sql(upsertString)(cleansed)
25+
26+
// Run it
27+
const { rows } = await query(prepared.text, prepared.values)
28+
return rows
29+
}
30+
31+
export const verifyChargeExists = async (id: string): Promise<boolean> => {
32+
const prepared = sql(`
33+
select id from "${config.SCHEMA}"."charges"
34+
where id = :id;
35+
`)({ id })
36+
const { rows } = await query(prepared.text, prepared.values)
37+
return rows.length > 0
38+
}
39+
40+
export const fetchAndInsertCharge = async (id: string): Promise<Stripe.Charge[]> => {
41+
const charge = await stripe.charges.retrieve(id)
42+
return upsertCharge(charge)
43+
}

src/lib/sync.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { stripe } from '../utils/StripeClientManager'
77
import Stripe from 'stripe'
88
import { upsertSetupIntent } from './setup_intents'
99
import { upsertPaymentMethod } from './payment_methods'
10+
import { upsertDispute } from './disputes'
1011

1112
interface Sync {
1213
synced: number
@@ -20,6 +21,7 @@ interface SyncBackfill {
2021
invoices?: Sync
2122
setupIntents?: Sync
2223
paymentMethods?: Sync
24+
disputes?: Sync
2325
}
2426

2527
export interface SyncBackfillParams {
@@ -36,10 +38,11 @@ type SyncObject =
3638
| 'subscription'
3739
| 'setup_intent'
3840
| 'payment_method'
41+
| 'dispute'
3942

4043
export async function syncBackfill(params?: SyncBackfillParams): Promise<SyncBackfill> {
4144
const { created, object } = params ?? {}
42-
let products, prices, customers, subscriptions, invoices, setupIntents, paymentMethods
45+
let products, prices, customers, subscriptions, invoices, setupIntents, paymentMethods, disputes
4346

4447
switch (object) {
4548
case 'all':
@@ -72,6 +75,9 @@ export async function syncBackfill(params?: SyncBackfillParams): Promise<SyncBac
7275
case 'payment_method':
7376
paymentMethods = await syncPaymentMethods(created)
7477
break
78+
case 'dispute':
79+
disputes = await syncDisputes(created)
80+
break
7581
default:
7682
break
7783
}
@@ -84,6 +90,7 @@ export async function syncBackfill(params?: SyncBackfillParams): Promise<SyncBac
8490
invoices,
8591
setupIntents,
8692
paymentMethods,
93+
disputes,
8794
}
8895
}
8996

@@ -213,3 +220,16 @@ export async function syncPaymentMethods(created?: Stripe.RangeQueryParam): Prom
213220

214221
return { synced }
215222
}
223+
224+
export async function syncDisputes(created?: Stripe.RangeQueryParam): Promise<Sync> {
225+
const params: Stripe.DisputeListParams = { limit: 100 }
226+
if (created) params.created = created
227+
228+
let synced = 0
229+
for await (const dispute of stripe.disputes.list(params)) {
230+
await upsertDispute(dispute)
231+
synced++
232+
}
233+
234+
return { synced }
235+
}

src/routes/webhooks.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { upsertCharge } from '../lib/charges'
1010
import Stripe from 'stripe'
1111
import { upsertSetupIntent } from '../lib/setup_intents'
1212
import { upsertPaymentMethod } from '../lib/payment_methods'
13+
import { upsertDispute } from '../lib/disputes'
1314

1415
const config = getConfig()
1516

@@ -103,6 +104,17 @@ export default async function routes(fastify: FastifyInstance) {
103104
await upsertPaymentMethod(paymentMethod)
104105
break
105106
}
107+
case 'charge.dispute.closed':
108+
case 'charge.dispute.created':
109+
case 'charge.dispute.funds_reinstated':
110+
case 'charge.dispute.funds_withdrawn':
111+
case 'charge.dispute.updated':
112+
case 'charge.dispute.closed': {
113+
const dispute = event.data.object as Stripe.Dispute
114+
115+
await upsertDispute(dispute)
116+
break
117+
}
106118

107119
default:
108120
throw new Error('Unhandled webhook event')

src/schemas/dispute.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { JsonSchema } from '../types/types'
2+
3+
export const disputeSchema: JsonSchema = {
4+
$id: 'disputeSchema',
5+
type: 'object',
6+
properties: {
7+
id: { type: 'string' },
8+
object: { type: 'string' },
9+
amount: { type: 'integer' },
10+
charge: { type: 'string' },
11+
created: { type: 'integer' },
12+
currency: { type: 'string' },
13+
balance_transactions: { type: 'object' },
14+
evidence: { type: 'object' },
15+
evidence_details: { type: 'object' },
16+
is_charge_refundable: { type: 'boolean' },
17+
livemode: { type: 'boolean' },
18+
metadata: { type: 'object' },
19+
payment_intent: { type: 'string' },
20+
reason: { type: 'string' },
21+
status: { type: 'string' },
22+
},
23+
required: ['id'],
24+
} as const

test/helpers/stripe.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,4 +435,104 @@ export default {
435435
})
436436
),
437437
},
438+
charges: {
439+
retrieve: jest.fn((id) =>
440+
Promise.resolve({
441+
id,
442+
object: 'charge',
443+
amount: 3000,
444+
amount_captured: 3000,
445+
amount_refunded: 0,
446+
application: null,
447+
application_fee: null,
448+
application_fee_amount: null,
449+
balance_transaction: 'txn_3KtQThJDPojXS6LN0ClROjTy',
450+
billing_details: {
451+
address: {
452+
city: null,
453+
country: 'AU',
454+
line1: null,
455+
line2: null,
456+
postal_code: null,
457+
state: null,
458+
},
459+
email: null,
460+
name: null,
461+
phone: null,
462+
},
463+
calculated_statement_descriptor: 'SUPABASE',
464+
captured: true,
465+
created: 1651125926,
466+
currency: 'usd',
467+
customer: 'cus_J7Mkgr8mvbl1eK',
468+
description: 'Subscription update',
469+
destination: null,
470+
dispute: null,
471+
disputed: false,
472+
failure_balance_transaction: null,
473+
failure_code: null,
474+
failure_message: null,
475+
fraud_details: {},
476+
invoice: 'in_1KJdKkJDPojXS6LNSwSWkZSN',
477+
livemode: false,
478+
metadata: {},
479+
on_behalf_of: null,
480+
order: null,
481+
outcome: {
482+
network_status: 'approved_by_network',
483+
reason: null,
484+
risk_level: 'normal',
485+
risk_score: 55,
486+
seller_message: 'Payment complete.',
487+
type: 'authorized',
488+
},
489+
paid: true,
490+
payment_intent: 'pi_3KtQThJDPojXS6LN0H9EfsjV',
491+
payment_method: 'pm_1KtQTCJDPojXS6LNmYLNUmTc',
492+
payment_method_details: {
493+
card: {
494+
brand: 'visa',
495+
checks: {
496+
address_line1_check: null,
497+
address_postal_code_check: null,
498+
cvc_check: 'pass',
499+
},
500+
country: 'US',
501+
exp_month: 4,
502+
exp_year: 2024,
503+
fingerprint: 'oPS2CZBviZaiMQyc',
504+
funding: 'credit',
505+
installments: null,
506+
last4: '4242',
507+
mandate: null,
508+
network: 'visa',
509+
three_d_secure: null,
510+
wallet: null,
511+
},
512+
type: 'card',
513+
},
514+
receipt_email: null,
515+
receipt_number: null,
516+
receipt_url:
517+
'https://pay.stripe.com/receipts/acct_1GThseJDPojXS6LN/ch_3KtQThJDPojXS6LN0YmgbxGj/rcpt_LabhDLLWN1rPswDktT0KujMSqYV8ROm',
518+
refunded: false,
519+
refunds: {
520+
object: 'list',
521+
data: [],
522+
has_more: false,
523+
total_count: 0,
524+
url: '/v1/charges/ch_3KtQThJDPojXS6LN0YmgbxGj/refunds',
525+
},
526+
review: null,
527+
shipping: null,
528+
source: null,
529+
source_transfer: null,
530+
statement_descriptor: null,
531+
statement_descriptor_suffix: null,
532+
status: 'succeeded',
533+
transfer_data: null,
534+
transfer_group: null,
535+
})
536+
),
537+
},
438538
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
{
2+
"id": "evt_3KtQThJDPojXS6LN0E06aNxq",
3+
"object": "event",
4+
"api_version": "2020-03-02",
5+
"created": 1619701111,
6+
"data": {
7+
"object": {
8+
"id": "dp_1MLuxQJDPojXS6LNrukJCBBG",
9+
"object": "dispute",
10+
"amount": 1000,
11+
"balance_transactions": [],
12+
"charge": "ch_1IqtGAJDPojXS6LNstgEfUji",
13+
"created": 1672692608,
14+
"currency": "usd",
15+
"evidence": {
16+
"access_activity_log": null,
17+
"billing_address": null,
18+
"cancellation_policy": null,
19+
"cancellation_policy_disclosure": null,
20+
"cancellation_rebuttal": null,
21+
"customer_communication": null,
22+
"customer_email_address": null,
23+
"customer_name": null,
24+
"customer_purchase_ip": null,
25+
"customer_signature": null,
26+
"duplicate_charge_documentation": null,
27+
"duplicate_charge_explanation": null,
28+
"duplicate_charge_id": null,
29+
"product_description": null,
30+
"receipt": null,
31+
"refund_policy": null,
32+
"refund_policy_disclosure": null,
33+
"refund_refusal_explanation": null,
34+
"service_date": null,
35+
"service_documentation": null,
36+
"shipping_address": null,
37+
"shipping_carrier": null,
38+
"shipping_date": null,
39+
"shipping_documentation": null,
40+
"shipping_tracking_number": null,
41+
"uncategorized_file": null,
42+
"uncategorized_text": null
43+
},
44+
"evidence_details": {
45+
"due_by": 1674345599,
46+
"has_evidence": false,
47+
"past_due": false,
48+
"submission_count": 0
49+
},
50+
"is_charge_refundable": true,
51+
"livemode": false,
52+
"metadata": {},
53+
"payment_intent": "pi_1IqtG9JDPojXS6LNOGnYrIqq",
54+
"reason": "general",
55+
"status": "warning_needs_response"
56+
}
57+
},
58+
"livemode": false,
59+
"pending_webhooks": 4,
60+
"request": {
61+
"id": "req_QyDCzn33ls4m1t",
62+
"idempotency_key": null
63+
},
64+
"type": "charge.dispute.closed"
65+
}

0 commit comments

Comments
 (0)