Skip to content

Commit cbb3570

Browse files
authored
feat(billing): implement charges csv export
1 parent 2bf44f7 commit cbb3570

File tree

28 files changed

+875
-122
lines changed

28 files changed

+875
-122
lines changed

apps/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
"bcryptjs": "^2.4.3",
7575
"commander": "^12.1.0",
7676
"cosmjs-types": "^0.9.0",
77+
"csv-stringify": "^6.6.0",
7778
"dataloader": "^2.2.2",
7879
"date-fns": "^2.29.2",
7980
"date-fns-tz": "^1.3.6",

apps/api/src/billing/controllers/stripe/stripe.controller.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,4 +150,13 @@ export class StripeController {
150150
const response = await this.stripe.getCustomerTransactions(currentUser.stripeCustomerId, options);
151151
return { data: response };
152152
}
153+
154+
@Protected([{ action: "read", subject: "StripePayment" }])
155+
async exportTransactionsCsvStream(options: { startDate: string; endDate: string; timezone: string }): Promise<AsyncIterable<string>> {
156+
const { currentUser } = this.authService;
157+
158+
assert(currentUser.stripeCustomerId, 403, "Payments are not configured. Please start with a trial first");
159+
160+
return this.stripe.exportTransactionsCsvStream(currentUser.stripeCustomerId, options);
161+
}
153162
}

apps/api/src/billing/http-schemas/stripe.schema.spec.ts

