InvoicesSettings.tsx260 lines · main
1import dayjs from 'dayjs'
2import { ChevronLeft, ChevronRight, FileText, Receipt, ScrollText } from 'lucide-react'
3import { useEffect, useState } from 'react'
4import { toast } from 'sonner'
5import {
6 Button,
7 Card,
8 CardFooter,
9 cn,
10 Table,
11 TableBody,
12 TableCell,
13 TableHead,
14 TableHeader,
15 TableRow,
16} from 'ui'
17import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
18
19import InvoicePayButton from './InvoicePayButton'
20import { InvoiceStatus } from '@/components/interfaces/Billing/Invoices.types'
21import InvoiceStatusBadge from '@/components/interfaces/Billing/InvoiceStatusBadge'
22import AlertError from '@/components/ui/AlertError'
23import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
24import PartnerManagedResource from '@/components/ui/PartnerManagedResource'
25import { getInvoice } from '@/data/invoices/invoice-query'
26import { getInvoiceReceipt } from '@/data/invoices/invoice-receipt-query'
27import { useInvoicesCountQuery } from '@/data/invoices/invoices-count-query'
28import { useInvoicesQuery } from '@/data/invoices/invoices-query'
29import { isPartnerBillingOrganization } from '@/data/organizations/managed-by-utils'
30import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
31import { MANAGED_BY } from '@/lib/constants/infrastructure'
32import { formatCurrency } from '@/lib/helpers'
33import { Organization } from '@/types/base'
34
35const PAGE_LIMIT = 5
36
37const getPartnerManagedResourceCta = (selectedOrganization: Organization) => {
38 if (selectedOrganization.managed_by === MANAGED_BY.VERCEL_MARKETPLACE) {
39 return {
40 installationId: selectedOrganization?.partner_id,
41 path: '/invoices',
42 }
43 }
44 if (selectedOrganization.managed_by === MANAGED_BY.AWS_MARKETPLACE) {
45 return {
46 organizationSlug: selectedOrganization?.slug,
47 overrideUrl: 'https://console.aws.amazon.com/billing/home#/bills',
48 }
49 }
50}
51export const InvoicesSettings = () => {
52 const [page, setPage] = useState(1)
53
54 const { data: selectedOrganization } = useSelectedOrganizationQuery()
55 const slug = selectedOrganization?.slug
56 const isPartnerBilledOrganization = isPartnerBillingOrganization(
57 selectedOrganization?.billing_partner
58 )
59 const offset = (page - 1) * PAGE_LIMIT
60
61 const { data: count, isError: isErrorCount } = useInvoicesCountQuery(
62 {
63 slug,
64 },
65 { enabled: !isPartnerBilledOrganization }
66 )
67 const {
68 data,
69 error,
70 isPending: isLoading,
71 isError,
72 } = useInvoicesQuery(
73 {
74 slug,
75 offset,
76 limit: PAGE_LIMIT,
77 },
78 { enabled: !isPartnerBilledOrganization }
79 )
80 const invoices = data || []
81
82 useEffect(() => {
83 setPage(1)
84 }, [slug])
85
86 const fetchInvoice = async (id: string) => {
87 try {
88 const invoice = await getInvoice({ invoiceId: id, slug })
89 if (invoice?.invoice_pdf) window.open(invoice.invoice_pdf, '_blank')
90 } catch (error: any) {
91 toast.error(`Failed to fetch the selected invoice: ${error.message}`)
92 }
93 }
94
95 const fetchReceipt = async (invoiceId: string) => {
96 if (!slug) return
97
98 try {
99 const receipt = await getInvoiceReceipt({ invoiceId, slug })
100 if (receipt?.receipt_pdf) window.open(receipt.receipt_pdf, '_blank')
101 } catch (error: any) {
102 toast.error(`Failed to fetch receipt: ${error.message}`)
103 }
104 }
105
106 if (selectedOrganization && isPartnerBilledOrganization) {
107 return (
108 <PartnerManagedResource
109 managedBy={selectedOrganization?.managed_by}
110 resource="Invoices"
111 cta={getPartnerManagedResourceCta(selectedOrganization)}
112 />
113 )
114 }
115
116 // Handle loading state faded text for table headers
117 const tableHeadClassName =
118 isLoading || invoices.length === 0 ? 'text-foreground-muted' : undefined
119
120 return (
121 <Card>
122 <Table>
123 <TableHeader>
124 <TableRow>
125 {invoices.length > 0 && (
126 <TableHead className="w-2">
127 <span className="sr-only">Icon</span>
128 </TableHead>
129 )}
130 <TableHead className={cn(tableHeadClassName)}>Date</TableHead>
131 <TableHead className={cn(tableHeadClassName)}>Amount</TableHead>
132 <TableHead className={cn(tableHeadClassName)}>Invoice number</TableHead>
133 <TableHead className={cn(tableHeadClassName)}>Status</TableHead>
134 <TableHead>
135 <span className="sr-only">Actions</span>
136 </TableHead>
137 </TableRow>
138 </TableHeader>
139 <TableBody>
140 {isLoading ? (
141 new Array(6).fill(0).map((_, idx) => (
142 <TableRow key={`loading-${idx}`}>
143 <TableCell colSpan={invoices.length > 0 ? 6 : 5}>
144 <ShimmeringLoader />
145 </TableCell>
146 </TableRow>
147 ))
148 ) : isError ? (
149 <TableRow className="rounded-b">
150 <TableCell
151 colSpan={invoices.length > 0 ? 6 : 5}
152 className="p-0! rounded-b! overflow-hidden"
153 >
154 <AlertError
155 className="border-0 rounded-none"
156 error={error}
157 subject="Failed to retrieve invoices"
158 />
159 </TableCell>
160 </TableRow>
161 ) : invoices.length === 0 ? (
162 <TableRow className="[&>td]:hover:bg-inherit">
163 <TableCell colSpan={5} className="py-6">
164 <p className="text-foreground-lighter">No invoices for this organization yet</p>
165 </TableCell>
166 </TableRow>
167 ) : (
168 <>
169 {invoices.map((x) => {
170 return (
171 <TableRow key={x.id}>
172 <TableCell className="w-2">
173 <FileText aria-hidden="true" size={16} className="text-foreground-muted" />
174 </TableCell>
175 <TableCell>
176 <p>{dayjs(x.period_end * 1000).format('MMM DD, YYYY')}</p>
177 </TableCell>
178 <TableCell translate="no">
179 <p>{formatCurrency(x.amount_due / 100)}</p>
180 </TableCell>
181 <TableCell>
182 <p className="font-mono text-foreground-light">{x.number}</p>
183 </TableCell>
184 <TableCell>
185 <InvoiceStatusBadge
186 status={x.status as InvoiceStatus}
187 paymentAttempted={x.payment_attempted}
188 paymentProcessing={x.payment_is_processing}
189 />
190 </TableCell>
191 <TableCell className="text-right">
192 <div className="flex items-center justify-end space-x-2">
193 {x.amount_due > 0 &&
194 !x.payment_is_processing &&
195 [
196 InvoiceStatus.UNCOLLECTIBLE,
197 InvoiceStatus.OPEN,
198 InvoiceStatus.ISSUED,
199 ].includes(x.status as InvoiceStatus) && (
200 <InvoicePayButton slug={slug} invoiceId={x.id} />
201 )}
202
203 <ButtonTooltip
204 type="outline"
205 className="w-7"
206 icon={<ScrollText size={16} strokeWidth={1.5} />}
207 onClick={() => fetchInvoice(x.id)}
208 tooltip={{ content: { side: 'bottom', text: 'Download invoice' } }}
209 />
210
211 {x.status === InvoiceStatus.PAID && x.amount_due > 0 && (
212 <ButtonTooltip
213 type="outline"
214 className="w-7"
215 icon={<Receipt size={16} strokeWidth={1.5} />}
216 onClick={() => fetchReceipt(x.id)}
217 tooltip={{ content: { side: 'bottom', text: 'Download receipt' } }}
218 />
219 )}
220 </div>
221 </TableCell>
222 </TableRow>
223 )
224 })}
225 </>
226 )}
227 </TableBody>
228 </Table>
229 {invoices.length > 0 && (
230 <CardFooter className="border-t p-4 flex items-center justify-between">
231 <p className="text-foreground-muted text-sm">
232 {isErrorCount
233 ? 'Failed to retrieve total number of invoices'
234 : typeof count === 'number'
235 ? `Showing ${offset + 1} to ${offset + invoices.length} out of ${count} invoices`
236 : `Showing ${offset + 1} to ${offset + invoices.length} invoices`}
237 </p>
238 <div className="flex items-center gap-x-2" aria-label="Pagination">
239 <Button
240 icon={<ChevronLeft />}
241 aria-label="Previous page"
242 type="default"
243 size="tiny"
244 disabled={page === 1}
245 onClick={async () => setPage(page - 1)}
246 />
247 <Button
248 icon={<ChevronRight />}
249 aria-label="Next page"
250 type="default"
251 size="tiny"
252 disabled={page * PAGE_LIMIT >= (count ?? 0)}
253 onClick={async () => setPage(page + 1)}
254 />
255 </div>
256 </CardFooter>
257 )}
258 </Card>
259 )
260}