Skip to content

refactor(billing): extract transaction reporting into its own service - #3502

Closed
ygrishajev wants to merge 1 commit into
mainfrom
refactor/billing-transaction-reporting-service-1
Closed

refactor(billing): extract transaction reporting into its own service#3502
ygrishajev wants to merge 1 commit into
mainfrom
refactor/billing-transaction-reporting-service-1

Conversation

@ygrishajev

@ygrishajev ygrishajev commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Why

StripeService is still a god object — charging cards, coupons, customers, payment methods, and transaction reporting all live in one class. Reporting (listing a customer's charges for the UI, streaming the CSV export) is a self-contained concern with its own dependencies — csv-stringify, a paging generator, timezone/CSV formatting — that has nothing to do with taking a payment.

This PR carves reporting out into a dedicated TransactionReportingService. StripeService sheds ~200 lines and its CSV/stream imports; reporting gets a single-responsibility, independently testable home. It's a pure move — no behavior changes — and the next step in the layered StripeService split.

Closes CON-721 · Part of CON-718. Stacked on #3498 (the StripeService injected-client refactor) — review and merge that first; this PR's diff should be read on top of it.

What

apps/api — behavior-preserving, no new runtime behavior, no migrations.

  • Adds TransactionReportingService (injected STRIPE_CLIENT + StripeTransactionRepository + logger). It owns getCustomerTransactions and exportTransactionsCsvStream, plus their private helpers (paging generator, CSV-row transform, timezone normalization, CSV-injection sanitization) — all moved verbatim from StripeService.
  • StripeController now delegates the two reporting endpoints to TransactionReportingService; every other endpoint still goes through StripeService.
  • StripeService drops the six reporting methods and the now-dead csv-stringify / stream / Transaction / TransactionCsvRow imports.
  • The reporting tests move from stripe.service spec into a new transaction-reporting.service spec, unchanged.

Verification

  • Full billing unit suite green — 357 tests / 27 files (reporting tests relocated, not lost: the 15 moved tests now live in the new spec).
  • tsc: no new errors in the changed files (pre-existing apps/api baseline unchanged).
  • Prettier clean. Lint (ESLint v9 flat config) and integration tests run in CI.

Summary by CodeRabbit

  • New Features

    • Added customer transaction reporting with pagination and enriched payment details.
    • Added CSV transaction exports with date-range and timezone support.
    • Improved exported transaction data with purchase bonuses, payment methods, and formatted amounts.
  • Bug Fixes

    • Added safer handling for missing payment details, unsupported timezones, empty results, and export errors.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Transaction reporting moves from StripeService to a new TransactionReportingService. The controller delegates transaction retrieval and CSV export to it, with coverage for pagination, enrichment, formatting, sanitization, and error handling.

Changes

Transaction reporting extraction

Layer / File(s) Summary
Reporting service implementation
apps/api/src/billing/services/transaction-reporting/..., apps/api/src/billing/services/stripe/stripe.service.ts
Adds Stripe transaction retrieval, bonus enrichment, paginated CSV streaming, sanitization, timezone normalization, and error handling; removes the corresponding StripeService methods.
Controller delegation and dependency wiring
apps/api/src/billing/controllers/stripe/...
Injects TransactionReportingService and delegates transaction listing and CSV export endpoints to it.
Reporting behavior validation
apps/api/src/billing/services/transaction-reporting/transaction-reporting.service.spec.ts, apps/api/src/billing/services/stripe/stripe.service.spec.ts
Adds reporting-service tests and removes obsolete StripeService transaction/export suites.
Estimated code review effort: 4 (Complex) ~45 minutes

Possibly related PRs

Suggested reviewers: baktun14

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/billing-transaction-reporting-service-1

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install timed out. The project may have too many dependencies for the sandbox.


Comment @coderabbitai help to get the list of available commands.

Move getCustomerTransactions and the CSV export stream out of StripeService
into a dedicated TransactionReportingService, and delegate to it from the
controller. Behavior-preserving: the reporting tests move alongside the new
service and StripeService sheds its CSV/reporting dependencies.
@ygrishajev
ygrishajev force-pushed the refactor/billing-transaction-reporting-service-1 branch from 13a9ee3 to d2c9ba0 Compare July 22, 2026 12:45

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/api/src/billing/services/transaction-reporting/transaction-reporting.service.spec.ts`:
- Around line 473-505: Add a test alongside “handles error during streaming
gracefully” that makes the initial getCustomerTransactions call reject, then
consume exportTransactionsCsvStream and assert the CSV contains the expected
headers and generic “Error: unable to fetch transactions” message without
exposing the raw Stripe error. Keep the existing second-page error coverage
unchanged.

In
`@apps/api/src/billing/services/transaction-reporting/transaction-reporting.service.ts`:
- Around line 129-179: Update the transaction-fetch catch path in the reporting
generator so an emitted fetch-error row marks the export as having yielded a
result, preventing the trailing “No transactions found” row from being emitted
after a first-page failure. Also guard the pagination update after
getCustomerTransactions so a hasMore response without a nextPage terminates
iteration instead of refetching the same page indefinitely.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e6895c69-5b6c-42b1-8339-ada8ffbf695a

📥 Commits

Reviewing files that changed from the base of the PR and between a874395 and d2c9ba0.

📒 Files selected for processing (6)
  • apps/api/src/billing/controllers/stripe/stripe.controller.spec.ts
  • apps/api/src/billing/controllers/stripe/stripe.controller.ts
  • apps/api/src/billing/services/stripe/stripe.service.spec.ts
  • apps/api/src/billing/services/stripe/stripe.service.ts
  • apps/api/src/billing/services/transaction-reporting/transaction-reporting.service.spec.ts
  • apps/api/src/billing/services/transaction-reporting/transaction-reporting.service.ts

Comment on lines +473 to +505
it("handles error during streaming gracefully", async () => {
const { service } = setup();
const mockTransaction = createTestTransaction();

vi.spyOn(service, "getCustomerTransactions")
.mockResolvedValueOnce({
transactions: [mockTransaction],
hasMore: true,
nextPage: "ch_123",
prevPage: null
})
.mockRejectedValueOnce(new Error("Stripe API error"));

const csvStream = service.exportTransactionsCsvStream(TEST_CONSTANTS.CUSTOMER_ID, {
startDate: "2022-01-01T00:00:00Z",
endDate: "2022-01-31T23:59:59Z",
timezone: "America/New_York"
});

const chunks: string[] = [];
for await (const chunk of csvStream) {
chunks.push(chunk);
}

const fullCsv = chunks.join("");

expect(fullCsv).toContain(
"Transaction ID,Date (America/New_York),Amount,Bonus,Currency,Status,Payment Method,Card Brand,Card Last 4,Description,Receipt URL"
);
expect(fullCsv).toContain("ch_123");
expect(fullCsv).toContain("Error: unable to fetch transactions");
expect(fullCsv).not.toContain("Stripe API error");
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Solid coverage, and good that the error test asserts the raw Stripe message isn't leaked into the CSV. Note this suite only exercises an error on the second page — the first-page-error edge (flagged in transaction-reporting.service.ts) is untested. Worth adding once that fix lands.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/api/src/billing/services/transaction-reporting/transaction-reporting.service.spec.ts`
around lines 473 - 505, Add a test alongside “handles error during streaming
gracefully” that makes the initial getCustomerTransactions call reject, then
consume exportTransactionsCsvStream and assert the CSV contains the expected
headers and generic “Error: unable to fetch transactions” message without
exposing the raw Stripe error. Keep the existing second-page error coverage
unchanged.

Comment on lines +129 to +179
while (hasMore) {
try {
const batch = await this.getCustomerTransactions(customerId, {
limit: batchSize,
startingAfter,
startDate: options.startDate,
endDate: options.endDate
});

for (const transaction of batch.transactions) {
hasYieldedAny = true;

yield this.transformTransactionForCsv(transaction, options.timezone);
}

hasMore = batch.hasMore;
startingAfter = batch.nextPage || undefined;
} catch (error) {
this.loggerService.error({ event: "TRANSACTION_FETCH_ERROR", error, customerId, startingAfter });
yield {
id: this.sanitizeForCsv("Error: unable to fetch transactions"),
date: "",
amount: "",
bonusAmount: "",
currency: "",
status: "",
paymentMethodType: "",
cardBrand: "",
cardLast4: "",
description: "",
receiptUrl: ""
};
hasMore = false;
}
}

if (!hasYieldedAny) {
yield {
id: "No transactions found for the specified date range",
date: "",
amount: "",
bonusAmount: "",
currency: "",
status: "",
paymentMethodType: "",
cardBrand: "",
cardLast4: "",
description: "",
receiptUrl: ""
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

First-page fetch error emits a contradictory "no transactions" row. The catch block never sets hasYieldedAny = true. If the very first getCustomerTransactions call throws, you yield the "Error: unable to fetch transactions" row, exit the loop, and then the trailing if (!hasYieldedAny) also yields "No transactions found for the specified date range". The exported CSV then reports both an error and "no transactions" simultaneously. The existing test only exercises an error on the second page (page 1 succeeds), so it misses this.

🐛 Proposed fix
       } catch (error) {
         this.loggerService.error({ event: "TRANSACTION_FETCH_ERROR", error, customerId, startingAfter });
+        hasYieldedAny = true;
         yield {
           id: this.sanitizeForCsv("Error: unable to fetch transactions"),

Separately, consider a no-progress guard: if batch.hasMore is ever true while batch.nextPage is null, startingAfter stays undefined and the same page is refetched indefinitely.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
while (hasMore) {
try {
const batch = await this.getCustomerTransactions(customerId, {
limit: batchSize,
startingAfter,
startDate: options.startDate,
endDate: options.endDate
});
for (const transaction of batch.transactions) {
hasYieldedAny = true;
yield this.transformTransactionForCsv(transaction, options.timezone);
}
hasMore = batch.hasMore;
startingAfter = batch.nextPage || undefined;
} catch (error) {
this.loggerService.error({ event: "TRANSACTION_FETCH_ERROR", error, customerId, startingAfter });
yield {
id: this.sanitizeForCsv("Error: unable to fetch transactions"),
date: "",
amount: "",
bonusAmount: "",
currency: "",
status: "",
paymentMethodType: "",
cardBrand: "",
cardLast4: "",
description: "",
receiptUrl: ""
};
hasMore = false;
}
}
if (!hasYieldedAny) {
yield {
id: "No transactions found for the specified date range",
date: "",
amount: "",
bonusAmount: "",
currency: "",
status: "",
paymentMethodType: "",
cardBrand: "",
cardLast4: "",
description: "",
receiptUrl: ""
};
}
while (hasMore) {
try {
const batch = await this.getCustomerTransactions(customerId, {
limit: batchSize,
startingAfter,
startDate: options.startDate,
endDate: options.endDate
});
for (const transaction of batch.transactions) {
hasYieldedAny = true;
yield this.transformTransactionForCsv(transaction, options.timezone);
}
hasMore = batch.hasMore;
startingAfter = batch.nextPage || undefined;
} catch (error) {
this.loggerService.error({ event: "TRANSACTION_FETCH_ERROR", error, customerId, startingAfter });
hasYieldedAny = true;
yield {
id: this.sanitizeForCsv("Error: unable to fetch transactions"),
date: "",
amount: "",
bonusAmount: "",
currency: "",
status: "",
paymentMethodType: "",
cardBrand: "",
cardLast4: "",
description: "",
receiptUrl: ""
};
hasMore = false;
}
}
if (!hasYieldedAny) {
yield {
id: "No transactions found for the specified date range",
date: "",
amount: "",
bonusAmount: "",
currency: "",
status: "",
paymentMethodType: "",
cardBrand: "",
cardLast4: "",
description: "",
receiptUrl: ""
};
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/api/src/billing/services/transaction-reporting/transaction-reporting.service.ts`
around lines 129 - 179, Update the transaction-fetch catch path in the reporting
generator so an emitted fetch-error row marks the export as having yielded a
result, preventing the trailing “No transactions found” row from being emitted
after a first-page failure. Also guard the pagination update after
getCustomerTransactions so a hasMore response without a nextPage terminates
iteration instead of refetching the same page indefinitely.

@ygrishajev ygrishajev closed this Jul 22, 2026
@codecov

codecov Bot commented Jul 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 72.37%. Comparing base (a874395) to head (d2c9ba0).
⚠️ Report is 2 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3502      +/-   ##
==========================================
- Coverage   73.45%   72.37%   -1.09%     
==========================================
  Files        1134     1044      -90     
  Lines       29606    27190    -2416     
  Branches     7416     6933     -483     
==========================================
- Hits        21748    19678    -2070     
+ Misses       6932     6616     -316     
+ Partials      926      896      -30     
Flag Coverage Δ *Carryforward flag
api 86.39% <ø> (-0.04%) ⬇️ Carriedforward from a874395
deploy-web 62.85% <ø> (ø) Carriedforward from a874395
log-collector ?
notifications 93.84% <ø> (ø) Carriedforward from a874395
provider-console 81.38% <ø> (ø) Carriedforward from a874395
provider-inventory ?
provider-proxy 88.17% <ø> (ø) Carriedforward from a874395
tx-signer ?

*This pull request uses carry forward flags. Click here to find out more.

Files with missing lines Coverage Δ
...rc/billing/controllers/stripe/stripe.controller.ts 69.00% <ø> (+1.03%) ⬆️
.../api/src/billing/services/stripe/stripe.service.ts 80.99% <ø> (-2.02%) ⬇️

... and 90 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ygrishajev
ygrishajev deleted the refactor/billing-transaction-reporting-service-1 branch July 24, 2026 14:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants