Skip to content

Commit 1d5f07d

Browse files
authored
Merge pull request dubinc#2424 from dubinc/partner-invoice
Partner invoice
2 parents e9d55ac + 57c5d3f commit 1d5f07d

5 files changed

Lines changed: 253 additions & 17 deletions

File tree

apps/web/app/(ee)/app.dub.co/invoices/[invoiceId]/route.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { prisma } from "@dub/prisma";
55
import {
66
currencyFormatter,
77
DUB_WORDMARK,
8+
EU_COUNTRY_CODES,
89
formatDate,
910
OG_AVATAR_URL,
1011
} from "@dub/utils";
@@ -148,6 +149,17 @@ export const GET = withSession(async ({ session, params }) => {
148149
maximumFractionDigits: 2,
149150
}),
150151
},
152+
// if customer is in EU, add VAT reverse charge note:
153+
// Reverse charge: VAT to be accounted for by the recipient under Article 196 of Directive 2006/112/EC.
154+
...(customer?.address?.country &&
155+
EU_COUNTRY_CODES.includes(customer.address.country)
156+
? [
157+
{
158+
label: "VAT reverse charge",
159+
value: "Tax to be paid on reverse charge basis.",
160+
},
161+
]
162+
: []),
151163
];
152164

153165
const addresses = [

apps/web/app/(ee)/partners.dub.co/(dashboard)/settings/payouts/payout-details-sheet.tsx

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ import {
99
Button,
1010
buttonVariants,
1111
ExpandingArrow,
12+
InvoiceDollar,
1213
LoadingSpinner,
1314
Sheet,
1415
StatusBadge,
1516
Table,
17+
Tooltip,
1618
useRouterStuff,
1719
useTable,
1820
} from "@dub/ui";
@@ -79,12 +81,29 @@ function PayoutDetailsSheetContent({ payout }: PayoutDetailsSheetProps) {
7981
),
8082

8183
Amount: (
82-
<strong>
83-
{currencyFormatter(payout.amount / 100, {
84-
minimumFractionDigits: 2,
85-
maximumFractionDigits: 2,
86-
})}
87-
</strong>
84+
<div className="flex items-center gap-2">
85+
<strong>
86+
{currencyFormatter(payout.amount / 100, {
87+
minimumFractionDigits: 2,
88+
maximumFractionDigits: 2,
89+
})}
90+
</strong>
91+
92+
{["completed", "processing"].includes(payout.status) && (
93+
<Tooltip content="View invoice">
94+
<div className="flex h-5 w-5 items-center justify-center rounded-md transition-colors duration-150 hover:border hover:border-neutral-200 hover:bg-neutral-100">
95+
<Link
96+
href={`/invoices/${payout.id}`}
97+
className="text-neutral-700"
98+
target="_blank"
99+
rel="noopener noreferrer"
100+
>
101+
<InvoiceDollar className="size-4" />
102+
</Link>
103+
</div>
104+
</Tooltip>
105+
)}
106+
</div>
88107
),
89108

90109
Description: payout.description || "-",

apps/web/app/(ee)/partners.dub.co/(dashboard)/settings/payouts/payout-table.tsx

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ import {
1212
Filter,
1313
StatusBadge,
1414
Table,
15+
Tooltip,
1516
usePagination,
1617
useRouterStuff,
1718
useTable,
1819
} from "@dub/ui";
19-
import { MoneyBill2 } from "@dub/ui/icons";
20+
import { InvoiceDollar, MoneyBill2 } from "@dub/ui/icons";
2021
import { OG_AVATAR_URL, formatPeriod } from "@dub/utils";
22+
import Link from "next/link";
2123
import { useEffect, useState } from "react";
2224
import { PayoutDetailsSheet } from "./payout-details-sheet";
2325
import { usePayoutFilters } from "./use-payout-filters";
@@ -94,12 +96,30 @@ export function PayoutTable() {
9496
id: "amount",
9597
header: "Amount",
9698
cell: ({ row }) => (
97-
<AmountRowItem
98-
amount={row.original.amount}
99-
status={row.original.status}
100-
payoutsEnabled={Boolean(partner?.payoutsEnabledAt)}
101-
minPayoutAmount={row.original.program.minPayoutAmount}
102-
/>
99+
<div className="flex items-center gap-2">
100+
<AmountRowItem
101+
amount={row.original.amount}
102+
status={row.original.status}
103+
payoutsEnabled={Boolean(partner?.payoutsEnabledAt)}
104+
minPayoutAmount={row.original.program.minPayoutAmount}
105+
/>
106+
107+
{["completed", "processing"].includes(row.original.status) && (
108+
<Tooltip content="View invoice">
109+
<div className="flex h-5 w-5 items-center justify-center rounded-md transition-colors duration-150 hover:border hover:border-neutral-200 hover:bg-neutral-100">
110+
<Link
111+
href={`/invoices/${row.original.id}`}
112+
className="text-neutral-700"
113+
target="_blank"
114+
rel="noopener noreferrer"
115+
onClick={(e) => e.stopPropagation()}
116+
>
117+
<InvoiceDollar className="size-4" />
118+
</Link>
119+
</div>
120+
</Tooltip>
121+
)}
122+
</div>
103123
),
104124
},
105125
],
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { DubApiError } from "@/lib/api/errors";
2+
import { withPartnerProfile } from "@/lib/auth/partner";
3+
import { prisma } from "@dub/prisma";
4+
import {
5+
currencyFormatter,
6+
DUB_WORDMARK,
7+
EU_COUNTRY_CODES,
8+
formatDate,
9+
} from "@dub/utils";
10+
import {
11+
Document,
12+
Image,
13+
Link,
14+
Page,
15+
renderToBuffer,
16+
Text,
17+
View,
18+
} from "@react-pdf/renderer";
19+
import { createTw } from "react-pdf-tailwind";
20+
21+
export const dynamic = "force-dynamic";
22+
23+
const tw = createTw({
24+
theme: {
25+
fontFamily: {
26+
// sans: ["Times-Bold"],
27+
},
28+
},
29+
});
30+
31+
// GET /partners.dub.co/invoices/[payoutId] - get the invoice for a payout
32+
export const GET = withPartnerProfile(async ({ partner, params }) => {
33+
const { payoutId } = params;
34+
35+
const payout = await prisma.payout.findUniqueOrThrow({
36+
where: {
37+
id: payoutId,
38+
},
39+
select: {
40+
id: true,
41+
status: true,
42+
partnerId: true,
43+
periodStart: true,
44+
periodEnd: true,
45+
description: true,
46+
amount: true,
47+
program: {
48+
select: {
49+
name: true,
50+
logo: true,
51+
supportEmail: true,
52+
},
53+
},
54+
},
55+
});
56+
57+
if (payout.partnerId !== partner.id) {
58+
throw new DubApiError({
59+
code: "unauthorized",
60+
message: "You are not authorized to view this payout.",
61+
});
62+
}
63+
64+
if (!["completed", "processing"].includes(payout.status)) {
65+
throw new DubApiError({
66+
code: "unauthorized",
67+
message:
68+
"This payout is not completed yet, hence no invoice is generated.",
69+
});
70+
}
71+
72+
const invoiceMetadata = [
73+
{
74+
label: "Program",
75+
value: (
76+
<View style={tw("flex-row items-center gap-2")}>
77+
{payout.program.logo && (
78+
<Image
79+
src={payout.program.logo}
80+
style={tw("w-5 h-5 rounded-full")}
81+
/>
82+
)}
83+
<Text>{payout.program.name}</Text>
84+
</View>
85+
),
86+
},
87+
{
88+
label: "Period",
89+
value: (
90+
<Text style={tw("text-neutral-800 w-2/3")}>
91+
{payout.periodStart && payout.periodEnd
92+
? `${formatDate(payout.periodStart, {
93+
month: "short",
94+
year: "numeric",
95+
})} - ${formatDate(payout.periodEnd, { month: "short" })}`
96+
: "-"}
97+
</Text>
98+
),
99+
},
100+
{
101+
label: "Description",
102+
value: (
103+
<Text style={tw("text-neutral-800 w-2/3")}>
104+
{payout.description || "-"}
105+
</Text>
106+
),
107+
},
108+
{
109+
label: "Amount",
110+
value: (
111+
<Text style={tw("text-neutral-800 w-2/3")}>
112+
{currencyFormatter(payout.amount / 100, {
113+
minimumFractionDigits: 2,
114+
maximumFractionDigits: 2,
115+
})}
116+
</Text>
117+
),
118+
},
119+
{
120+
label: "Payout reference number",
121+
value: <Text style={tw("text-neutral-800 w-2/3")}>{payout.id}</Text>,
122+
},
123+
// if partner is in EU, add VAT reverse charge note:
124+
...(partner.country && EU_COUNTRY_CODES.includes(partner.country)
125+
? [
126+
{
127+
label: "VAT",
128+
value: (
129+
<Text style={tw("text-neutral-800 w-2/3")}>
130+
Tax to be paid on reverse charge basis.
131+
</Text>
132+
),
133+
},
134+
]
135+
: []),
136+
];
137+
138+
const supportEmail = payout.program.supportEmail || "support@dub.co";
139+
140+
const pdf = await renderToBuffer(
141+
<Document>
142+
<Page size="A4" style={tw("p-20 bg-white flex flex-col min-h-full")}>
143+
<View style={tw("flex-1")}>
144+
<View style={tw("flex-row justify-between items-center mb-10")}>
145+
<Image src={DUB_WORDMARK} style={tw("w-20 h-10")} />
146+
<View style={tw("text-right w-1/2")}>
147+
<Text style={tw("text-sm font-medium text-neutral-800")}>
148+
Dub Technologies INC
149+
</Text>
150+
<Text style={tw("text-sm text-neutral-500")}>
151+
2261 Market Street STE 5906
152+
</Text>
153+
<Text style={tw("text-sm text-neutral-500")}>
154+
San Francisco, CA 94114
155+
</Text>
156+
</View>
157+
</View>
158+
159+
<Text style={tw("text-lg font-bold text-neutral-900 mb-4 leading-6")}>
160+
Invoice detail
161+
</Text>
162+
<View style={tw("flex-col gap-4 text-sm font-medium mb-10")}>
163+
{invoiceMetadata.map((row) => (
164+
<View style={tw("flex-row")} key={row.label}>
165+
<Text style={tw("text-neutral-500 w-1/3")}>{row.label}</Text>
166+
{row.value}
167+
</View>
168+
))}
169+
</View>
170+
</View>
171+
172+
<Text style={tw("text-sm text-neutral-600 mt-auto")}>
173+
If you have any questions, contact the program at{" "}
174+
<Link href={`mailto:${supportEmail}`} style={tw("text-neutral-900")}>
175+
{supportEmail}
176+
</Link>
177+
</Text>
178+
</Page>
179+
</Document>,
180+
);
181+
182+
return new Response(pdf, {
183+
headers: {
184+
"Content-Type": "application/pdf",
185+
"Content-Disposition": `inline; filename="payout-invoice-${payout.id}.pdf"`,
186+
},
187+
});
188+
});

apps/web/app/api/workspaces/[idOrSlug]/billing/invoices/route.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,7 @@ const payoutInvoices = async (workspaceId: string) => {
6767
return {
6868
...invoice,
6969
description: "Dub Partner payout",
70-
pdfUrl:
71-
invoice.status === "completed"
72-
? `${APP_DOMAIN}/invoices/${invoice.id}`
73-
: null,
70+
pdfUrl: `${APP_DOMAIN}/invoices/${invoice.id}`,
7471
};
7572
});
7673
};

0 commit comments

Comments
 (0)