Skip to content

Commit b4a7c23

Browse files
feat(chore): support retrieval of invoice PDF download URLs
support retrieval of invoice PDF download URLs GH-27
1 parent fa31111 commit b4a7c23

4 files changed

Lines changed: 282 additions & 0 deletions

File tree

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import {expect, sinon} from '@loopback/testlab';
2+
import chargebee from 'chargebee';
3+
import {ChargeBeeService} from '../../providers/sdk/chargebee/charge-bee.service';
4+
import {StripeService} from '../../providers/sdk/stripe/stripe.service';
5+
import {TInvoicePdf} from '../../types';
6+
7+
// -------------------------------------------------------------------------
8+
// ChargeBee Tests
9+
// -------------------------------------------------------------------------
10+
11+
describe('ChargeBeeService - Invoice PDF Download', () => {
12+
let service: ChargeBeeService;
13+
let sandbox: sinon.SinonSandbox;
14+
15+
/**
16+
* Helper function to stub ChargeBee API calls.
17+
* ChargeBee SDK uses a builder pattern: chargebee.resource.action(params).request()
18+
* So each stub must return an object with a `.request` stub.
19+
*/
20+
function stubCb(returnValue: object) {
21+
// NOSONAR
22+
return {
23+
request: sinon.stub().resolves(returnValue),
24+
setIdempotencyKey: sinon.stub().returnsThis(),
25+
headers: sinon.stub().returnsThis(),
26+
};
27+
}
28+
29+
beforeEach(() => {
30+
sandbox = sinon.createSandbox();
31+
32+
// Stub the global chargebee.configure to prevent side effects
33+
sandbox.stub(chargebee, 'configure');
34+
35+
// Initialize service with test configuration
36+
service = new ChargeBeeService({
37+
site: 'test-site',
38+
apiKey: 'test-key',
39+
});
40+
});
41+
42+
afterEach(() => {
43+
sandbox.restore();
44+
});
45+
46+
describe('getInvoicePdf - Happy Path', () => {
47+
it('returns PDF URL for a valid invoice', async () => {
48+
// Stub the chargebee.invoice.pdf() call
49+
const pdfStub = sandbox.stub(chargebee.invoice, 'pdf').returns(
50+
stubCb({
51+
download: {
52+
download_url: 'https://test.chargebee.com/invoice/inv_123/pdf',
53+
expires_at: 1735689600, // 2024-12-31
54+
},
55+
}),
56+
);
57+
58+
// Call the method
59+
const result: TInvoicePdf = await service.getInvoicePdf('inv_123');
60+
61+
// Verify the result
62+
expect(result.invoiceId).to.equal('inv_123');
63+
expect(result.pdfUrl).to.equal(
64+
'https://test.chargebee.com/invoice/inv_123/pdf',
65+
);
66+
expect(result.expiresAt).to.equal(1735689600);
67+
expect(result.generatedAt).to.be.type('number');
68+
expect(result.generatedAt).to.be.greaterThan(0);
69+
70+
// Verify the API was called correctly
71+
sinon.assert.calledOnce(pdfStub);
72+
sinon.assert.calledWith(pdfStub, 'inv_123');
73+
});
74+
75+
it('returns PDF URL with current timestamp', async () => {
76+
sandbox.stub(chargebee.invoice, 'pdf').returns(
77+
stubCb({
78+
download: {
79+
download_url: 'https://test.chargebee.com/invoice/inv_456/pdf',
80+
expires_at: 1735689600,
81+
},
82+
}),
83+
);
84+
85+
const before = Math.floor(Date.now() / 1000);
86+
const result = await service.getInvoicePdf('inv_456');
87+
const after = Math.floor(Date.now() / 1000);
88+
89+
expect(result.generatedAt).to.be.greaterThanOrEqual(before);
90+
expect(result.generatedAt).to.be.lessThanOrEqual(after);
91+
});
92+
});
93+
94+
describe('getInvoicePdf - Error Cases', () => {
95+
it('throws error when PDF URL is not available', async () => {
96+
// Stub to return empty download object
97+
sandbox.stub(chargebee.invoice, 'pdf').returns(
98+
stubCb({
99+
download: {},
100+
}),
101+
);
102+
103+
await expect(service.getInvoicePdf('inv_123')).to.be.rejectedWith(
104+
'PDF URL not available for invoice inv_123. The invoice may be in an invalid state.',
105+
);
106+
});
107+
108+
it('throws error when download object is missing', async () => {
109+
// Stub to return result without download
110+
sandbox.stub(chargebee.invoice, 'pdf').returns(stubCb({}));
111+
112+
await expect(service.getInvoicePdf('inv_123')).to.be.rejectedWith(
113+
'PDF URL not available for invoice inv_123. The invoice may be in an invalid state.',
114+
);
115+
});
116+
});
117+
});
118+
119+
// -------------------------------------------------------------------------
120+
// Stripe Tests
121+
// -------------------------------------------------------------------------
122+
123+
describe('StripeService - Invoice PDF Download', () => {
124+
let service: StripeService;
125+
let sandbox: sinon.SinonSandbox;
126+
127+
beforeEach(() => {
128+
sandbox = sinon.createSandbox();
129+
130+
// Initialize service with test configuration
131+
service = new StripeService({secretKey: 'sk_test_123'});
132+
});
133+
134+
afterEach(() => {
135+
sandbox.restore();
136+
});
137+
138+
describe('getInvoicePdf - Error Cases', () => {
139+
it('throws error for non-existent invoice', async () => {
140+
// Mock Stripe error
141+
sandbox
142+
.stub(service['stripe'].invoices, 'retrieve')
143+
.rejects({code: 'resource_missing'});
144+
145+
await expect(service.getInvoicePdf('in_nonexistent')).to.be.rejectedWith(
146+
'Invoice not found: in_nonexistent',
147+
);
148+
});
149+
150+
it('throws error for other Stripe errors', async () => {
151+
// Mock generic Stripe error
152+
sandbox
153+
.stub(service['stripe'].invoices, 'retrieve')
154+
.rejects({code: 'api_error', message: 'Something went wrong'});
155+
156+
await expect(service.getInvoicePdf('in_error')).to.be.rejected();
157+
});
158+
});
159+
});

