From b4a7c2318aa93afe03cf70ab5d2fa48ca0ed566d Mon Sep 17 00:00:00 2001 From: Sourav Kashyap Date: Mon, 4 May 2026 14:49:05 +0530 Subject: [PATCH] feat(chore): support retrieval of invoice PDF download URLs support retrieval of invoice PDF download URLs GH-27 --- .../unit/invoice-pdf.service.unit.ts | 159 ++++++++++++++++++ .../sdk/chargebee/charge-bee.service.ts | 53 ++++++ src/providers/sdk/stripe/stripe.service.ts | 47 ++++++ src/types.ts | 23 +++ 4 files changed, 282 insertions(+) create mode 100644 src/__tests__/unit/invoice-pdf.service.unit.ts diff --git a/src/__tests__/unit/invoice-pdf.service.unit.ts b/src/__tests__/unit/invoice-pdf.service.unit.ts new file mode 100644 index 0000000..cbd140c --- /dev/null +++ b/src/__tests__/unit/invoice-pdf.service.unit.ts @@ -0,0 +1,159 @@ +import {expect, sinon} from '@loopback/testlab'; +import chargebee from 'chargebee'; +import {ChargeBeeService} from '../../providers/sdk/chargebee/charge-bee.service'; +import {StripeService} from '../../providers/sdk/stripe/stripe.service'; +import {TInvoicePdf} from '../../types'; + +// ------------------------------------------------------------------------- +// ChargeBee Tests +// ------------------------------------------------------------------------- + +describe('ChargeBeeService - Invoice PDF Download', () => { + let service: ChargeBeeService; + let sandbox: sinon.SinonSandbox; + + /** + * Helper function to stub ChargeBee API calls. + * ChargeBee SDK uses a builder pattern: chargebee.resource.action(params).request() + * So each stub must return an object with a `.request` stub. + */ + function stubCb(returnValue: object) { + // NOSONAR + return { + request: sinon.stub().resolves(returnValue), + setIdempotencyKey: sinon.stub().returnsThis(), + headers: sinon.stub().returnsThis(), + }; + } + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + // Stub the global chargebee.configure to prevent side effects + sandbox.stub(chargebee, 'configure'); + + // Initialize service with test configuration + service = new ChargeBeeService({ + site: 'test-site', + apiKey: 'test-key', + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('getInvoicePdf - Happy Path', () => { + it('returns PDF URL for a valid invoice', async () => { + // Stub the chargebee.invoice.pdf() call + const pdfStub = sandbox.stub(chargebee.invoice, 'pdf').returns( + stubCb({ + download: { + download_url: 'https://test.chargebee.com/invoice/inv_123/pdf', + expires_at: 1735689600, // 2024-12-31 + }, + }), + ); + + // Call the method + const result: TInvoicePdf = await service.getInvoicePdf('inv_123'); + + // Verify the result + expect(result.invoiceId).to.equal('inv_123'); + expect(result.pdfUrl).to.equal( + 'https://test.chargebee.com/invoice/inv_123/pdf', + ); + expect(result.expiresAt).to.equal(1735689600); + expect(result.generatedAt).to.be.type('number'); + expect(result.generatedAt).to.be.greaterThan(0); + + // Verify the API was called correctly + sinon.assert.calledOnce(pdfStub); + sinon.assert.calledWith(pdfStub, 'inv_123'); + }); + + it('returns PDF URL with current timestamp', async () => { + sandbox.stub(chargebee.invoice, 'pdf').returns( + stubCb({ + download: { + download_url: 'https://test.chargebee.com/invoice/inv_456/pdf', + expires_at: 1735689600, + }, + }), + ); + + const before = Math.floor(Date.now() / 1000); + const result = await service.getInvoicePdf('inv_456'); + const after = Math.floor(Date.now() / 1000); + + expect(result.generatedAt).to.be.greaterThanOrEqual(before); + expect(result.generatedAt).to.be.lessThanOrEqual(after); + }); + }); + + describe('getInvoicePdf - Error Cases', () => { + it('throws error when PDF URL is not available', async () => { + // Stub to return empty download object + sandbox.stub(chargebee.invoice, 'pdf').returns( + stubCb({ + download: {}, + }), + ); + + await expect(service.getInvoicePdf('inv_123')).to.be.rejectedWith( + 'PDF URL not available for invoice inv_123. The invoice may be in an invalid state.', + ); + }); + + it('throws error when download object is missing', async () => { + // Stub to return result without download + sandbox.stub(chargebee.invoice, 'pdf').returns(stubCb({})); + + await expect(service.getInvoicePdf('inv_123')).to.be.rejectedWith( + 'PDF URL not available for invoice inv_123. The invoice may be in an invalid state.', + ); + }); + }); +}); + +// ------------------------------------------------------------------------- +// Stripe Tests +// ------------------------------------------------------------------------- + +describe('StripeService - Invoice PDF Download', () => { + let service: StripeService; + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + // Initialize service with test configuration + service = new StripeService({secretKey: 'sk_test_123'}); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('getInvoicePdf - Error Cases', () => { + it('throws error for non-existent invoice', async () => { + // Mock Stripe error + sandbox + .stub(service['stripe'].invoices, 'retrieve') + .rejects({code: 'resource_missing'}); + + await expect(service.getInvoicePdf('in_nonexistent')).to.be.rejectedWith( + 'Invoice not found: in_nonexistent', + ); + }); + + it('throws error for other Stripe errors', async () => { + // Mock generic Stripe error + sandbox + .stub(service['stripe'].invoices, 'retrieve') + .rejects({code: 'api_error', message: 'Something went wrong'}); + + await expect(service.getInvoicePdf('in_error')).to.be.rejected(); + }); + }); +}); diff --git a/src/providers/sdk/chargebee/charge-bee.service.ts b/src/providers/sdk/chargebee/charge-bee.service.ts index 3d68ceb..78d4f65 100644 --- a/src/providers/sdk/chargebee/charge-bee.service.ts +++ b/src/providers/sdk/chargebee/charge-bee.service.ts @@ -4,6 +4,7 @@ import {inject} from '@loopback/core'; import chargebee from 'chargebee'; import { RecurringInterval, + TInvoicePdf, TInvoicePrice, TPrice, TProduct, @@ -614,4 +615,56 @@ export class ChargeBeeService implements IChargeBeeService { throw new Error(JSON.stringify(error)); } } + + /** + * Retrieves the PDF download URL for a ChargeBee invoice. + * + * ChargeBee uses the `invoice.pdf()` API to generate a temporary download URL + * for the invoice PDF. The URL is typically valid for a limited time. + * + * @param invoiceId - The ChargeBee invoice ID + * @returns Object containing the PDF URL, expiry time, and generation timestamp + * @throws Error if the invoice doesn't exist or PDF cannot be generated + */ + async getInvoicePdf(invoiceId: string): Promise { + try { + // Call ChargeBee's invoice.pdf() to generate the PDF URL + const result = await chargebee.invoice.pdf(invoiceId).request(); + + // Check if download URL is available + // Type assertion to handle ChargeBee SDK type limitations + const download = result.download as { + download_url?: string; + expires_at?: number; + }; + if (!download?.download_url) { + throw new Error( + `PDF URL not available for invoice ${invoiceId}. ` + + `The invoice may be in an invalid state.`, + ); + } + + // Return the PDF information + return { + invoiceId: invoiceId, + pdfUrl: download.download_url, + generatedAt: Math.floor(Date.now() / 1000), // Current timestamp in seconds + // ChargeBee provides expiry time for the download URL + expiresAt: download.expires_at, + }; + } catch (error) { + // Re-throw with better error message + const cbError = error as {api_error_code?: string; http_status?: number}; + const HTTP_NOT_FOUND = 404; + + if ( + cbError.api_error_code === 'resource_not_found' || + cbError.http_status === HTTP_NOT_FOUND + ) { + throw new Error(`Invoice not found: ${invoiceId}`); + } + + throw error; + } + } } diff --git a/src/providers/sdk/stripe/stripe.service.ts b/src/providers/sdk/stripe/stripe.service.ts index ecd2610..ec06191 100644 --- a/src/providers/sdk/stripe/stripe.service.ts +++ b/src/providers/sdk/stripe/stripe.service.ts @@ -1,10 +1,12 @@ /* eslint-disable @typescript-eslint/naming-convention */ + import {inject} from '@loopback/core'; import Stripe from 'stripe'; import { CollectionMethod, RecurringInterval, TInvoice, + TInvoicePdf, TInvoicePrice, TPrice, TProduct, @@ -534,4 +536,49 @@ export class StripeService implements IStripeService { throw error; } } + + /** + * Retrieves the PDF download URL for a Stripe invoice. + * + * Stripe invoices have an `invoice_pdf` field that contains a temporary URL + * to download the PDF. This URL is typically valid for a limited time. + * + * Note: PDF URLs are only available for finalized invoices. Draft invoices + * will not have this field. + * + * @param invoiceId - The Stripe invoice ID + * @returns Object containing the PDF URL and generation timestamp + * @throws Error if the invoice doesn't exist or PDF URL is not available + */ + async getInvoicePdf(invoiceId: string): Promise { + try { + // Retrieve the invoice from Stripe + const invoice = await this.stripe.invoices.retrieve(invoiceId); + + // Check if PDF URL is available + if (!invoice.invoice_pdf) { + throw new Error( + `PDF URL not available for invoice ${invoiceId}. ` + + `The invoice may be in draft status or not finalized. ` + + `Only finalized invoices have PDF URLs.`, + ); + } + + // Return the PDF information + return { + invoiceId: invoice.id, + pdfUrl: invoice.invoice_pdf, + generatedAt: Math.floor(Date.now() / 1000), // Current timestamp in seconds + // Stripe PDF URLs have expiry but it's not exposed in the API response + // The URL is typically valid for a limited time (check Stripe docs) + }; + } catch (error) { + // Re-throw with better error message + const stripeError = error as {code?: string; message?: string}; + if (stripeError.code === 'resource_missing') { + throw new Error(`Invoice not found: ${invoiceId}`); + } + throw error; + } + } } diff --git a/src/types.ts b/src/types.ts index 7438b25..50cb47c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,6 +39,7 @@ export interface IService { ): Promise; deleteInvoice(invoiceId: string): Promise; getPaymentStatus(invoiceId: string): Promise; + getInvoicePdf(invoiceId: string): Promise; } export interface IAdapter { /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ @@ -244,6 +245,28 @@ export interface TInvoicePrice { amountExcludingTax: number; } +/** + * Represents a PDF download URL for an invoice. + * + * The PDF URL is typically temporary and expires after a certain period. + * The exact expiry duration depends on the billing provider. + */ +export interface TInvoicePdf { + /** The invoice ID */ + invoiceId: string; + /** The temporary download URL for the PDF */ + pdfUrl: string; + /** + * Timestamp (in seconds) when the URL expires, if provided by the provider. + * Some providers don't return expiry information. + */ + expiresAt?: number; + /** + * Timestamp (in seconds) when the PDF was generated/retrieved. + */ + generatedAt: number; +} + /** * Interface that any billing provider must implement to support the full * recurring-subscription lifecycle.