Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions src/__tests__/unit/invoice-pdf.service.unit.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
53 changes: 53 additions & 0 deletions src/providers/sdk/chargebee/charge-bee.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {inject} from '@loopback/core';
import chargebee from 'chargebee';
import {
RecurringInterval,
TInvoicePdf,
TInvoicePrice,
TPrice,
TProduct,
Expand Down Expand Up @@ -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<TInvoicePdf> {
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;
}
}
}
47 changes: 47 additions & 0 deletions src/providers/sdk/stripe/stripe.service.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<TInvoicePdf> {
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;
}
}
}
23 changes: 23 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface IService {
): Promise<TInvoice>;
deleteInvoice(invoiceId: string): Promise<void>;
getPaymentStatus(invoiceId: string): Promise<boolean>;
getInvoicePdf(invoiceId: string): Promise<TInvoicePdf>;
}
export interface IAdapter<T, R = T> {
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
Expand Down Expand Up @@ -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.
Expand Down
Loading