diff --git a/package-lock.json b/package-lock.json index 03c8f44..c86a8e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1130,24 +1130,24 @@ "license": "MIT" }, "node_modules/@loopback/build": { - "version": "12.0.11", - "resolved": "https://registry.npmjs.org/@loopback/build/-/build-12.0.11.tgz", - "integrity": "sha512-Qr3j1tF20YQ7W/0Ont8Fv5xAroXdkEwlOOn+tqv8K7D3aTGeSehMkCnrVpOWllbWmiMAdqtVJXlNNqFne8j3+w==", + "version": "12.0.12", + "resolved": "https://registry.npmjs.org/@loopback/build/-/build-12.0.12.tgz", + "integrity": "sha512-ufmzogGEHvKX4Phaab0du8W35vlIktLbiCHjMnqWrOzzzL35N61p4QnIsDV5ImfSEu4XmouZNLwwLuBuqlthWQ==", "dev": true, "license": "MIT", "dependencies": { "@loopback/eslint-config": "^16.0.1", "@types/mocha": "^10.0.10", - "@types/node": "^20.19.39", + "@types/node": "^20.19.40", "cross-spawn": "^7.0.6", "debug": "^4.4.3", "eslint": "^8.57.1", - "fs-extra": "^11.3.4", + "fs-extra": "^11.3.5", "glob": "^13.0.6", "lodash": "^4.18.1", "mocha": "^11.7.5", "nyc": "^18.0.0", - "prettier": "^3.8.2", + "prettier": "^3.8.3", "rimraf": "^5.0.10", "source-map-support": "^0.5.21", "typescript": "~5.2.2" @@ -1166,9 +1166,9 @@ } }, "node_modules/@loopback/build/node_modules/@types/node": { - "version": "20.19.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", - "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5886,9 +5886,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -6309,9 +6309,9 @@ "license": "MIT" }, "node_modules/fs-extra": { - "version": "11.3.4", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", - "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", "dev": true, "license": "MIT", "dependencies": { @@ -14785,9 +14785,9 @@ } }, "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" 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/adapter/index.ts b/src/providers/sdk/chargebee/adapter/index.ts index ed2e797..d9a287c 100644 --- a/src/providers/sdk/chargebee/adapter/index.ts +++ b/src/providers/sdk/chargebee/adapter/index.ts @@ -1,4 +1,5 @@ export * from './customer.adapter'; export * from './invoice.adapter'; +export * from './payment-intent.adapter'; export * from './payment-source.adapter'; export * from './subscription.adapter'; diff --git a/src/providers/sdk/chargebee/adapter/invoice.adapter.ts b/src/providers/sdk/chargebee/adapter/invoice.adapter.ts index c1d55de..e43e0b7 100644 --- a/src/providers/sdk/chargebee/adapter/invoice.adapter.ts +++ b/src/providers/sdk/chargebee/adapter/invoice.adapter.ts @@ -1,5 +1,10 @@ +import { + TInvoicePdf, + TInvoicePaymentDetails, + TPaymentMethod, +} from '../../../../types'; +import {ChargebeeInvoice, ICharge, IChargeBeeInvoice, IDiscount} from '../type'; import {AnyObject} from '@loopback/repository'; -import {ICharge, IChargeBeeInvoice, IDiscount} from '../type'; export class InvoiceAdapter { constructor() {} @@ -38,4 +43,64 @@ export class InvoiceAdapter { }; return res; } + + /** + * Adapts a ChargeBee invoice download result to TInvoicePdf format. + * + * @param download - ChargeBee download object + * @param invoiceId - The invoice ID + * @returns TInvoicePdf - Invoice PDF information + */ + adaptToInvoicePdf( + download: Record, + invoiceId: string, + ): TInvoicePdf { + const downloadUrl = download['download_url']; + const pdfUrl = typeof downloadUrl === 'string' ? downloadUrl : ''; + return { + invoiceId: invoiceId, + pdfUrl: pdfUrl, + generatedAt: Math.floor(Date.now() / 1000), // Current timestamp in seconds + expiresAt: download['expires_at'] as number | undefined, + }; + } + + /** + * Adapts ChargeBee invoice and payment method data to TInvoicePaymentDetails format. + * + * @param invoice - ChargeBee invoice object + * @param paymentMethod - Payment method details + * @returns TInvoicePaymentDetails - Payment details for the invoice + */ + adaptToPaymentDetails( + invoice: ChargebeeInvoice, + paymentMethod: TPaymentMethod, + ): TInvoicePaymentDetails { + const id = invoice.invoiceId ?? invoice.id ?? ''; + return { + invoiceId: id, + paymentMethod: paymentMethod, + paymentDate: invoice.paidAt + ? Math.floor(new Date(invoice.paidAt).getTime() / 1000) + : undefined, + amount: invoice.total ?? 0, + currency: invoice.currencyCode ?? 'USD', + status: invoice.status ?? 'unknown', + transactionId: id, + description: `Payment for invoice ${id}`, + }; + } + + /** + * Extracts customer ID from invoice. + * + * @param invoice - The ChargeBee invoice + * @returns Customer ID or empty string if not found + */ + getCustomerIdFromInvoice(invoice: ChargebeeInvoice): string { + const customerId = + ((invoice as Record)['customer_id'] as string) || + invoice.customerId; + return customerId ?? ''; + } } diff --git a/src/providers/sdk/chargebee/adapter/payment-intent.adapter.ts b/src/providers/sdk/chargebee/adapter/payment-intent.adapter.ts new file mode 100644 index 0000000..0b14bd6 --- /dev/null +++ b/src/providers/sdk/chargebee/adapter/payment-intent.adapter.ts @@ -0,0 +1,227 @@ +import {PaymentStatus, TPaymentIntent, TPaymentMethod} from '../../../../types'; +import { + ChargebeeCard, + ChargebeePaymentSource, + ChargebeeTransaction, + ChargebeeCardDefaults, +} from '../type'; + +/** Default fallback values for card details when config is not provided */ +const DEFAULT_EXPIRY_MONTH = 12; +const DEFAULT_FUNDING_TYPE = 'credit'; +const DEFAULT_CARD_BRAND = 'unknown'; + +export class ChargebeePaymentIntentAdapter { + private readonly cardDefaults: Required; + + constructor(cardDefaults?: ChargebeeCardDefaults) { + const currentYear = new Date().getFullYear(); + this.cardDefaults = { + defaultExpiryMonth: + cardDefaults?.defaultExpiryMonth ?? DEFAULT_EXPIRY_MONTH, + defaultExpiryYear: cardDefaults?.defaultExpiryYear ?? currentYear, + defaultFundingType: + cardDefaults?.defaultFundingType ?? DEFAULT_FUNDING_TYPE, + defaultCardBrand: cardDefaults?.defaultCardBrand ?? DEFAULT_CARD_BRAND, + }; + } + + /** + * Adapts a ChargeBee transaction to the generic TPaymentIntent format. + * + * @param transaction - ChargeBee transaction object + * @param paymentMethod - Optional payment method details + * @returns TPaymentIntent - Normalized payment intent format + */ + adaptToModel( + transaction: ChargebeeTransaction, + paymentMethod?: TPaymentMethod, + ): TPaymentIntent { + const currencyCode = this.extractCurrencyCode(transaction); + const customerId = this.extractCustomerId(transaction); + const amountCapturable = this.extractAmountCapturable(transaction); + const transactionType = this.extractTransactionType(transaction); + const created = this.extractCreatedTimestamp(transaction); + + return { + id: transaction.id ?? '', + amount: transaction.amount ?? 0, + currency: currencyCode.toLowerCase(), + status: this.mapTransactionStatusToPaymentIntentStatus( + transaction.status ?? 'in_progress', + ), + created: created, + customer: customerId, + paymentMethod: paymentMethod, + description: `ChargeBee ${transactionType} transaction`, + latestCharge: transaction.id ?? '', // In ChargeBee, transaction is the charge + clientSecret: undefined, // ChargeBee doesn't have client secret concept + amountCapturable: amountCapturable, + captureMethod: 'automatic', // ChargeBee default behavior + }; + } + + private extractCurrencyCode(transaction: ChargebeeTransaction): string { + const currencyCode = + ((transaction as Record)['currency_code'] as + | string + | undefined) ?? transaction.currencyCode; + return currencyCode ?? 'usd'; + } + + private extractCustomerId( + transaction: ChargebeeTransaction, + ): string | undefined { + return ( + ((transaction as Record)['customer_id'] as + | string + | undefined) ?? transaction.customerId + ); + } + + private extractAmountCapturable( + transaction: ChargebeeTransaction, + ): number | undefined { + return (transaction as Record)['amount_capturable'] as + | number + | undefined; + } + + private extractTransactionType(transaction: ChargebeeTransaction): string { + return ( + ((transaction as Record)['type'] as + | string + | undefined) ?? 'transaction' + ); + } + + private extractCreatedTimestamp(transaction: ChargebeeTransaction): number { + if (typeof transaction.date === 'string') { + return Math.floor(new Date(transaction.date).getTime() / 1000); + } + return transaction.date ?? 0; + } + + /** + * Maps ChargeBee transaction status to PaymentIntent status. + * + * ChargeBee transaction statuses: in_progress, success, voided, failure, timeout, needs_attention, late_failure + * PaymentIntent statuses: requires_payment_method, requires_confirmation, requires_action, processing, requires_capture, canceled, succeeded + * + * @param transactionStatus - ChargeBee transaction status + * @returns Corresponding PaymentIntent status + */ + private mapTransactionStatusToPaymentIntentStatus( + transactionStatus: string, + ): PaymentStatus { + switch (transactionStatus) { + case 'in_progress': + return PaymentStatus.PROCESSING; + case 'success': + return PaymentStatus.SUCCEEDED; + case 'voided': + return PaymentStatus.CANCELED; + case 'failure': + return PaymentStatus.CANCELED; + case 'timeout': + return PaymentStatus.CANCELED; + case 'needs_attention': + return PaymentStatus.REQUIRES_ACTION; + case 'late_failure': + return PaymentStatus.CANCELED; + default: + return PaymentStatus.REQUIRES_PAYMENT_METHOD; + } + } + + /** + * Adapts a ChargeBee payment source to the generic TPaymentMethod format. + */ + adaptPaymentSource(source: ChargebeePaymentSource): TPaymentMethod { + if (source.type === 'card' && source.card) { + return { + type: 'card', + id: source.id ?? '', + customer: source.customerId, + card: this.buildCardDetails(source.card), + }; + } + + return { + type: source.type ?? 'unknown', + id: source.id ?? '', + customer: source.customerId, + }; + } + + /** + * Builds card details object from ChargeBee card data. + * + * @param card - ChargeBee card information + * @returns Formatted card details + */ + private buildCardDetails(card: ChargebeeCard): { + brand: string; + last4: string; + expMonth: number; + expYear: number; + funding: string; + } { + return { + brand: this.getCardBrand(card), + last4: card.last4 ?? '****', + expMonth: card.expiryMonth ?? this.cardDefaults.defaultExpiryMonth, + expYear: card.expiryYear ?? this.cardDefaults.defaultExpiryYear, + funding: card.funding ?? this.cardDefaults.defaultFundingType, + }; + } + + /** + * Gets card brand from first six digits. + * + * @param card - ChargeBee card information + * @returns Card brand + */ + private getCardBrand(card: ChargebeeCard): string { + if (card.firstSixDigits) { + return this.detectCardBrand(card.firstSixDigits); + } + return this.cardDefaults.defaultCardBrand; + } + + /** + * Detects card brand from first six digits. + */ + private detectCardBrand(firstSix: string): string { + if (firstSix.startsWith('4')) return 'visa'; + if (firstSix.startsWith('5') || firstSix.startsWith('2')) + return 'mastercard'; + if (firstSix.startsWith('3')) return 'amex'; + return 'unknown'; + } + + /** + * Creates a pending payment method object. + * + * @returns Payment method with pending status + */ + createPendingPaymentMethod(): TPaymentMethod { + return { + type: 'pending', + description: 'Payment not yet processed', + } as TPaymentMethod; + } + + /** + * Creates an unknown payment method object. + * + * @param description - Description for the unknown payment method + * @returns Payment method with unknown status + */ + createUnknownPaymentMethod(description: string): TPaymentMethod { + return { + type: 'unknown', + description, + } as TPaymentMethod; + } +} diff --git a/src/providers/sdk/chargebee/charge-bee.service.ts b/src/providers/sdk/chargebee/charge-bee.service.ts index 3d68ceb..069f8b5 100644 --- a/src/providers/sdk/chargebee/charge-bee.service.ts +++ b/src/providers/sdk/chargebee/charge-bee.service.ts @@ -4,7 +4,11 @@ import {inject} from '@loopback/core'; import chargebee from 'chargebee'; import { RecurringInterval, + TInvoicePdf, + TInvoicePaymentDetails, TInvoicePrice, + TPaymentIntent, + TPaymentMethod, TPrice, TProduct, TSubscriptionCreate, @@ -13,6 +17,7 @@ import { Transaction, } from '../../../types'; import { + ChargebeePaymentIntentAdapter, CustomerAdapter, InvoiceAdapter, PaymentSourceAdapter, @@ -22,6 +27,9 @@ import {ChargeBeeBindings} from './key'; import { ChargeBeeConfig, ChargebeePeriodUnit, + ChargebeeCustomer, + ChargebeeInvoice, + ChargebeePaymentSource, IChargeBeeCustomer, IChargeBeeInvoice, IChargeBeePaymentSource, @@ -33,6 +41,7 @@ export class ChargeBeeService implements IChargeBeeService { customerAdapter: CustomerAdapter; paymentSource: PaymentSourceAdapter; chargebeeSubscriptionAdapter: ChargebeeSubscriptionAdapter; + chargebeePaymentIntentAdapter: ChargebeePaymentIntentAdapter; constructor( @inject(ChargeBeeBindings.config, {optional: true}) private readonly chargeBeeConfig: ChargeBeeConfig, @@ -56,6 +65,9 @@ export class ChargeBeeService implements IChargeBeeService { this.customerAdapter = new CustomerAdapter(); this.paymentSource = new PaymentSourceAdapter(); this.chargebeeSubscriptionAdapter = new ChargebeeSubscriptionAdapter(); + this.chargebeePaymentIntentAdapter = new ChargebeePaymentIntentAdapter( + chargeBeeConfig.cardDefaults, + ); } async createCustomer( customerDto: IChargeBeeCustomer, @@ -614,4 +626,212 @@ 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 using adapter + return this.invoiceAdapter.adaptToInvoicePdf(download, invoiceId); + } 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; + } + } + + /** + * Retrieves payment method details associated with a ChargeBee invoice. + * + * This method retrieves the invoice, gets the payment source details from the + * customer, and returns comprehensive payment information. + * + * @param invoiceId - The ChargeBee invoice ID + * @returns Payment details including method, amount, and status + * @throws Error if invoice not found + */ + async getInvoicePaymentDetails( + invoiceId: string, + ): Promise { + try { + // Retrieve the invoice + const result = await chargebee.invoice.retrieve(invoiceId).request(); + const invoice = result.invoice as ChargebeeInvoice; + + // Get payment method based on invoice state + const paymentMethod = await this.getInvoicePaymentMethod( + invoice, + invoiceId, + ); + + // Build and return payment details using adapter + return this.invoiceAdapter.adaptToPaymentDetails(invoice, paymentMethod); + } catch (error) { + const cbError = error as {api_error_code?: string; http_status?: number}; + if (cbError.api_error_code === 'resource_not_found') { + throw new Error(`Invoice not found: ${invoiceId}`); + } + throw error; + } + } + + /** + * Retrieves customer payment method for an invoice. + * + * @param invoice - The ChargeBee invoice + * @param invoiceId - Invoice ID for error messages + * @returns Payment method details + */ + private async getInvoicePaymentMethod( + invoice: ChargebeeInvoice, + invoiceId: string, + ): Promise { + // Check for linked payments first + if (invoice.linkedPayments && invoice.linkedPayments.length > 0) { + const payment = invoice.linkedPayments[0]; + + // Check if payment was applied + if (payment.appliedAt) { + return this.getCustomerPaymentMethod(invoice, invoiceId); + } + + // Payment not yet processed + return this.chargebeePaymentIntentAdapter.createPendingPaymentMethod(); + } + + // Fall back to customer's default payment method + return this.getCustomerPaymentMethod(invoice, invoiceId); + } + + /** + * Retrieves payment method from customer. + * + * @param invoice - The ChargeBee invoice + * @param invoiceId - Invoice ID for error messages + * @returns Payment method details + */ + private async getCustomerPaymentMethod( + invoice: ChargebeeInvoice, + invoiceId: string, + ): Promise { + const customerId = this.invoiceAdapter.getCustomerIdFromInvoice(invoice); + if (!customerId) { + throw new Error(`Customer ID not found for invoice ${invoiceId}`); + } + + const customerResult = await chargebee.customer + .retrieve(customerId) + .request(); + const customer = customerResult.customer as ChargebeeCustomer; + + if (customer.paymentSource) { + return this.chargebeePaymentIntentAdapter.adaptPaymentSource( + customer.paymentSource, + ); + } + + return this.chargebeePaymentIntentAdapter.createUnknownPaymentMethod( + 'Payment method details not available', + ); + } + + /** + * Retrieves a ChargeBee transaction by ID and returns it in PaymentIntent format. + * + * NOTE: This is a limited implementation that maps ChargeBee transactions to + * PaymentIntent format. ChargeBee's transaction model differs significantly from + * Stripe's PaymentIntent concept: + * + * - ChargeBee transactions represent actual payment attempts (not payment flow tracking) + * - Transactions are always tied to invoices/subscriptions (cannot be created independently) + * - No clientSecret for frontend payment completion + * - Limited real-time status tracking (only processing states, not payment flow states) + * - ChargeBee uses hosted payment pages instead of direct frontend integration + * + * @param paymentIntentId - The ChargeBee transaction ID + * @returns Payment intent details with ChargeBee transaction data mapped + * @throws Error if transaction not found + */ + async getPaymentIntent(paymentIntentId: string): Promise { + try { + // Retrieve the transaction from ChargeBee + const result = await chargebee.transaction + .retrieve(paymentIntentId) + .request(); + const transaction = result.transaction; + + // Get payment method details if payment_source_id is available + let paymentMethod: TPaymentMethod | undefined; + if (transaction.payment_source_id) { + try { + const paymentSourceResult = await chargebee.payment_source + .retrieve(transaction.payment_source_id) + .request(); + const paymentSource = + paymentSourceResult.payment_source as ChargebeePaymentSource; + paymentMethod = + this.chargebeePaymentIntentAdapter.adaptPaymentSource( + paymentSource, + ); + } catch (paymentSourceError) { + // If payment source not found, we'll continue without payment method details + console.info( + `Could not retrieve payment source ${transaction.payment_source_id}:`, + paymentSourceError, + ); + } + } + + // Return using adapter + return this.chargebeePaymentIntentAdapter.adaptToModel( + transaction, + paymentMethod, + ); + } catch (error) { + 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(`Transaction not found: ${paymentIntentId}`); + } + + throw new Error(JSON.stringify(error)); + } + } } diff --git a/src/providers/sdk/chargebee/type/chargebee-config.type.ts b/src/providers/sdk/chargebee/type/chargebee-config.type.ts index 91f9829..74a0118 100644 --- a/src/providers/sdk/chargebee/type/chargebee-config.type.ts +++ b/src/providers/sdk/chargebee/type/chargebee-config.type.ts @@ -1,3 +1,18 @@ +/** + * Card default values for when card data is missing from the provider response. + * These should only be used as fallbacks when payment providers return incomplete data. + */ +export interface ChargebeeCardDefaults { + /** Default expiry month (1-12). Defaults to 12. */ + defaultExpiryMonth?: number; + /** Default expiry year. Defaults to current year. */ + defaultExpiryYear?: number; + /** Default funding type. Defaults to 'credit'. */ + defaultFundingType?: string; + /** Default card brand. Defaults to 'unknown'. */ + defaultCardBrand?: string; +} + /** * Configuration for the Chargebee billing provider. * @@ -34,6 +49,11 @@ export interface ChargeBeeConfig { * Must be one of the reason codes configured on your Chargebee site. */ defaultCancelReasonCode?: string; + /** + * Card default values for fallback when provider returns incomplete card data. + * These should rarely be needed with valid Chargebee responses. + */ + cardDefaults?: ChargebeeCardDefaults; } export type ChargebeePricingModel = @@ -44,3 +64,83 @@ export type ChargebeePricingModel = | 'stairstep'; export type ChargebeePeriodUnit = 'day' | 'week' | 'month' | 'year'; + +/** + * ChargeBee linked payment object structure (flexible to handle API responses) + */ +export interface ChargebeeLinkedPayment { + id?: string; + customerId?: string; + invoiceId?: string; + appliedAt?: string; + amount?: number; + currencyCode?: string; + status?: string; + [key: string]: unknown; +} + +/** + * ChargeBee card object structure (flexible to handle API responses) + */ +export interface ChargebeeCard { + firstSixDigits?: string; + last4?: string; + expiryMonth?: number; + expiryYear?: number; + funding?: string; + [key: string]: unknown; +} + +/** + * ChargeBee payment source object structure (flexible to handle API responses) + */ +export interface ChargebeePaymentSource { + id?: string; + customerId?: string; + type?: string; + card?: ChargebeeCard; + [key: string]: unknown; +} + +/** + * ChargeBee customer object structure (flexible to handle API responses) + */ +export interface ChargebeeCustomer { + id?: string; + firstName?: string; + lastName?: string; + email?: string; + paymentSource?: ChargebeePaymentSource; + [key: string]: unknown; +} + +/** + * ChargeBee invoice object structure (flexible to handle API responses) + */ +export interface ChargebeeInvoice { + invoiceId?: string; + id?: string; + customerId?: string; + total?: number; + currencyCode?: string; + status?: string; + paidAt?: string; + linkedPayments?: ChargebeeLinkedPayment[]; + [key: string]: unknown; +} + +/** + * ChargeBee transaction object structure (flexible to handle API responses) + */ +export interface ChargebeeTransaction { + id?: string; + customerId?: string; + amount?: number; + currencyCode?: string; + status?: string; + date?: string | number; + description?: string; + metadata?: Record; + gatewayAccountId?: string; + [key: string]: unknown; +} diff --git a/src/providers/sdk/stripe/adapter/index.ts b/src/providers/sdk/stripe/adapter/index.ts index ed2e797..d9a287c 100644 --- a/src/providers/sdk/stripe/adapter/index.ts +++ b/src/providers/sdk/stripe/adapter/index.ts @@ -1,4 +1,5 @@ export * from './customer.adapter'; export * from './invoice.adapter'; +export * from './payment-intent.adapter'; export * from './payment-source.adapter'; export * from './subscription.adapter'; diff --git a/src/providers/sdk/stripe/adapter/invoice.adapter.ts b/src/providers/sdk/stripe/adapter/invoice.adapter.ts index 97d19c9..845dd0b 100644 --- a/src/providers/sdk/stripe/adapter/invoice.adapter.ts +++ b/src/providers/sdk/stripe/adapter/invoice.adapter.ts @@ -1,6 +1,12 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import Stripe from 'stripe'; import {AnyObject} from '@loopback/repository'; -import {IAdapter} from '../../../../types'; +import { + IAdapter, + TInvoicePdf, + TInvoicePaymentDetails, + TPaymentMethod, +} from '../../../../types'; import {IStripeInvoice} from '../type'; export class StripeInvoiceAdapter implements IAdapter { constructor() {} @@ -69,4 +75,42 @@ export class StripeInvoiceAdapter implements IAdapter { auto_advance: data.options?.autoAdvance ?? false, }; } + + /** + * Adapts a Stripe Invoice to TInvoicePdf format. + * + * @param invoice - Stripe Invoice object + * @returns TInvoicePdf - Invoice PDF information + */ + adaptToInvoicePdf(invoice: Stripe.Invoice): TInvoicePdf { + return { + invoiceId: invoice.id, + pdfUrl: invoice.invoice_pdf ?? '', + generatedAt: Math.floor(Date.now() / 1000), + }; + } + + /** + * Adapts Stripe invoice and charge data to TInvoicePaymentDetails format. + * + * @param invoice - Stripe Invoice object + * @param paymentMethod - Payment method details + * @returns TInvoicePaymentDetails - Payment details for the invoice + */ + adaptToPaymentDetails( + invoice: Stripe.Invoice, + paymentMethod: TPaymentMethod, + ): TInvoicePaymentDetails { + const charge = invoice.charge as Stripe.Charge; + return { + invoiceId: invoice.id, + paymentMethod: paymentMethod, + paymentDate: invoice.status_transitions?.paid_at ?? undefined, + amount: charge.amount, + currency: charge.currency, + status: charge.status, + transactionId: charge.id, + description: charge.description ?? undefined, + }; + } } diff --git a/src/providers/sdk/stripe/adapter/payment-intent.adapter.ts b/src/providers/sdk/stripe/adapter/payment-intent.adapter.ts new file mode 100644 index 0000000..8041d4f --- /dev/null +++ b/src/providers/sdk/stripe/adapter/payment-intent.adapter.ts @@ -0,0 +1,34 @@ +import Stripe from 'stripe'; +import {PaymentStatus, TPaymentIntent, TPaymentMethod} from '../../../../types'; + +export class StripePaymentIntentAdapter { + constructor() {} + + /** + * Adapts a Stripe PaymentIntent to the generic TPaymentIntent format. + * + * @param paymentIntent - Stripe PaymentIntent object + * @param paymentMethod - Optional payment method details + * @returns TPaymentIntent - Normalized payment intent format + */ + adaptToModel( + paymentIntent: Stripe.PaymentIntent, + paymentMethod?: TPaymentMethod, + ): TPaymentIntent { + return { + id: paymentIntent.id, + amount: paymentIntent.amount, + currency: paymentIntent.currency, + status: paymentIntent.status as PaymentStatus, + created: paymentIntent.created, + customer: (paymentIntent.customer as string) ?? undefined, + paymentMethod: paymentMethod, + description: paymentIntent.description ?? undefined, + metadata: paymentIntent.metadata as Record, + latestCharge: (paymentIntent.latest_charge as string) ?? undefined, + clientSecret: paymentIntent.client_secret ?? undefined, + amountCapturable: paymentIntent.amount_capturable, + captureMethod: paymentIntent.capture_method, + }; + } +} diff --git a/src/providers/sdk/stripe/adapter/payment-source.adapter.ts b/src/providers/sdk/stripe/adapter/payment-source.adapter.ts index 508284c..d05fb7a 100644 --- a/src/providers/sdk/stripe/adapter/payment-source.adapter.ts +++ b/src/providers/sdk/stripe/adapter/payment-source.adapter.ts @@ -1,8 +1,31 @@ +import Stripe from 'stripe'; import {AnyObject} from '@loopback/repository'; -import {IAdapter} from '../../../../types'; -import {IStripePaymentSource} from '../type'; +import {TPaymentMethod, IAdapter} from '../../../../types'; +import { + IStripePaymentSource, + StripeLegacySource, + StripeCardDefaults, +} from '../type'; + +/** Default fallback values for card details when config is not provided */ +const DEFAULT_EXPIRY_MONTH = 12; +const DEFAULT_FUNDING_TYPE = 'credit'; +const DEFAULT_CARD_BRAND = 'unknown'; + export class StripePaymentAdapter implements IAdapter { - constructor() {} + private readonly cardDefaults: Required; + + constructor(cardDefaults?: StripeCardDefaults) { + const currentYear = new Date().getFullYear(); + this.cardDefaults = { + defaultExpiryMonth: + cardDefaults?.defaultExpiryMonth ?? DEFAULT_EXPIRY_MONTH, + defaultExpiryYear: cardDefaults?.defaultExpiryYear ?? currentYear, + defaultFundingType: + cardDefaults?.defaultFundingType ?? DEFAULT_FUNDING_TYPE, + defaultCardBrand: cardDefaults?.defaultCardBrand ?? DEFAULT_CARD_BRAND, + }; + } adaptToModel(resp: AnyObject): IStripePaymentSource { return { @@ -22,7 +45,66 @@ export class StripePaymentAdapter implements IAdapter { }, }; } - adaptFromModel(data: IStripePaymentSource): AnyObject { + adaptFromModel(_data: IStripePaymentSource): AnyObject { return {}; // This is intentional } + + /** + * Adapts a Stripe PaymentMethod to the generic TPaymentMethod format. + */ + adaptPaymentMethod(pm: Stripe.PaymentMethod): TPaymentMethod { + if (pm.type === 'card') { + return { + type: 'card', + id: pm.id, + customer: pm.customer as string, + card: { + brand: pm.card!.brand, + last4: pm.card!.last4, + expMonth: pm.card!.exp_month, + expYear: pm.card!.exp_year, + funding: pm.card!.funding, + country: pm.card!.country ?? undefined, + }, + }; + } + + // Handle other payment method types as needed + return { + type: pm.type, + id: pm.id, + customer: pm.customer as string, + }; + } + + /** + * Adapts a legacy Stripe Source to the generic TPaymentMethod format. + */ + adaptSource(source: StripeLegacySource): TPaymentMethod { + if (source.type === 'card' && source.card) { + const card = source.card as { + brand: string; + last4: string; + expMonth: number; + expYear: number; + funding: string; + }; + return { + type: 'card', + id: source.id, + card: { + brand: card.brand || this.cardDefaults.defaultCardBrand, + last4: card.last4 || '****', + expMonth: card.expMonth || this.cardDefaults.defaultExpiryMonth, + expYear: card.expYear || this.cardDefaults.defaultExpiryYear, + funding: card.funding || this.cardDefaults.defaultFundingType, + }, + }; + } + + return { + type: source.type ?? 'unknown', + id: source.id, + }; + } } diff --git a/src/providers/sdk/stripe/stripe.service.ts b/src/providers/sdk/stripe/stripe.service.ts index ecd2610..e48bbe6 100644 --- a/src/providers/sdk/stripe/stripe.service.ts +++ b/src/providers/sdk/stripe/stripe.service.ts @@ -1,11 +1,16 @@ /* eslint-disable @typescript-eslint/naming-convention */ + import {inject} from '@loopback/core'; import Stripe from 'stripe'; import { CollectionMethod, RecurringInterval, TInvoice, + TInvoicePdf, + TInvoicePaymentDetails, TInvoicePrice, + TPaymentIntent, + TPaymentMethod, TPrice, TProduct, TSubscriptionCreate, @@ -17,6 +22,7 @@ import { StripeCustomerAdapter, StripeInvoiceAdapter, StripePaymentAdapter, + StripePaymentIntentAdapter, StripeSubscriptionAdapter, } from './adapter'; import {StripeBindings} from './key'; @@ -26,6 +32,7 @@ import { IStripePaymentSource, IStripeService, StripeConfig, + StripeLegacySource, } from './type'; export class StripeService implements IStripeService { /** @@ -37,6 +44,7 @@ export class StripeService implements IStripeService { stripeInvoiceAdapter: StripeInvoiceAdapter; stripePaymentAdapter: StripePaymentAdapter; stripeSubscriptionAdapter: StripeSubscriptionAdapter; + stripePaymentIntentAdapter: StripePaymentIntentAdapter; constructor( @inject(StripeBindings.config, {optional: true}) @@ -47,8 +55,11 @@ export class StripeService implements IStripeService { }); this.stripeCustomerAdapter = new StripeCustomerAdapter(); this.stripeInvoiceAdapter = new StripeInvoiceAdapter(); - this.stripePaymentAdapter = new StripePaymentAdapter(); + this.stripePaymentAdapter = new StripePaymentAdapter( + stripeConfig.cardDefaults, + ); this.stripeSubscriptionAdapter = new StripeSubscriptionAdapter(); + this.stripePaymentIntentAdapter = new StripePaymentIntentAdapter(); } async createCustomer(customerDto: IStripeCustomer): Promise { @@ -534,4 +545,153 @@ 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 using adapter + return this.stripeInvoiceAdapter.adaptToInvoicePdf(invoice); + } 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; + } + } + + /** + * Retrieves payment method details associated with a Stripe invoice. + * + * This method retrieves the invoice, expands to get the charge and payment + * method details, and returns comprehensive payment information. + * + * @param invoiceId - The Stripe invoice ID + * @returns Payment details including method, amount, and status + * @throws Error if invoice not found or no payment available + */ + async getInvoicePaymentDetails( + invoiceId: string, + ): Promise { + try { + // Retrieve the invoice with expanded charge and payment method + const invoice = await this.stripe.invoices.retrieve(invoiceId, { + expand: ['charge', 'default_payment_method'], + }); + + // Check if invoice has a charge + if (!invoice.charge) { + throw new Error( + `No payment found for invoice ${invoiceId}. The invoice may not be paid yet.`, + ); + } + + const charge = invoice.charge as Stripe.Charge; + + // Get payment method details + let paymentMethod: TPaymentMethod; + + if (charge.payment_method) { + // Retrieve the payment method + const pm = await this.stripe.paymentMethods.retrieve( + charge.payment_method as string, + ); + paymentMethod = this.stripePaymentAdapter.adaptPaymentMethod(pm); + } else if (charge.source) { + // Legacy source-based payment + const source = charge.source as unknown as StripeLegacySource; + paymentMethod = this.stripePaymentAdapter.adaptSource(source); + } else { + throw new Error('No payment method information available'); + } + + // Return using adapter + return this.stripeInvoiceAdapter.adaptToPaymentDetails( + invoice, + paymentMethod, + ); + } catch (error) { + const stripeError = error as {code?: string; message?: string}; + if (stripeError.code === 'resource_missing') { + throw new Error(`Invoice not found: ${invoiceId}`); + } + throw error; + } + } + + /** + * Retrieves a Stripe payment intent by ID. + * + * Payment intents represent the payment flow from initiation to completion. + * This method returns comprehensive payment tracking information. + * + * @param paymentIntentId - The Stripe payment intent ID + * @returns Payment intent details including status, amount, and method + * @throws Error if payment intent not found + */ + async getPaymentIntent(paymentIntentId: string): Promise { + try { + // Retrieve the payment intent with expanded payment method + const paymentIntent = await this.stripe.paymentIntents.retrieve( + paymentIntentId, + { + expand: ['payment_method', 'latest_charge'], + }, + ); + + // Adapt payment method if available + let paymentMethod: TPaymentMethod | undefined; + if (paymentIntent.payment_method) { + if (typeof paymentIntent.payment_method === 'string') { + // If it's just an ID, retrieve the full payment method + const pm = await this.stripe.paymentMethods.retrieve( + paymentIntent.payment_method, + ); + paymentMethod = this.stripePaymentAdapter.adaptPaymentMethod(pm); + } else { + // Already expanded + paymentMethod = this.stripePaymentAdapter.adaptPaymentMethod( + paymentIntent.payment_method as Stripe.PaymentMethod, + ); + } + } + + // Return using adapter + return this.stripePaymentIntentAdapter.adaptToModel( + paymentIntent, + paymentMethod, + ); + } catch (error) { + const stripeError = error as {code?: string; message?: string}; + if (stripeError.code === 'resource_missing') { + throw new Error(`Payment intent not found: ${paymentIntentId}`); + } + throw error; + } + } } diff --git a/src/providers/sdk/stripe/type/stripe-config.type.ts b/src/providers/sdk/stripe/type/stripe-config.type.ts index 1d2f776..6e7b22d 100644 --- a/src/providers/sdk/stripe/type/stripe-config.type.ts +++ b/src/providers/sdk/stripe/type/stripe-config.type.ts @@ -1,4 +1,20 @@ import Stripe from 'stripe'; + +/** + * Card default values for when card data is missing from the provider response. + * These should only be used as fallbacks when payment providers return incomplete data. + */ +export interface StripeCardDefaults { + /** Default expiry month (1-12). Defaults to 12. */ + defaultExpiryMonth?: number; + /** Default expiry year. Defaults to current year. */ + defaultExpiryYear?: number; + /** Default funding type. Defaults to 'credit'. */ + defaultFundingType?: string; + /** Default card brand. Defaults to 'unknown'. */ + defaultCardBrand?: string; +} + export interface StripeConfig { secretKey: string; /** @@ -13,4 +29,29 @@ export interface StripeConfig { * @see https://stripe.com/docs/api/subscriptions/create#create_subscription-payment_behavior */ defaultPaymentBehavior?: Stripe.SubscriptionCreateParams.PaymentBehavior; + /** + * Card default values for fallback when provider returns incomplete card data. + * These should rarely be needed with valid Stripe responses. + */ + cardDefaults?: StripeCardDefaults; } + +/** + * Stripe legacy Source object structure (for card-based payments) + * Made more flexible to handle Stripe's CustomerSource union type + */ +export type StripeLegacySource = { + id: string; + type?: string; + object?: string; + card?: { + brand?: string; + last4?: string; + expMonth?: number; + expYear?: number; + funding?: string; + }; + customer?: string; + metadata?: Record; + [key: string]: unknown; +}; diff --git a/src/types.ts b/src/types.ts index 7438b25..24398c7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,6 +39,9 @@ export interface IService { ): Promise; deleteInvoice(invoiceId: string): Promise; getPaymentStatus(invoiceId: string): Promise; + getInvoicePdf(invoiceId: string): Promise; + getInvoicePaymentDetails(invoiceId: string): Promise; + getPaymentIntent(paymentIntentId: string): Promise; } export interface IAdapter { /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ @@ -164,6 +167,19 @@ export enum ProrationBehavior { ALWAYS_INVOICE = 'always_invoice', } +/** + * Payment intent status values + */ +export enum PaymentStatus { + REQUIRES_PAYMENT_METHOD = 'requires_payment_method', + REQUIRES_CONFIRMATION = 'requires_confirmation', + REQUIRES_ACTION = 'requires_action', + PROCESSING = 'processing', + REQUIRES_CAPTURE = 'requires_capture', + CANCELED = 'canceled', + SUCCEEDED = 'succeeded', +} + /** * Parameters required to create a product in the billing provider. */ @@ -244,6 +260,162 @@ 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; +} + +/** + * Card payment method details + */ +export interface TCard { + /** Card brand: visa, mastercard, amex, etc. */ + brand: string; + /** Last 4 digits */ + last4: string; + /** Expiration month */ + expMonth: number; + /** Expiration year */ + expYear: number; + /** Funding type: credit, debit, prepaid, unknown */ + funding: string; + /** Country code */ + country?: string; +} + +/** + * Bank account payment method details + */ +export interface TBankAccount { + /** Bank name */ + bankName: string; + /** Last 4 digits */ + last4: string; + /** Routing number */ + routingNumber?: string; + /** Account type: checking, savings */ + accountType?: string; +} + +/** + * Represents payment method details (card, bank account, etc.) + */ +export interface TPaymentMethod { + /** Payment method type: card, bank_account, etc. */ + type: string; + + /** Card details (if type is card) */ + card?: TCard; + + /** Bank account details (if type is bank_account) */ + bankAccount?: TBankAccount; + + /** Customer ID */ + customer?: string; + + /** Payment method ID at provider */ + id?: string; +} + +/** + * Complete payment details for an invoice + */ +export interface TInvoicePaymentDetails { + /** Invoice ID */ + invoiceId: string; + + /** Payment method information */ + paymentMethod: TPaymentMethod; + + /** Payment date (timestamp in seconds) */ + paymentDate?: number; + + /** Payment amount (in minor units) */ + amount?: number; + + /** Currency code */ + currency?: string; + + /** Payment status */ + status?: string; + + /** Transaction ID */ + transactionId?: string; + + /** Payment description */ + description?: string; +} + +/** + * Represents a payment intent for tracking payment flow + */ +export interface TPaymentIntent { + /** Payment intent ID */ + id: string; + + /** Payment amount (in minor units) */ + amount: number; + + /** Currency code */ + currency: string; + + /** + * Payment status: + * - requires_payment_method + * - requires_confirmation + * - requires_action + * - processing + * - requires_capture + * - canceled + * - succeeded + */ + status: PaymentStatus; + + /** Creation timestamp (seconds) */ + created: number; + + /** Customer ID */ + customer?: string; + + /** Payment method details */ + paymentMethod?: TPaymentMethod; + + /** Payment description */ + description?: string; + + /** Metadata key-value pairs */ + metadata?: Record; + + /** Latest charge ID */ + latestCharge?: string; + + /** Client secret for client-side confirmation */ + clientSecret?: string; + + /** Amount captured (if applicable) */ + amountCapturable?: number; + + /** Capture method: automatic or manual */ + captureMethod?: string; +} + /** * Interface that any billing provider must implement to support the full * recurring-subscription lifecycle.