src/providers/sdk/chargebee/charge-bee.service.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {inject} from '@loopback/core';
44
import chargebee from 'chargebee';
55
import {
66
RecurringInterval,
7+
TInvoicePdf,
78
TInvoicePrice,
89
TPrice,
910
TProduct,
@@ -614,4 +615,56 @@ export class ChargeBeeService implements IChargeBeeService {
614615
throw new Error(JSON.stringify(error));
615616
}
616617
}
618+
619+
/**
620+
* Retrieves the PDF download URL for a ChargeBee invoice.
621+
*
622+
* ChargeBee uses the `invoice.pdf()` API to generate a temporary download URL
623+
* for the invoice PDF. The URL is typically valid for a limited time.
624+
*
625+
* @param invoiceId - The ChargeBee invoice ID
626+
* @returns Object containing the PDF URL, expiry time, and generation timestamp
627+
* @throws Error if the invoice doesn't exist or PDF cannot be generated
628+
*/
629+
async getInvoicePdf(invoiceId: string): Promise<TInvoicePdf> {
630+
try {
631+
// Call ChargeBee's invoice.pdf() to generate the PDF URL
632+
const result = await chargebee.invoice.pdf(invoiceId).request();
633+
634+
// Check if download URL is available
635+
// Type assertion to handle ChargeBee SDK type limitations
636+
const download = result.download as {
637+
download_url?: string;
638+
expires_at?: number;
639+
};
640+
if (!download?.download_url) {
641+
throw new Error(
642+
`PDF URL not available for invoice ${invoiceId}. ` +
643+
`The invoice may be in an invalid state.`,
644+
);
645+
}
646+
647+
// Return the PDF information
648+
return {
649+
invoiceId: invoiceId,
650+
pdfUrl: download.download_url,
651+
generatedAt: Math.floor(Date.now() / 1000), // Current timestamp in seconds
652+
// ChargeBee provides expiry time for the download URL
653+
expiresAt: download.expires_at,
654+
};
655+
} catch (error) {
656+
// Re-throw with better error message
657+
const cbError = error as {api_error_code?: string; http_status?: number};
658+
const HTTP_NOT_FOUND = 404;
659+
660+
if (
661+
cbError.api_error_code === 'resource_not_found' ||
662+
cbError.http_status === HTTP_NOT_FOUND
663+
) {
664+
throw new Error(`Invoice not found: ${invoiceId}`);
665+
}
666+
667+
throw error;
668+
}
669+
}
617670
}

