Skip to content

Commit 4b40abb

Browse files
authored
Fix revenue projections w/ discounts & refunds (#3753)
1 parent 64a8a88 commit 4b40abb

File tree

6 files changed

+113
-45
lines changed

6 files changed

+113
-45
lines changed

valhalla/jawn/src/managers/admin/AdminManager.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,11 @@ export class AdminManager extends BaseManager {
290290
gte: chunk.start,
291291
lt: chunk.end,
292292
},
293-
expand: ["data.subscription"],
293+
expand: [
294+
"data.subscription",
295+
"data.discounts",
296+
"data.charge.refunds",
297+
],
294298
limit: 100,
295299
}),
296300
lastId ? { starting_after: lastId } : {}

web/components/admin/InvoiceTable.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,17 @@ export const InvoiceTable: React.FC<InvoiceTableProps> = ({
8383
</th>
8484
<th
8585
className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
86-
onClick={() => onSort("amountAfterDiscount")} // We know this column sorts by amount
86+
onClick={() => onSort("amountAfterProcessing")} // We know this column sorts by amount
8787
>
8888
Amount
89-
{renderSortIcon("amountAfterDiscount")}
89+
{renderSortIcon("amountAfterProcessing")}
90+
</th>
91+
<th
92+
className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
93+
onClick={() => onSort("refundAmount")}
94+
>
95+
Refunded
96+
{renderSortIcon("refundAmount")}
9097
</th>
9198
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
9299
Customer
@@ -138,7 +145,16 @@ export const InvoiceTable: React.FC<InvoiceTableProps> = ({
138145
)}
139146
</td>
140147
<td className="px-3 py-2 whitespace-nowrap text-sm">
141-
{formatCurrency(invoice.amountAfterDiscount)}
148+
{formatCurrency(invoice.amountAfterProcessing)}
149+
</td>
150+
<td className="px-3 py-2 whitespace-nowrap text-sm">
151+
{invoice.refundAmount > 0 ? (
152+
<span className="text-destructive font-medium">
153+
{formatCurrency(invoice.refundAmount)}
154+
</span>
155+
) : (
156+
<span className="text-muted-foreground">-</span>
157+
)}
142158
</td>
143159
<td className="px-3 py-2 whitespace-nowrap text-sm">
144160
{invoice.customerEmail}

web/components/admin/RevenueChart.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,8 @@ function transformInvoiceData(
194194

195195
// Only include if in our date range
196196
if (monthBuckets[monthKey]) {
197-
monthBuckets[monthKey].billed += invoice.amountAfterDiscount;
198-
monthBuckets[monthKey].total += invoice.amountAfterDiscount;
197+
monthBuckets[monthKey].billed += invoice.amountAfterProcessing;
198+
monthBuckets[monthKey].total += invoice.amountAfterProcessing;
199199
}
200200
});
201201

@@ -205,8 +205,8 @@ function transformInvoiceData(
205205
).padStart(2, "0")}`;
206206
upcomingInvoices.forEach((invoice) => {
207207
if (monthBuckets[currentMonthKey]) {
208-
monthBuckets[currentMonthKey].upcoming += invoice.amountAfterDiscount;
209-
monthBuckets[currentMonthKey].total += invoice.amountAfterDiscount;
208+
monthBuckets[currentMonthKey].upcoming += invoice.amountAfterProcessing;
209+
monthBuckets[currentMonthKey].total += invoice.amountAfterProcessing;
210210
}
211211
});
212212

web/components/templates/admin/adminProjections.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,7 @@ const AdminProjections = () => {
429429
{revenueData.billedInvoices.length > 0
430430
? `$${revenueData.billedInvoices
431431
.reduce(
432-
(sum, inv) => sum + inv.amountAfterDiscount,
432+
(sum, inv) => sum + inv.amountAfterProcessing,
433433
0
434434
)
435435
.toFixed(2)} total`
@@ -473,7 +473,7 @@ const AdminProjections = () => {
473473
{revenueData.upcomingInvoices.length > 0
474474
? `$${revenueData.upcomingInvoices
475475
.reduce(
476-
(sum, inv) => sum + inv.amountAfterDiscount,
476+
(sum, inv) => sum + inv.amountAfterProcessing,
477477
0
478478
)
479479
.toFixed(2)} projected`

web/lib/admin/RevenueCalculator.ts

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ export interface InvoiceData {
1515
id: string;
1616
subscriptionId?: string;
1717
amount: number;
18-
amountAfterDiscount: number;
18+
amountAfterProcessing: number;
19+
refundAmount: number;
1920
customerEmail: string;
2021
status: string;
2122
created: Date;
@@ -149,11 +150,11 @@ export class RevenueCalculator {
149150

150151
// Calculate monthly totals
151152
const current = monthInvoices.reduce(
152-
(sum, inv) => sum + inv.amountAfterDiscount,
153+
(sum, inv) => sum + inv.amountAfterProcessing,
153154
0
154155
);
155156
const projected = relevantUpcomingInvoices.reduce(
156-
(sum, inv) => sum + inv.amountAfterDiscount,
157+
(sum, inv) => sum + inv.amountAfterProcessing,
157158
0
158159
);
159160

@@ -240,31 +241,32 @@ export class RevenueCalculator {
240241
return invoices
241242
.map((inv) => {
242243
const isRegularInvoice = "id" in inv;
243-
const { amount, amountAfterDiscount } = calculateInvoiceAmounts(
244-
inv,
245-
this.discounts,
246-
typeof productId === "string" ? productId : undefined // Only pass single productId
247-
);
248-
249-
if (amountAfterDiscount <= 0) return null;
250-
251-
// Extract subscription ID from the invoice
252-
const subscriptionId =
253-
typeof inv.subscription === "string"
254-
? inv.subscription
255-
: (inv.subscription as any)?.id;
256-
257-
return {
258-
id: isRegularInvoice ? inv.id : crypto.randomUUID(),
259-
subscriptionId,
244+
const { amount, amountAfterProcessing, refundAmount } =
245+
calculateInvoiceAmounts(
246+
inv,
247+
this.discounts,
248+
typeof productId === "string" ? productId : undefined // Only pass single productId
249+
);
250+
251+
if (amountAfterProcessing <= 0) return null;
252+
253+
const invoiceData: InvoiceData = {
254+
id: isRegularInvoice ? (inv as Stripe.Invoice).id : "upcoming",
255+
subscriptionId:
256+
typeof inv.subscription === "string"
257+
? inv.subscription
258+
: (inv.subscription as any)?.id,
260259
amount,
261-
amountAfterDiscount,
260+
amountAfterProcessing,
261+
refundAmount,
262262
customerEmail: inv.customer_email || "unknown",
263263
status: inv.status || "unknown",
264264
created: new Date(inv.created * 1000),
265265
rawJSON: inv,
266266
};
267+
268+
return invoiceData;
267269
})
268-
.filter(Boolean) as InvoiceData[];
270+
.filter((inv) => inv !== null) as InvoiceData[];
269271
}
270272
}

web/lib/admin/calculatorUtil.ts

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ export function calculateInvoiceAmounts(
5353
productId?: string
5454
): {
5555
amount: number;
56-
amountAfterDiscount: number;
56+
amountAfterProcessing: number;
57+
refundAmount: number;
5758
} {
5859
// Calculate the original amount (product-specific if productId is provided)
5960
let amount = 0;
@@ -80,39 +81,84 @@ export function calculateInvoiceAmounts(
8081
// Calculate the discount based on this specific amount
8182
let discountAmount = 0;
8283

84+
// Handle both single discount and multiple discounts
85+
const discountsToApply: Stripe.Discount[] = [];
86+
8387
if (invoice.discount) {
84-
const discountObj = invoice.discount;
88+
// Single discount case
89+
discountsToApply.push(invoice.discount);
90+
}
91+
92+
if (invoice.discounts && invoice.discounts.length > 1) {
93+
// Multiple discounts case - can be array of IDs or objects
94+
if (Array.isArray(invoice.discounts)) {
95+
invoice.discounts.forEach((discount) => {
96+
// Skip deleted discounts
97+
if (typeof discount === "object" && "deleted" in discount) {
98+
return; // Skip this iteration
99+
}
100+
101+
// If it's a string (discount ID), look it up in the discounts parameter
102+
if (typeof discount === "string" && discounts && discounts[discount]) {
103+
discountsToApply.push(discounts[discount]);
104+
}
105+
// If it's a Discount object, use it directly
106+
else if (typeof discount === "object" && "id" in discount) {
107+
discountsToApply.push(discount as Stripe.Discount);
108+
}
109+
});
110+
}
111+
}
112+
113+
// Apply each discount - loop over our collected valid discount objects
114+
discountsToApply.forEach((discountObj) => {
85115
const { coupon } = discountObj;
86116

87117
if (coupon) {
88-
// Check if discount applies to all products or specific ones
89-
const hasNoProductRestrictions =
90-
!coupon.applies_to ||
91-
!coupon.applies_to.products ||
92-
coupon.applies_to.products.length === 0;
118+
// Check if discount applies to specific products
119+
const hasProductRestrictions =
120+
coupon.applies_to &&
121+
coupon.applies_to.products &&
122+
coupon.applies_to.products.length > 0;
93123

94124
// If productId is specified, check if discount applies to it
95125
const discountAppliesToProduct =
96-
hasNoProductRestrictions ||
126+
!hasProductRestrictions ||
97127
(productId && coupon.applies_to?.products?.includes(productId));
98128

99129
if (discountAppliesToProduct) {
100130
if (coupon.amount_off) {
101131
// For amount-based discounts, only apply if product-specific
102-
if (!hasNoProductRestrictions || amount < 1000) {
103-
discountAmount = coupon.amount_off / 100;
132+
if (
133+
hasProductRestrictions ||
134+
amount < 1000 ||
135+
coupon.amount_off > 1000
136+
) {
137+
discountAmount += coupon.amount_off / 100;
104138
}
105139
} else if (coupon.percent_off) {
106140
// Always apply percentage discounts, even at subscription level
107-
discountAmount = amount * (coupon.percent_off / 100);
141+
discountAmount += amount * (coupon.percent_off / 100);
108142
}
109143
}
110144
}
145+
});
146+
147+
// Calculate refund amount if available
148+
let refundAmount = 0;
149+
150+
// Check if this is a regular invoice with an expanded charge
151+
if ("charge" in invoice && invoice.charge) {
152+
// If charge is expanded to an object with refunds
153+
if (typeof invoice.charge !== "string" && invoice.charge.refunds) {
154+
refundAmount = (invoice.charge.amount_refunded || 0) / 100;
155+
}
111156
}
112157

113-
// Return both values
158+
// Return values - calculate final amount after both discounts and refunds
114159
return {
115160
amount,
116-
amountAfterDiscount: Math.max(0, amount - discountAmount),
161+
amountAfterProcessing: Math.max(0, amount - discountAmount - refundAmount),
162+
refundAmount,
117163
};
118164
}

0 commit comments

Comments
 (0)