Lines changed: 65 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,39 +8,77 @@ if (!ZodType.prototype.openapi) {
88

99
import { secondsInDay } from "date-fns/constants";
1010

11-
import { CustomerTransactionsQuerySchema } from "./stripe.schema";
11+
import { CustomerTransactionsCsvExportQuerySchema, CustomerTransactionsQuerySchema } from "./stripe.schema";
1212

13-
describe("CustomerTransactionsQuerySchema", () => {
14-
it("accepts both startDate and endDate when range ≤ 366 days", () => {
15-
const startDate = new Date();
16-
const endDate = new Date(startDate.getTime() + secondsInDay * 1000 * 366);
17-
const out = CustomerTransactionsQuerySchema.parse({
18-
startDate: startDate.toISOString(),
19-
endDate: endDate.toISOString()
13+
describe("Stripe Schema", () => {
14+
describe("CustomerTransactionsQuerySchema", () => {
15+
it("accepts no dates", () => {
16+
const out = CustomerTransactionsQuerySchema.parse({});
17+
expect(out).toEqual({});
2018
});
21-
expect(out).toEqual({
22-
startDate: startDate.toISOString(),
23-
endDate: endDate.toISOString()
19+
20+
it("accepts only startDate", () => {
21+
const startDate = new Date();
22+
const out = CustomerTransactionsQuerySchema.parse({ startDate: startDate.toISOString() });
23+
expect(out).toEqual({ startDate: startDate.toISOString() });
2424
});
25-
});
2625

27-
it("rejects when startDate > endDate", () => {
28-
expect(() =>
29-
CustomerTransactionsQuerySchema.parse({
30-
startDate: new Date("2025-01-02T00:00:00Z").toISOString(),
31-
endDate: new Date("2025-01-01T00:00:00Z").toISOString()
32-
})
33-
).toThrow("Date range cannot exceed 366 days and startDate must be before endDate");
34-
});
26+
it("accepts only endDate", () => {
27+
const endDate = new Date();
28+
const out = CustomerTransactionsQuerySchema.parse({ endDate: endDate.toISOString() });
29+
expect(out).toEqual({ endDate: endDate.toISOString() });
30+
});
3531

36-
it("rejects when range > 366 days", () => {
37-
const startDate = new Date();
38-
const endDate = new Date(startDate.getTime() + secondsInDay * 1000 * 367);
39-
expect(() =>
40-
CustomerTransactionsQuerySchema.parse({
32+
it("accepts both startDate and endDate when range ≤ 366 days", () => {
33+
const startDate = new Date();
34+
const endDate = new Date(startDate.getTime() + secondsInDay * 1000 * 366);
35+
const out = CustomerTransactionsQuerySchema.parse({
4136
startDate: startDate.toISOString(),
4237
endDate: endDate.toISOString()
43-
})
44-
).toThrow("Date range cannot exceed 366 days and startDate must be before endDate");
38+
});
39+
expect(out).toEqual({
40+
startDate: startDate.toISOString(),
41+
endDate: endDate.toISOString()
42+
});
43+
});
44+
45+
it("rejects when startDate > endDate", () => {
46+
expect(() =>
47+
CustomerTransactionsQuerySchema.parse({
48+
startDate: new Date("2025-01-02T00:00:00Z").toISOString(),
49+
endDate: new Date("2025-01-01T00:00:00Z").toISOString()
50+
})
51+
).toThrow("Date range cannot exceed 366 days and startDate must be before endDate");
52+
});
53+
54+
it("rejects when range > 366 days", () => {
55+
const startDate = new Date();
56+
const endDate = new Date(startDate.getTime() + secondsInDay * 1000 * 367);
57+
expect(() =>
58+
CustomerTransactionsQuerySchema.parse({
59+
startDate: startDate.toISOString(),
60+
endDate: endDate.toISOString()
61+
})
62+
).toThrow("Date range cannot exceed 366 days and startDate must be before endDate");
63+
});
64+
});
65+
66+
describe("CustomerTransactionsCsvExportQuerySchema", () => {
67+
it("accepts valid timezone", () => {
68+
const out = CustomerTransactionsCsvExportQuerySchema.parse({
69+
timezone: "America/New_York",
70+
startDate: "2023-01-01T00:00:00Z",
71+
endDate: "2023-01-02T00:00:00Z"
72+
});
73+
expect(out).toEqual({ timezone: "America/New_York", startDate: "2023-01-01T00:00:00Z", endDate: "2023-01-02T00:00:00Z" });
74+
});
75+
76+
it("rejects missing timezone", () => {
77+
expect(() => CustomerTransactionsCsvExportQuerySchema.parse({})).toThrow("Required");
78+
});
79+
80+
it("rejects invalid timezone", () => {
81+
expect(() => CustomerTransactionsCsvExportQuerySchema.parse({ timezone: "Invalid/Timezone" })).toThrow("Invalid IANA timezone");
82+
});
4583
});
4684
});

apps/api/src/billing/http-schemas/stripe.schema.ts

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,30 @@ export const CustomerTransactionsResponseSchema = z.object({
128128
})
129129
});
130130

131+
const dateRangeSchema = {
132+
startDate: z.string().datetime().openapi({
133+
description: "Start date for filtering transactions (inclusive)",
134+
example: "2025-01-01T00:00:00Z"
135+
}),
136+
endDate: z.string().datetime().openapi({
137+
description: "End date for filtering transactions (inclusive)",
138+
example: "2025-01-02T00:00:00Z"
139+
})
140+
};
141+
142+
const dateRangeCheck = (data: { startDate?: string; endDate?: string }) => {
143+
if (!data.startDate || !data.endDate) {
144+
return true;
145+
}
146+
147+
const start = new Date(data.startDate);
148+
const end = new Date(data.endDate);
149+
150+
return start <= end && differenceInDays(end, start) <= 366;
151+
};
152+
153+
const dateRangeErrorMessage = "Date range cannot exceed 366 days and startDate must be before endDate";
154+
131155
export const CustomerTransactionsQuerySchema = z
132156
.object({
133157
limit: z.coerce.number().optional().openapi({
@@ -146,30 +170,27 @@ export const CustomerTransactionsQuerySchema = z
146170
description: "ID of the first transaction from the previous page (if paginating backwards)",
147171
example: "ch_0987654321"
148172
}),
149-
startDate: z.string().datetime().optional().openapi({
150-
description: "Start date for filtering transactions (inclusive)",
151-
example: "2025-01-01T00:00:00Z"
152-
}),
153-
endDate: z.string().datetime().optional().openapi({
154-
description: "End date for filtering transactions (inclusive)",
155-
example: "2025-01-02T00:00:00Z"
156-
})
173+
startDate: dateRangeSchema.startDate.optional(),
174+
endDate: dateRangeSchema.endDate.optional()
175+
})
176+
.refine(dateRangeCheck, {
177+
message: dateRangeErrorMessage
178+
});
179+
180+
export const CustomerTransactionsCsvExportQuerySchema = z
181+
.object({
182+
timezone: z
183+
.string()
184+
.refine(tz => Intl.supportedValuesOf("timeZone").includes(tz), { message: "Invalid IANA timezone" })
185+
.openapi({
186+
description: "Timezone for date formatting in the CSV",
187+
example: "America/New_York"
188+
}),
189+
...dateRangeSchema
157190
})
158-
.refine(
159-
data => {
160-
if (!data.startDate || !data.endDate) {
161-
return true;
162-
}
163-
164-
const start = new Date(data.startDate);
165-
const end = new Date(data.endDate);
166-
167-
return start <= end && differenceInDays(end, start) <= 366;
168-
},
169-
{
170-
message: "Date range cannot exceed 366 days and startDate must be before endDate"
171-
}
172-
);
191+
.refine(dateRangeCheck, {
192+
message: dateRangeErrorMessage
193+
});
173194

174195
export const ErrorResponseSchema = z.object({
175196
message: z.string(),

apps/api/src/billing/routes/stripe-transactions/stripe-transactions.router.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import { createRoute } from "@hono/zod-openapi";
2+
import { Readable } from "stream";
23
import { container } from "tsyringe";
34

45
import { StripeController } from "@src/billing/controllers/stripe/stripe.controller";
5-
import { ConfirmPaymentRequestSchema, CustomerTransactionsQuerySchema, CustomerTransactionsResponseSchema } from "@src/billing/http-schemas/stripe.schema";
6+
import {
7+
ConfirmPaymentRequestSchema,
8+
CustomerTransactionsCsvExportQuerySchema,
9+
CustomerTransactionsQuerySchema,
10+
CustomerTransactionsResponseSchema
11+
} from "@src/billing/http-schemas/stripe.schema";
612
import { OpenApiHonoHandler } from "@src/core/services/open-api-hono-handler/open-api-hono-handler";
713

814
const confirmPaymentRoute = createRoute({
@@ -46,6 +52,29 @@ const getCustomerTransactionsRoute = createRoute({
4652
}
4753
});
4854

55+
const exportTransactionsCsvRoute = createRoute({
56+
method: "get",
57+
path: "/v1/stripe/transactions/export",
58+
summary: "Export transaction history as CSV for the current customer",
59+
tags: ["Payment"],
60+
request: {
61+
query: CustomerTransactionsCsvExportQuerySchema
62+
},
63+
responses: {
64+
200: {
65+
description: "CSV file with transaction data",
66+
content: {
67+
"text/csv": {
68+
schema: {
69+
type: "string",
70+
format: "binary"
71+
}
72+
}
73+
}
74+
}
75+
}
76+
});
77+
4978
export const stripeTransactionsRouter = new OpenApiHonoHandler();
5079

5180
stripeTransactionsRouter.openapi(confirmPaymentRoute, async function confirmPayment(c) {
@@ -70,3 +99,25 @@ stripeTransactionsRouter.openapi(getCustomerTransactionsRoute, async function ge
7099
});
71100
return c.json(response, 200);
72101
});
102+
103+
stripeTransactionsRouter.openapi(exportTransactionsCsvRoute, async function exportTransactionsCsv(c) {
104+
const { startDate, endDate, timezone } = c.req.valid("query");
105+
106+
const filename = `transactions_${startDate.split("T")[0]}_to_${endDate.split("T")[0]}.csv`;
107+
108+
const csvStream = await container.resolve(StripeController).exportTransactionsCsvStream({
109+
startDate,
110+
endDate,
111+
timezone
112+
});
113+
114+
const readableStream = Readable.toWeb(Readable.from(csvStream)) as ReadableStream;
115+
116+
return new Response(readableStream, {
117+
headers: {
118+
"Content-Type": "text/csv",
119+
"Content-Disposition": `attachment; filename="${filename}"`,
120+
"Transfer-Encoding": "chunked"
121+
}
122+
});
123+
});

0 commit comments

Comments
 (0)