src/providers/sdk/stripe/stripe.service.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
/* eslint-disable @typescript-eslint/naming-convention */
2+
23
import {inject} from '@loopback/core';
34
import Stripe from 'stripe';
45
import {
56
CollectionMethod,
67
RecurringInterval,
78
TInvoice,
9+
TInvoicePdf,
810
TInvoicePrice,
911
TPrice,
1012
TProduct,
@@ -534,4 +536,49 @@ export class StripeService implements IStripeService {
534536
throw error;
535537
}
536538
}
539+
540+
/**
541+
* Retrieves the PDF download URL for a Stripe invoice.
542+
*
543+
* Stripe invoices have an `invoice_pdf` field that contains a temporary URL
544+
* to download the PDF. This URL is typically valid for a limited time.
545+
*
546+
* Note: PDF URLs are only available for finalized invoices. Draft invoices
547+
* will not have this field.
548+
*
549+
* @param invoiceId - The Stripe invoice ID
550+
* @returns Object containing the PDF URL and generation timestamp
551+
* @throws Error if the invoice doesn't exist or PDF URL is not available
552+
*/
553+
async getInvoicePdf(invoiceId: string): Promise<TInvoicePdf> {
554+
try {
555+
// Retrieve the invoice from Stripe
556+
const invoice = await this.stripe.invoices.retrieve(invoiceId);
557+
558+
// Check if PDF URL is available
559+
if (!invoice.invoice_pdf) {
560+
throw new Error(
561+
`PDF URL not available for invoice ${invoiceId}. ` +
562+
`The invoice may be in draft status or not finalized. ` +
563+
`Only finalized invoices have PDF URLs.`,
564+
);
565+
}
566+
567+
// Return the PDF information
568+
return {
569+
invoiceId: invoice.id,
570+
pdfUrl: invoice.invoice_pdf,
571+
generatedAt: Math.floor(Date.now() / 1000), // Current timestamp in seconds
572+
// Stripe PDF URLs have expiry but it's not exposed in the API response
573+
// The URL is typically valid for a limited time (check Stripe docs)
574+
};
575+
} catch (error) {
576+
// Re-throw with better error message
577+
const stripeError = error as {code?: string; message?: string};
578+
if (stripeError.code === 'resource_missing') {
579+
throw new Error(`Invoice not found: ${invoiceId}`);
580+
}
581+
throw error;
582+
}
583+
}
537584
}

src/types.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export interface IService {
3939
): Promise<TInvoice>;
4040
deleteInvoice(invoiceId: string): Promise<void>;
4141
getPaymentStatus(invoiceId: string): Promise<boolean>;
42+
getInvoicePdf(invoiceId: string): Promise<TInvoicePdf>;
4243
}
4344
export interface IAdapter<T, R = T> {
4445
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
@@ -244,6 +245,28 @@ export interface TInvoicePrice {
244245
amountExcludingTax: number;
245246
}
246247

248+
/**
249+
* Represents a PDF download URL for an invoice.
250+
*
251+
* The PDF URL is typically temporary and expires after a certain period.
252+
* The exact expiry duration depends on the billing provider.
253+
*/
254+
export interface TInvoicePdf {
255+
/** The invoice ID */
256+
invoiceId: string;
257+
/** The temporary download URL for the PDF */
258+
pdfUrl: string;
259+
/**
260+
* Timestamp (in seconds) when the URL expires, if provided by the provider.
261+
* Some providers don't return expiry information.
262+
*/
263+
expiresAt?: number;
264+
/**
265+
* Timestamp (in seconds) when the PDF was generated/retrieved.
266+
*/
267+
generatedAt: number;
268+
}
269+
247270
/**
248271
* Interface that any billing provider must implement to support the full
249272
* recurring-subscription lifecycle.

0 commit comments

Comments
 (0)