Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: sync credit notes #113

Merged
merged 3 commits into from
Jul 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ This server synchronizes your Stripe account to a Postgres database. It can be a
- [ ] `checkout.session.async_payment_failed`
- [ ] `checkout.session.async_payment_succeeded`
- [ ] `checkout.session.completed`
- [x] `credit_note.created` 🟢
- [x] `credit_note.updated` 🟢
- [x] `credit_note.voided` 🟢
- [x] `customer.created` 🟢
- [x] `customer.deleted` 🟢
- [ ] `customer.source.created`
Expand Down Expand Up @@ -129,7 +132,7 @@ body: {
}
```

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

#### Alternative routes to sync `daily/weekly/monthly` data
Expand Down
36 changes: 36 additions & 0 deletions db/migrations/0026_credit_notes.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
create table if not exists
"stripe"."credit_notes" (
"id" text primary key,
object text,
amount integer,
amount_shipping integer,
created integer,
currency text,
customer text,
customer_balance_transaction text,
discount_amount integer,
discount_amounts jsonb,
invoice text,
lines jsonb,
livemode boolean,
memo text,
metadata jsonb,
number text,
out_of_band_amount integer,
pdf text,
reason text,
refund text,
shipping_cost jsonb,
status text,
subtotal integer,
subtotal_excluding_tax integer,
tax_amounts jsonb,
total integer,
total_excluding_tax integer,
type text,
voided_at text
);

create index stripe_credit_notes_customer_idx on "stripe"."credit_notes" using btree (customer);

create index stripe_credit_notes_invoice_idx on "stripe"."credit_notes" using btree (invoice);
64 changes: 64 additions & 0 deletions src/lib/creditNotes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import Stripe from 'stripe'
import { getConfig } from '../utils/config'
import { constructUpsertSql } from '../utils/helpers'
import { backfillInvoices } from './invoices'
import { backfillCustomers } from './customers'
import { findMissingEntries, getUniqueIds, upsertMany } from './database_utils'
import { stripe } from '../utils/StripeClientManager'
import { creditNoteSchema } from '../schemas/credit_note'

const config = getConfig()

export const upsertCreditNotes = async (
creditNotes: Stripe.CreditNote[],
backfillRelatedEntities: boolean = true
): Promise<Stripe.CreditNote[]> => {
if (backfillRelatedEntities) {
await Promise.all([
backfillCustomers(getUniqueIds(creditNotes, 'customer')),
backfillInvoices(getUniqueIds(creditNotes, 'invoice')),
])
}

// Stripe only sends the first 10 refunds by default, the option will actively fetch all refunds
if (getConfig().AUTO_EXPAND_LISTS) {
for (const creditNote of creditNotes) {
if (creditNote.lines?.has_more) {
const allLines: Stripe.CreditNoteLineItem[] = []
for await (const lineItem of stripe.creditNotes.listLineItems(creditNote.id, {
limit: 100,
})) {
allLines.push(lineItem)
}

creditNote.lines = {
...creditNote.lines,
data: allLines,
has_more: false,
}
}
}
}

return upsertMany(creditNotes, () =>
constructUpsertSql(config.SCHEMA, 'credit_notes', creditNoteSchema)
)
}

export const backfillCreditNotes = async (creditNoteIds: string[]) => {
const missingCreditNoteIds = await findMissingEntries('credit_notes', creditNoteIds)
await fetchAndInsertCreditNotes(missingCreditNoteIds)
}

const fetchAndInsertCreditNotes = async (creditNoteIds: string[]) => {
if (!creditNoteIds.length) return

const creditNotes: Stripe.CreditNote[] = []

for (const creditNoteId of creditNoteIds) {
const creditNote = await stripe.creditNotes.retrieve(creditNoteId)
creditNotes.push(creditNote)
}

await upsertCreditNotes(creditNotes, true)
}
25 changes: 24 additions & 1 deletion src/lib/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { upsertPlans } from './plans'
import { upsertSubscriptionSchedules } from './subscription_schedules'
import pLimit from 'p-limit'
import { upsertTaxIds } from './tax_ids'
import { upsertCreditNotes } from './creditNotes'

const config = getConfig()

Expand All @@ -38,6 +39,7 @@ interface SyncBackfill {
disputes?: Sync
charges?: Sync
taxIds?: Sync
creditNotes?: Sync
}

export interface SyncBackfillParams {
Expand All @@ -61,6 +63,7 @@ type SyncObject =
| 'payment_intent'
| 'plan'
| 'tax_id'
| 'credit_note'

export async function syncSingleEntity(stripeId: string) {
if (stripeId.startsWith('cus_')) {
Expand Down Expand Up @@ -89,6 +92,8 @@ export async function syncSingleEntity(stripeId: string) {
return stripe.paymentIntents.retrieve(stripeId).then((it) => upsertPaymentIntents([it]))
} else if (stripeId.startsWith('txi_')) {
return stripe.taxIds.retrieve(stripeId).then((it) => upsertTaxIds([it]))
} else if (stripeId.startsWith('cn_')) {
return stripe.creditNotes.retrieve(stripeId).then((it) => upsertCreditNotes([it]))
}
}

Expand All @@ -106,7 +111,8 @@ export async function syncBackfill(params?: SyncBackfillParams): Promise<SyncBac
charges,
paymentIntents,
plans,
taxIds
taxIds,
creditNotes

switch (object) {
case 'all':
Expand All @@ -122,6 +128,7 @@ export async function syncBackfill(params?: SyncBackfillParams): Promise<SyncBac
paymentMethods = await syncPaymentMethods(params)
paymentIntents = await syncPaymentIntents(params)
taxIds = await syncTaxIds(params)
creditNotes = await syncCreditNotes(params)
break
case 'customer':
customers = await syncCustomers(params)
Expand Down Expand Up @@ -161,6 +168,9 @@ export async function syncBackfill(params?: SyncBackfillParams): Promise<SyncBac
case 'tax_id':
taxIds = await syncTaxIds(params)
break
case 'credit_note':
creditNotes = await syncCreditNotes(params)
break
default:
break
}
Expand All @@ -179,6 +189,7 @@ export async function syncBackfill(params?: SyncBackfillParams): Promise<SyncBac
paymentIntents,
plans,
taxIds,
creditNotes,
}
}

Expand Down Expand Up @@ -361,6 +372,18 @@ export async function syncDisputes(syncParams?: SyncBackfillParams): Promise<Syn
)
}

export async function syncCreditNotes(syncParams?: SyncBackfillParams): Promise<Sync> {
console.log('Syncing credit notes')

const params: Stripe.CreditNoteListParams = { limit: 100 }
if (syncParams?.created) params.created = syncParams?.created

return fetchAndUpsert(
() => stripe.creditNotes.list(params),
(creditNotes) => upsertCreditNotes(creditNotes)
)
}

async function fetchAndUpsert<T>(
fetch: () => Stripe.ApiListPromise<T>,
upsert: (items: T[]) => Promise<T[]>
Expand Down
10 changes: 10 additions & 0 deletions src/routes/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { deletePlan, upsertPlans } from '../lib/plans'
import { upsertPaymentIntents } from '../lib/payment_intents'
import { upsertSubscriptionSchedules } from '../lib/subscription_schedules'
import { deleteTaxId, upsertTaxIds } from '../lib/tax_ids'
import { upsertCreditNotes } from '../lib/creditNotes'

const config = getConfig()

Expand Down Expand Up @@ -180,6 +181,15 @@ export default async function routes(fastify: FastifyInstance) {
break
}

case 'credit_note.created':
case 'credit_note.updated':
case 'credit_note.voided': {
const creditNote = event.data.object as Stripe.CreditNote

await upsertCreditNotes([creditNote])
break
}

default:
throw new Error('Unhandled webhook event')
}
Expand Down
38 changes: 38 additions & 0 deletions src/schemas/credit_note.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { JsonSchema } from '../types/types'

export const creditNoteSchema: JsonSchema = {
$id: 'creditNoteSchema',
type: 'object',
properties: {
id: { type: 'string' },
object: { type: 'string' },
amount: { type: 'number' },
amount_shipping: { type: 'number' },
created: { type: 'number' },
currency: { type: 'string' },
customer: { type: 'string' },
customer_balance_transaction: { type: 'string' },
discount_amount: { type: 'number' },
discount_amounts: { type: 'object' },
invoice: { type: 'string' },
lines: { type: 'object' },
livemode: { type: 'boolean' },
memo: { type: 'string' },
metadata: { type: 'object' },
number: { type: 'string' },
out_of_band_amount: { type: 'number' },
pdf: { type: 'string' },
reason: { type: 'string' },
refund: { type: 'string' },
shipping_cost: { type: 'object' },
status: { type: 'string' },
subtotal: { type: 'number' },
subtotal_excluding_tax: { type: 'number' },
tax_amounts: { type: 'object' },
total: { type: 'number' },
total_excluding_tax: { type: 'number' },
type: { type: 'string' },
voided_at: { type: 'string' },
},
required: ['id'],
} as const
73 changes: 73 additions & 0 deletions test/stripe/credit_note_created.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
{
"id": "evt_1KJrLuJDPojXS6LNKLCh0CEr",
"object": "event",
"api_version": "2020-03-02",
"created": 1642649422,
"data": {
"object": {
"id": "cn_1MxvRqLkdIwHu7ixY0xbUcxk",
"object": "credit_note",
"amount": 1099,
"amount_shipping": 0,
"created": 1681750958,
"currency": "usd",
"customer": "cus_NjLgPhUokHubJC",
"customer_balance_transaction": null,
"discount_amount": 0,
"discount_amounts": [],
"invoice": "in_1MxvRkLkdIwHu7ixABNtI99m",
"lines": {
"object": "list",
"data": [
{
"id": "cnli_1MxvRqLkdIwHu7ixFpdhBFQf",
"object": "credit_note_line_item",
"amount": 1099,
"amount_excluding_tax": 1099,
"description": "T-shirt",
"discount_amount": 0,
"discount_amounts": [],
"invoice_line_item": "il_1MxvRlLkdIwHu7ixnkbntxUV",
"livemode": false,
"quantity": 1,
"tax_amounts": [],
"tax_rates": [],
"type": "invoice_line_item",
"unit_amount": 1099,
"unit_amount_decimal": "1099",
"unit_amount_excluding_tax": "1099"
}
],
"has_more": false,
"url": "/v1/credit_notes/cn_1MxvRqLkdIwHu7ixY0xbUcxk/lines"
},
"livemode": false,
"memo": null,
"metadata": {},
"number": "C9E0C52C-0036-CN-01",
"out_of_band_amount": null,
"pdf": "https://pay.stripe.com/credit_notes/acct_1M2JTkLkdIwHu7ix/test_YWNjdF8xTTJKVGtMa2RJd0h1N2l4LF9Oak9FOUtQNFlPdk52UXhFd2Z4SU45alpEd21kd0Y4LDcyMjkxNzU50200cROQsSK2/pdf?s=ap",
"reason": null,
"refund": null,
"shipping_cost": null,
"status": "issued",
"subtotal": 1099,
"subtotal_excluding_tax": 1099,
"tax_amounts": [],
"total": 1099,
"total_excluding_tax": 1099,
"type": "pre_payment",
"voided_at": null
},
"previous_attributes": {
"custom_fields": null
}
},
"livemode": false,
"pending_webhooks": 3,
"request": {
"id": "req_m87bnWeVxyQPx0",
"idempotency_key": "010d8300-b837-46e0-a795-6247dd0e05e1"
},
"type": "credit_note.created"
}
Loading