Skip to content

Commit 8d19019

Browse files
authored
feat: sync credit notes (#113)
1 parent 8969eb6 commit 8d19019

File tree

10 files changed

+398
-2
lines changed

10 files changed

+398
-2
lines changed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ This server synchronizes your Stripe account to a Postgres database. It can be a
4141
- [ ] `checkout.session.async_payment_failed`
4242
- [ ] `checkout.session.async_payment_succeeded`
4343
- [ ] `checkout.session.completed`
44+
- [x] `credit_note.created` 🟢
45+
- [x] `credit_note.updated` 🟢
46+
- [x] `credit_note.voided` 🟢
4447
- [x] `customer.created` 🟢
4548
- [x] `customer.deleted` 🟢
4649
- [ ] `customer.source.created`
@@ -129,7 +132,7 @@ body: {
129132
}
130133
```
131134

132-
- `object` **all** | **charge** | **customer** | **dispute** | **invoice** | **payment_method** | **payment_intent** | **plan** | **price** | **product** | **setup_intent** | **subscription**
135+
- `object` **all** | **charge** | **customer** | **dispute** | **invoice** | **payment_method** | **payment_intent** | **plan** | **price** | **product** | **setup_intent** | **subscription**
133136
- `created` is Stripe.RangeQueryParam. It supports **gt**, **gte**, **lt**, **lte**
134137

135138
#### Alternative routes to sync `daily/weekly/monthly` data

db/migrations/0026_credit_notes.sql

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
create table if not exists
2+
"stripe"."credit_notes" (
3+
"id" text primary key,
4+
object text,
5+
amount integer,
6+
amount_shipping integer,
7+
created integer,
8+
currency text,
9+
customer text,
10+
customer_balance_transaction text,
11+
discount_amount integer,
12+
discount_amounts jsonb,
13+
invoice text,
14+
lines jsonb,
15+
livemode boolean,
16+
memo text,
17+
metadata jsonb,
18+
number text,
19+
out_of_band_amount integer,
20+
pdf text,
21+
reason text,
22+
refund text,
23+
shipping_cost jsonb,
24+
status text,
25+
subtotal integer,
26+
subtotal_excluding_tax integer,
27+
tax_amounts jsonb,
28+
total integer,
29+
total_excluding_tax integer,
30+
type text,
31+
voided_at text
32+
);
33+
34+
create index stripe_credit_notes_customer_idx on "stripe"."credit_notes" using btree (customer);
35+
36+
create index stripe_credit_notes_invoice_idx on "stripe"."credit_notes" using btree (invoice);

src/lib/creditNotes.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import Stripe from 'stripe'
2+
import { getConfig } from '../utils/config'
3+
import { constructUpsertSql } from '../utils/helpers'
4+
import { backfillInvoices } from './invoices'
5+
import { backfillCustomers } from './customers'
6+
import { findMissingEntries, getUniqueIds, upsertMany } from './database_utils'
7+
import { stripe } from '../utils/StripeClientManager'
8+
import { creditNoteSchema } from '../schemas/credit_note'
9+
10+
const config = getConfig()
11+
12+
export const upsertCreditNotes = async (
13+
creditNotes: Stripe.CreditNote[],
14+
backfillRelatedEntities: boolean = true
15+
): Promise<Stripe.CreditNote[]> => {
16+
if (backfillRelatedEntities) {
17+
await Promise.all([
18+
backfillCustomers(getUniqueIds(creditNotes, 'customer')),
19+
backfillInvoices(getUniqueIds(creditNotes, 'invoice')),
20+
])
21+
}
22+
23+
// Stripe only sends the first 10 refunds by default, the option will actively fetch all refunds
24+
if (getConfig().AUTO_EXPAND_LISTS) {
25+
for (const creditNote of creditNotes) {
26+
if (creditNote.lines?.has_more) {
27+
const allLines: Stripe.CreditNoteLineItem[] = []
28+
for await (const lineItem of stripe.creditNotes.listLineItems(creditNote.id, {
29+
limit: 100,
30+
})) {
31+
allLines.push(lineItem)
32+
}
33+
34+
creditNote.lines = {
35+
...creditNote.lines,
36+
data: allLines,
37+
has_more: false,
38+
}
39+
}
40+
}
41+
}
42+
43+
return upsertMany(creditNotes, () =>
44+
constructUpsertSql(config.SCHEMA, 'credit_notes', creditNoteSchema)
45+
)
46+
}
47+
48+
export const backfillCreditNotes = async (creditNoteIds: string[]) => {
49+
const missingCreditNoteIds = await findMissingEntries('credit_notes', creditNoteIds)
50+
await fetchAndInsertCreditNotes(missingCreditNoteIds)
51+
}
52+
53+
const fetchAndInsertCreditNotes = async (creditNoteIds: string[]) => {
54+
if (!creditNoteIds.length) return
55+
56+
const creditNotes: Stripe.CreditNote[] = []
57+
58+
for (const creditNoteId of creditNoteIds) {
59+
const creditNote = await stripe.creditNotes.retrieve(creditNoteId)
60+
creditNotes.push(creditNote)
61+
}
62+
63+
await upsertCreditNotes(creditNotes, true)
64+
}

src/lib/sync.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { upsertPlans } from './plans'
1717
import { upsertSubscriptionSchedules } from './subscription_schedules'
1818
import pLimit from 'p-limit'
1919
import { upsertTaxIds } from './tax_ids'
20+
import { upsertCreditNotes } from './creditNotes'
2021

2122
const config = getConfig()
2223

@@ -38,6 +39,7 @@ interface SyncBackfill {
3839
disputes?: Sync
3940
charges?: Sync
4041
taxIds?: Sync
42+
creditNotes?: Sync
4143
}
4244

4345
export interface SyncBackfillParams {
@@ -61,6 +63,7 @@ type SyncObject =
6163
| 'payment_intent'
6264
| 'plan'
6365
| 'tax_id'
66+
| 'credit_note'
6467

6568
export async function syncSingleEntity(stripeId: string) {
6669
if (stripeId.startsWith('cus_')) {
@@ -89,6 +92,8 @@ export async function syncSingleEntity(stripeId: string) {
8992
return stripe.paymentIntents.retrieve(stripeId).then((it) => upsertPaymentIntents([it]))
9093
} else if (stripeId.startsWith('txi_')) {
9194
return stripe.taxIds.retrieve(stripeId).then((it) => upsertTaxIds([it]))
95+
} else if (stripeId.startsWith('cn_')) {
96+
return stripe.creditNotes.retrieve(stripeId).then((it) => upsertCreditNotes([it]))
9297
}
9398
}
9499

@@ -106,7 +111,8 @@ export async function syncBackfill(params?: SyncBackfillParams): Promise<SyncBac
106111
charges,
107112
paymentIntents,
108113
plans,
109-
taxIds
114+
taxIds,
115+
creditNotes
110116

111117
switch (object) {
112118
case 'all':
@@ -122,6 +128,7 @@ export async function syncBackfill(params?: SyncBackfillParams): Promise<SyncBac
122128
paymentMethods = await syncPaymentMethods(params)
123129
paymentIntents = await syncPaymentIntents(params)
124130
taxIds = await syncTaxIds(params)
131+
creditNotes = await syncCreditNotes(params)
125132
break
126133
case 'customer':
127134
customers = await syncCustomers(params)
@@ -161,6 +168,9 @@ export async function syncBackfill(params?: SyncBackfillParams): Promise<SyncBac
161168
case 'tax_id':
162169
taxIds = await syncTaxIds(params)
163170
break
171+
case 'credit_note':
172+
creditNotes = await syncCreditNotes(params)
173+
break
164174
default:
165175
break
166176
}
@@ -179,6 +189,7 @@ export async function syncBackfill(params?: SyncBackfillParams): Promise<SyncBac
179189
paymentIntents,
180190
plans,
181191
taxIds,
192+
creditNotes,
182193
}
183194
}
184195

@@ -361,6 +372,18 @@ export async function syncDisputes(syncParams?: SyncBackfillParams): Promise<Syn
361372
)
362373
}
363374

375+
export async function syncCreditNotes(syncParams?: SyncBackfillParams): Promise<Sync> {
376+
console.log('Syncing credit notes')
377+
378+
const params: Stripe.CreditNoteListParams = { limit: 100 }
379+
if (syncParams?.created) params.created = syncParams?.created
380+
381+
return fetchAndUpsert(
382+
() => stripe.creditNotes.list(params),
383+
(creditNotes) => upsertCreditNotes(creditNotes)
384+
)
385+
}
386+
364387
async function fetchAndUpsert<T>(
365388
fetch: () => Stripe.ApiListPromise<T>,
366389
upsert: (items: T[]) => Promise<T[]>

src/routes/webhooks.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { deletePlan, upsertPlans } from '../lib/plans'
1515
import { upsertPaymentIntents } from '../lib/payment_intents'
1616
import { upsertSubscriptionSchedules } from '../lib/subscription_schedules'
1717
import { deleteTaxId, upsertTaxIds } from '../lib/tax_ids'
18+
import { upsertCreditNotes } from '../lib/creditNotes'
1819

1920
const config = getConfig()
2021

@@ -180,6 +181,15 @@ export default async function routes(fastify: FastifyInstance) {
180181
break
181182
}
182183

184+
case 'credit_note.created':
185+
case 'credit_note.updated':
186+
case 'credit_note.voided': {
187+
const creditNote = event.data.object as Stripe.CreditNote
188+
189+
await upsertCreditNotes([creditNote])
190+
break
191+
}
192+
183193
default:
184194
throw new Error('Unhandled webhook event')
185195
}

src/schemas/credit_note.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { JsonSchema } from '../types/types'
2+
3+
export const creditNoteSchema: JsonSchema = {
4+
$id: 'creditNoteSchema',
5+
type: 'object',
6+
properties: {
7+
id: { type: 'string' },
8+
object: { type: 'string' },
9+
amount: { type: 'number' },
10+
amount_shipping: { type: 'number' },
11+
created: { type: 'number' },
12+
currency: { type: 'string' },
13+
customer: { type: 'string' },
14+
customer_balance_transaction: { type: 'string' },
15+
discount_amount: { type: 'number' },
16+
discount_amounts: { type: 'object' },
17+
invoice: { type: 'string' },
18+
lines: { type: 'object' },
19+
livemode: { type: 'boolean' },
20+
memo: { type: 'string' },
21+
metadata: { type: 'object' },
22+
number: { type: 'string' },
23+
out_of_band_amount: { type: 'number' },
24+
pdf: { type: 'string' },
25+
reason: { type: 'string' },
26+
refund: { type: 'string' },
27+
shipping_cost: { type: 'object' },
28+
status: { type: 'string' },
29+
subtotal: { type: 'number' },
30+
subtotal_excluding_tax: { type: 'number' },
31+
tax_amounts: { type: 'object' },
32+
total: { type: 'number' },
33+
total_excluding_tax: { type: 'number' },
34+
type: { type: 'string' },
35+
voided_at: { type: 'string' },
36+
},
37+
required: ['id'],
38+
} as const

test/stripe/credit_note_created.json

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
{
2+
"id": "evt_1KJrLuJDPojXS6LNKLCh0CEr",
3+
"object": "event",
4+
"api_version": "2020-03-02",
5+
"created": 1642649422,
6+
"data": {
7+
"object": {
8+
"id": "cn_1MxvRqLkdIwHu7ixY0xbUcxk",
9+
"object": "credit_note",
10+
"amount": 1099,
11+
"amount_shipping": 0,
12+
"created": 1681750958,
13+
"currency": "usd",
14+
"customer": "cus_NjLgPhUokHubJC",
15+
"customer_balance_transaction": null,
16+
"discount_amount": 0,
17+
"discount_amounts": [],
18+
"invoice": "in_1MxvRkLkdIwHu7ixABNtI99m",
19+
"lines": {
20+
"object": "list",
21+
"data": [
22+
{
23+
"id": "cnli_1MxvRqLkdIwHu7ixFpdhBFQf",
24+
"object": "credit_note_line_item",
25+
"amount": 1099,
26+
"amount_excluding_tax": 1099,
27+
"description": "T-shirt",
28+
"discount_amount": 0,
29+
"discount_amounts": [],
30+
"invoice_line_item": "il_1MxvRlLkdIwHu7ixnkbntxUV",
31+
"livemode": false,
32+
"quantity": 1,
33+
"tax_amounts": [],
34+
"tax_rates": [],
35+
"type": "invoice_line_item",
36+
"unit_amount": 1099,
37+
"unit_amount_decimal": "1099",
38+
"unit_amount_excluding_tax": "1099"
39+
}
40+
],
41+
"has_more": false,
42+
"url": "/v1/credit_notes/cn_1MxvRqLkdIwHu7ixY0xbUcxk/lines"
43+
},
44+
"livemode": false,
45+
"memo": null,
46+
"metadata": {},
47+
"number": "C9E0C52C-0036-CN-01",
48+
"out_of_band_amount": null,
49+
"pdf": "https://pay.stripe.com/credit_notes/acct_1M2JTkLkdIwHu7ix/test_YWNjdF8xTTJKVGtMa2RJd0h1N2l4LF9Oak9FOUtQNFlPdk52UXhFd2Z4SU45alpEd21kd0Y4LDcyMjkxNzU50200cROQsSK2/pdf?s=ap",
50+
"reason": null,
51+
"refund": null,
52+
"shipping_cost": null,
53+
"status": "issued",
54+
"subtotal": 1099,
55+
"subtotal_excluding_tax": 1099,
56+
"tax_amounts": [],
57+
"total": 1099,
58+
"total_excluding_tax": 1099,
59+
"type": "pre_payment",
60+
"voided_at": null
61+
},
62+
"previous_attributes": {
63+
"custom_fields": null
64+
}
65+
},
66+
"livemode": false,
67+
"pending_webhooks": 3,
68+
"request": {
69+
"id": "req_m87bnWeVxyQPx0",
70+
"idempotency_key": "010d8300-b837-46e0-a795-6247dd0e05e1"
71+
},
72+
"type": "credit_note.created"
73+
}

0 commit comments

Comments
 (0)