diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index b5fd0d3..78c7f1f 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -5,6 +5,7 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import { UsersModule } from './users/users.module'; import { AuthModule } from './auth/auth.module'; +import { PaymentsModule } from './payments/payments.module'; import AppDataSource from './data-source'; @Module({ @@ -12,6 +13,7 @@ import AppDataSource from './data-source'; TypeOrmModule.forRoot(AppDataSource.options), UsersModule, AuthModule, + PaymentsModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/backend/src/payments/STRIPE.MD b/apps/backend/src/payments/STRIPE.MD new file mode 100644 index 0000000..d733bc1 --- /dev/null +++ b/apps/backend/src/payments/STRIPE.MD @@ -0,0 +1,77 @@ +Stripe Integration Documentation + +This document outlines our Stripe integration for handling payments and subscriptions in the application. + +Configuration + +The application uses Stripe for payment processing. The integration requires the following environment variables: + +STRIPE_SECRET_KEY: Used for server-side API calls to Stripe +STRIPE_PUBLISHABLE_KEY: Used for client-side Stripe Elements integration + +To acquire these keys + +1. Login to Stripe using the FCC gmail and password posted in Slack: https://dashboard.stripe.com/ +2. Confirm that the top says "You're testing in a sandbox—your place to experiment with Stripe functionality." or there's some sort of indication that you're in the sandbox environment +3. On the bottom left click the "Developers" dropdown heading +4. Click the "API keys" dropdown option +5. Under the "Standard keys" section and under the "Token" heading in the table, copy each key + +Implementation Overview + +The Stripe integration is implemented in the PaymentsModule and PaymentsService classes. + +The PaymentsModule configures the Stripe client as a provider + +The PaymentsService class implements methods that call Stripe API and return data with the expectation that it will be passed to the front end + +The PaymentsService provides the following methods: + +async createPaymentIntent( + amount: number - Payment amount in the smallest currency unit (e.g., cents for USD) + currency: string - Three-letter ISO currency code (e.g., "usd") + metadata?: PaymentIntentMetadata - Optional key-value pairs to attach to the payment intent +): Promise + +async retrievePaymentIntent( + paymentIntentId: string - Stripe payment intent ID to retrieve +): Promise + + +Where PaymentIntentResponse is: + id: string; + clientSecret: string; + amount: number; + currency: string; + status: DonationStatus; + metadata?: Record; + paymentMethodId?: string; + paymentMethodTypes: string[]; + created: number; + requiresAction: boolean; + nextAction?: unknown; + lastPaymentError?: { + code: string; + message: string; + type: string; + }; + canceledAt?: number; + +async createSubscription( + customerId: string - Stripe customer ID (must start with "cus_") + priceId: string - Stripe price ID (must start with "price_") +): Promise<{ + id: string; + customerId: string; + priceId: string; + interval: RecurringInterval; + status: string; +}> + +Testing + +The Stripe integration can be tested using: + +- Stripe's test mode or sandbox with test API keys +- Stripe's test cards found from: https://docs.stripe.com/testing +- The provided unit tests with mocked Stripe responses \ No newline at end of file diff --git a/apps/backend/src/payments/payments.module.ts b/apps/backend/src/payments/payments.module.ts new file mode 100644 index 0000000..42223c1 --- /dev/null +++ b/apps/backend/src/payments/payments.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import Stripe from 'stripe'; +import { PaymentsService } from './payments.service'; + +@Module({ + imports: [ConfigModule], + providers: [ + { + provide: 'STRIPE_CLIENT', + useFactory: (configService: ConfigService) => { + return new Stripe(configService.get('STRIPE_SECRET_KEY'), { + apiVersion: '2025-09-30.clover', + }); + }, + inject: [ConfigService], + }, + PaymentsService + ], + exports: [PaymentsService], +}) +export class PaymentsModule {} \ No newline at end of file diff --git a/apps/backend/src/payments/payments.service.spec.ts b/apps/backend/src/payments/payments.service.spec.ts new file mode 100644 index 0000000..7f01b50 --- /dev/null +++ b/apps/backend/src/payments/payments.service.spec.ts @@ -0,0 +1,504 @@ +import { PaymentsService } from './payments.service'; +import { DonationStatus } from '../donations/donation.entity'; +import Stripe from 'stripe'; + +const stripeMock = { + paymentIntents: { + create: jest.fn(), + retrieve: jest.fn(), + update: jest.fn(), + cancel: jest.fn(), + }, + subscriptions: { + create: jest.fn(), + retrieve: jest.fn(), + update: jest.fn(), + cancel: jest.fn(), + }, +}; + +// Mock the Stripe constructor to return our mock +jest.mock('stripe', () => { + return jest.fn().mockImplementation(() => stripeMock); +}); + +// Helper function for creating Stripe-like errors +function createStripeError(type, code, message, extraProps = {}) { + return { + type, + code, + message, + ...extraProps, + raw: { + type: type.replace('Stripe', '').toLowerCase(), + code, + message, + }, + }; +} + +const paymentIntentMock1 = { + id: 'pi_1234567890abcdefghijklmn', + object: 'payment_intent', + amount: 50, + amount_capturable: 0, + amount_received: 0, + application: null, + application_fee_amount: null, + automatic_payment_methods: { enabled: true }, + canceled_at: null, + cancellation_reason: null, + capture_method: 'automatic', + client_secret: 'pi_1234567890abcdef_secret_1234567890abcdef', + confirmation_method: 'automatic', + created: Math.floor(Date.now() / 1000), + currency: 'usd', + customer: null, + description: null, + invoice: null, + last_payment_error: null, + latest_charge: null, + livemode: false, + metadata: { orderId: '123' }, + next_action: null, + on_behalf_of: null, + payment_method: null, + payment_method_options: { + card: { request_three_d_secure: 'automatic' }, + }, + payment_method_types: ['card'], + processing: null, + receipt_email: null, + review: null, + setup_future_usage: null, + shipping: null, + statement_descriptor: null, + statement_descriptor_suffix: null, + status: 'processing', + transfer_data: null, + transfer_group: null, +}; + +const paymentIntentMock2 = { + id: 'pi_1234567890abcdefghijklmn', + object: 'payment_intent', + amount: 500, + amount_capturable: 0, + amount_received: 500, + application: null, + application_fee_amount: null, + automatic_payment_methods: { enabled: true }, + canceled_at: null, + cancellation_reason: null, + capture_method: 'automatic', + client_secret: 'pi_1234567890abcdefghijklmn_secret_1234567890abcdef', + confirmation_method: 'automatic', + created: Math.floor(Date.now() / 1000), + currency: 'usd', + customer: null, + description: null, + invoice: null, + last_payment_error: null, + latest_charge: 'ch_1234567890abcdef', + livemode: false, + metadata: { orderId: '123' }, + next_action: null, + on_behalf_of: null, + payment_method: 'pm_1234567890abcdef', + payment_method_options: { + card: { request_three_d_secure: 'automatic' }, + }, + payment_method_types: ['card'], + processing: null, + receipt_email: null, + review: null, + setup_future_usage: null, + shipping: null, + statement_descriptor: null, + statement_descriptor_suffix: null, + status: 'succeeded', + transfer_data: null, + transfer_group: null, +}; + +const subscriptionMock1 = { + id: 'sub_1234567890abcdef', + object: 'subscription', + application: null, + application_fee_percent: null, + automatic_tax: { enabled: false }, + billing_cycle_anchor: Math.floor(Date.now() / 1000), + billing_thresholds: null, + cancel_at: null, + cancel_at_period_end: false, + canceled_at: null, + collection_method: 'charge_automatically', + created: Math.floor(Date.now() / 1000), + current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, // 30 days from now + current_period_start: Math.floor(Date.now() / 1000), + customer: 'cus_1234abcdefgh5678', + days_until_due: null, + default_payment_method: null, + default_source: null, + default_tax_rates: [], + discount: null, + ended_at: null, + items: { + object: 'list', + data: [ + { + id: 'si_1234567890abcdef', + object: 'subscription_item', + billing_thresholds: null, + created: Math.floor(Date.now() / 1000), + metadata: { orderId: '123' }, + price: { + id: 'price_1234abcdefgh5678', + object: 'price', + active: true, + billing_scheme: 'per_unit', + created: Math.floor(Date.now() / 1000), + currency: 'usd', + livemode: false, + lookup_key: null, + metadata: { orderId: '123' }, + nickname: null, + product: 'prod_1234567890abcdef', + recurring: { + aggregate_usage: null, + interval: 'month', // Ensure interval is present + interval_count: 1, + usage_type: 'licensed', + }, + tax_behavior: 'unspecified', + tiers_mode: null, + transform_quantity: null, + type: 'recurring', + unit_amount: 1000, + unit_amount_decimal: '1000', + }, + quantity: 1, + subscription: 'sub_1234567890abcdef', + tax_rates: [], + }, + ], + has_more: false, + total_count: 1, + url: '/v1/subscription_items?subscription=sub_1234567890abcdef', + }, + latest_invoice: 'in_1234567890abcdef', + livemode: false, + metadata: { orderId: '123' }, + next_pending_invoice_item_invoice: null, + pause_collection: null, + payment_settings: { + payment_method_options: null, + payment_method_types: null, + save_default_payment_method: 'off', + }, + pending_invoice_item_interval: null, + pending_setup_intent: null, + pending_update: null, + schedule: null, + start_date: Math.floor(Date.now() / 1000), + status: 'active', + transfer_data: null, + trial_end: null, + trial_start: null, +}; + +describe('PaymentsService', () => { + let svc: PaymentsService; + + beforeEach(() => { + // Clear all mocks before each test + jest.clearAllMocks(); + + // Create a new instance with our mock + svc = new PaymentsService(stripeMock as unknown as Stripe); + + // Set up default mock implementations with more realistic Stripe responses + stripeMock.paymentIntents.create.mockResolvedValue(paymentIntentMock1); + + stripeMock.paymentIntents.retrieve.mockResolvedValue(paymentIntentMock2); + + stripeMock.subscriptions.create.mockResolvedValue(subscriptionMock1); + }); + + describe('createPaymentIntent', () => { + it('throws for invalid (negative) amount', async () => { + await expect(svc.createPaymentIntent(-1, 'usd')).rejects.toThrow( + 'Invalid amount: must be a number >= 0', + ); + }); + + it('throws for invalid amount (negative value) where currency is not usd', async () => { + await expect(svc.createPaymentIntent(-1, 'eur')).rejects.toThrow( + 'Invalid amount: must be a number >= 0', + ); + }); + + it('returns a well-formed payment intent for valid input (amount=0) where currency is not usd', async () => { + stripeMock.paymentIntents.create.mockResolvedValue({ + id: 'pi_1234567890abcdefghijklmn', + object: 'payment_intent', + amount: 0, + amount_capturable: 0, + amount_received: 0, + application: null, + application_fee_amount: null, + automatic_payment_methods: { enabled: true }, + canceled_at: null, + cancellation_reason: null, + capture_method: 'automatic', + client_secret: 'pi_1234567890abcdef_secret_1234567890abcdef', + confirmation_method: 'automatic', + created: Math.floor(Date.now() / 1000), + currency: 'eur', + customer: null, + description: null, + invoice: null, + last_payment_error: null, + latest_charge: null, + livemode: false, + metadata: { orderId: '123' }, + next_action: null, + on_behalf_of: null, + payment_method: null, + payment_method_options: { + card: { request_three_d_secure: 'automatic' }, + }, + payment_method_types: ['card'], + processing: null, + receipt_email: null, + review: null, + setup_future_usage: null, + shipping: null, + statement_descriptor: null, + statement_descriptor_suffix: null, + status: 'processing', + transfer_data: null, + transfer_group: null, + }); + + const pi = await svc.createPaymentIntent(0, 'eur', { orderId: '123' }); + + expect(pi).toHaveProperty('id'); + expect(pi).toHaveProperty('clientSecret'); + expect(pi.amount).toBe(0); + expect(pi.currency).toBe('eur'); + expect(pi.status).toBe(DonationStatus.PENDING); + expect(pi.metadata).toEqual({ orderId: '123' }); + }); + + it('throws for invalid (currency that has decimals) amount', async () => { + await expect(svc.createPaymentIntent(50.01, 'usd')).rejects.toThrow( + 'Invalid amount: amount is already in lowest currency unit, so there should be no decimals', + ); + }); + + it('throws for invalid usd of amount < 50 cents', async () => { + await expect(svc.createPaymentIntent(24, 'usd')).rejects.toThrow( + 'Invalid amount, US currency donations must be at least 50 cents', + ); + }); + + it('returns a well-formed payment intent for valid input (amount=50) where currency is usd', async () => { + const pi = await svc.createPaymentIntent(50, 'usd', {}); + expect(pi).toHaveProperty('id'); + expect(pi).toHaveProperty('clientSecret'); + expect(pi.amount).toBe(50); + expect(pi.currency).toBe('usd'); + expect(pi.status).toBe(DonationStatus.PENDING); + expect(pi.metadata).toEqual({ orderId: '123' }); + }); + + it('throws for null currency', async () => { + await expect(svc.createPaymentIntent(10, null)).rejects.toThrow( + /Invalid currency/i, + ); + }); + + it('throws for undefined currency', async () => { + await expect(svc.createPaymentIntent(10, null)).rejects.toThrow( + /Invalid currency/i, + ); + }); + + it('throws for non-string currency', async () => { + await expect( + svc.createPaymentIntent(10, 0 as unknown as string), + ).rejects.toThrow(/Invalid currency/i); + }); + + it('throws for currency length < 3', async () => { + await expect(svc.createPaymentIntent(10, 'a')).rejects.toThrow( + /Invalid currency/i, + ); + }); + + it('throws for currency length > 3', async () => { + await expect(svc.createPaymentIntent(10, 'aaaa')).rejects.toThrow( + /Invalid currency/i, + ); + }); + + it('returns a well-formed payment intent for valid input', async () => { + // Update the expected return shape to match the new PaymentIntentResponse format + const pi = await svc.createPaymentIntent(50, 'usd', { orderId: '123' }); + + // Check the returned object has all expected properties from PaymentIntentResponse + expect(pi).toHaveProperty('id'); + expect(pi).toHaveProperty('clientSecret'); + expect(pi).toHaveProperty('amount', 50); + expect(pi).toHaveProperty('currency', 'usd'); + expect(pi).toHaveProperty('status', DonationStatus.PENDING); + expect(pi).toHaveProperty('metadata', { orderId: '123' }); + + // Additional fields that should be present in the new response format + expect(pi).toHaveProperty('paymentMethodTypes'); + expect(pi).toHaveProperty('created'); + expect(pi).toHaveProperty('requiresAction'); + // Optional fields that might be undefined in a new payment intent + expect(pi).toHaveProperty('lastPaymentError'); + expect(pi).toHaveProperty('canceledAt'); + }); + + it('handles Stripe API errors correctly', async () => { + const cardDeclinedError = createStripeError( + 'StripeCardError', + 'card_declined', + 'Your card was declined', + { decline_code: 'insufficient_funds' }, + ); + + stripeMock.paymentIntents.create.mockRejectedValueOnce(cardDeclinedError); + + await expect(svc.createPaymentIntent(2550, 'usd')).rejects.toMatchObject( + cardDeclinedError, + ); + }); + }); + + describe('createSubscription', () => { + it('throws for undefined customerId', async () => { + await expect( + svc.createSubscription(undefined, 'price_1234abcdefgh5678'), + ).rejects.toThrow('Invalid customerId'); + }); + + it('throws for null customerId', async () => { + await expect( + svc.createSubscription(null, 'price_1234abcdefgh5678'), + ).rejects.toThrow('Invalid customerId'); + }); + + it('throws for undefined priceId', async () => { + await expect( + svc.createSubscription('cus_1234abcdefgh5678', undefined), + ).rejects.toThrow('Invalid priceId'); + }); + + it('throws for null priceId', async () => { + await expect( + svc.createSubscription('cus_1234abcdefgh5678', null), + ).rejects.toThrow('Invalid priceId'); + }); + + it('handles Stripe API errors correctly', async () => { + const paymentMethodNotSupportedError = createStripeError( + 'StripeInvalidRequestError', + 'payment_method_not_available', + 'This payment method type is not supported for subscription payments', + { payment_method: { id: 'pm_1234567890abcdef' } }, + ); + + stripeMock.subscriptions.create.mockRejectedValueOnce( + paymentMethodNotSupportedError, + ); + + await expect( + svc.createSubscription( + 'cus_1234abcdefgh5678', + 'price_1234abcdefgh5678', + ), + ).rejects.toMatchObject(paymentMethodNotSupportedError); + }); + + it('creates a mock subscription for valid inputs', async () => { + const sub = await svc.createSubscription( + 'cus_1234abcdefgh5678', + 'price_1234abcdefgh5678', + ); + expect(sub).toHaveProperty('id'); + expect(sub.customerId).toBe('cus_1234abcdefgh5678'); + expect(sub.priceId).toBe('price_1234abcdefgh5678'); + expect(sub.status).toBe('active'); + }); + }); + + describe('retrievePaymentIntent', () => { + it('throws for undefined id', async () => { + await expect(svc.retrievePaymentIntent(undefined)).rejects.toThrow( + /Invalid paymentIntentId/i, + ); + }); + + it('throws for id of invalid type', async () => { + await expect( + svc.retrievePaymentIntent(3 as unknown as string), + ).rejects.toThrow(/Invalid paymentIntentId/i); + }); + + it('throws for an empty id', async () => { + await expect(svc.retrievePaymentIntent('')).rejects.toThrow( + /Invalid paymentIntentId/i, + ); + }); + + it('returns payment intent details when given a valid id', async () => { + const paymentIntentId = 'pi_1234567890abcdefghijklmn'; + const pi = await svc.retrievePaymentIntent(paymentIntentId); + + // Verify all fields from the PaymentIntentResponse interface + expect(pi).toHaveProperty('id', paymentIntentId); + expect(pi).toHaveProperty( + 'clientSecret', + 'pi_1234567890abcdefghijklmn_secret_1234567890abcdef', + ); + expect(pi).toHaveProperty('amount', 500); // This matches our mock's return value + expect(pi).toHaveProperty('currency', 'usd'); + expect(pi).toHaveProperty('status', DonationStatus.SUCCEEDED); // Should map from 'succeeded' + expect(pi).toHaveProperty('metadata', { orderId: '123' }); + expect(pi).toHaveProperty('paymentMethodId', 'pm_1234567890abcdef'); + expect(pi).toHaveProperty('paymentMethodTypes', ['card']); + expect(pi).toHaveProperty('created'); + expect(pi).toHaveProperty('requiresAction', false); // Since status is 'succeeded' + expect(pi).toHaveProperty('lastPaymentError', undefined); + expect(pi).toHaveProperty('canceledAt', null); + + // Verify that status mapping works correctly + expect(pi.status).toBe(DonationStatus.SUCCEEDED); // Specific check for status mapping + }); + + it('handles Stripe API errors correctly', async () => { + // Make the mock throw an error for this test + const paymentIntentId = 'pi_1234567890abcdefghijklmn'; + + const noSuchPaymentIntent = createStripeError( + 'StripeInvalidRequestError', + 'resource_missing', + 'No such payment intent', + { decline_code: 'resource_missing' }, + ); + + stripeMock.paymentIntents.retrieve.mockRejectedValueOnce( + noSuchPaymentIntent, + ); + + await expect( + svc.retrievePaymentIntent(paymentIntentId), + ).rejects.toMatchObject(noSuchPaymentIntent); + }); + }); +}); diff --git a/apps/backend/src/payments/payments.service.ts b/apps/backend/src/payments/payments.service.ts new file mode 100644 index 0000000..b0f8567 --- /dev/null +++ b/apps/backend/src/payments/payments.service.ts @@ -0,0 +1,353 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import Stripe from 'stripe'; +import { + DonationStatus, + RecurringInterval, +} from '../donations/donation.entity'; + +/** + * Flexible definition for metadata, may want to change to be stricter later + */ +export type PaymentIntentMetadata = Record; + +/** + * Interface for object shape returned by service methods that output detailed payment intent info + * + * id - The unique identifier for the PaymentIntent, equivalent to what Stripe API returns + * clientSecret - The client secret used for client-side confirmation, equivalent to what Stripe API returns + * amount - The payment amount in smallest currency unit (e.g., cents), equivalent to what Stripe API returns + * currency - The three-letter ISO currency code (e.g., 'usd'), equivalent to what Stripe API returns + * status - An enum value from DonationStatus (PENDING, SUCCEEDED, FAILED, CANCELLED), mapped from Stripe's status to these four statuses + * metadata - Optional key-value pairs attached to the payment, equivalent to what Stripe API returns + * paymentMethodId - The ID of the payment method used, mapped from paymentIntent.payment_method cast as a string + * paymentMethodTypes - Array of payment method types enabled for this PaymentIntent, equivalent to what Stripe API returns + * created - Unix timestamp representing when the PaymentIntent was created, equivalent to what Stripe API returns + * requiresAction - Boolean indicating if the payment requires customer action, determined by checking if paymentIntent.status === 'requires_action' + * nextAction - Details about the required next action (if any), equivalent to what Stripe API returns + * lastPaymentError - Object containing error details if the payment failed, with properties - + * code - The error code, mapped from paymentIntent.last_payment_error.code + * message - The error message, mapped from paymentIntent.last_payment_error.message + * type - The error type, mapped from paymentIntent.last_payment_error.type + * canceledAt - Unix timestamp representing when the PaymentIntent was canceled (if applicable), equivalent to what Stripe API returns + */ +interface PaymentIntentResponse { + id: string; + clientSecret: string; + amount: number; + currency: string; + status: DonationStatus; + metadata?: Record; + paymentMethodId?: string; + paymentMethodTypes: string[]; + created: number; + requiresAction: boolean; + nextAction?: unknown; + lastPaymentError?: { + code: string; + message: string; + type: string; + }; + canceledAt?: number; +} + +@Injectable() +export class PaymentsService { + private readonly logger = new Logger(PaymentsService.name); + + constructor(@Inject('STRIPE_CLIENT') private stripe: Stripe) {} + + /** + * Validates the parameters for createPaymentIntent + * + * @param amount number - amount in smallest currency unit (required, positive integer) + * @param currency string - ISO currency code, e.g. 'usd' (required) + * @param metadata object - optional key/value metadata to attach to the payment + * @returns string - either an empty string to signify good paramters, or an error message + */ + private validateCreatePaymentIntentParams( + amount: number, + currency: string, + metadata?: PaymentIntentMetadata, + ): string { + if (typeof amount === 'undefined') { + this.logger.warn( + 'createPaymentIntent called with invalid amount: ' + amount, + ); + return 'Invalid amount: amount needs to be defined'; + } + + if (!Number.isFinite(amount) || amount < 0) { + this.logger.warn( + 'createPaymentIntent called with invalid amount: ' + amount, + ); + return 'Invalid amount: must be a number >= 0'; + } + + if (amount % 1 !== 0) { + this.logger.warn('createPaymentIntent called with decimals: ' + amount); + return 'Invalid amount: amount is already in lowest currency unit, so there should be no decimals'; + } + + if (!currency || typeof currency !== 'string') { + this.logger.warn( + 'createPaymentIntent called with invalid currency: ' + currency, + ); + return 'Invalid currency'; + } + + if (currency === 'usd' && amount < 50) { + this.logger.warn( + 'createPaymentIntent called with less than 50 cents (USD): was called with ' + + amount, + ); + return 'Invalid amount, US currency donations must be at least 50 cents'; + } + + if (!/^[a-z]{3}$/i.test(currency)) { + this.logger.warn( + 'createPaymentIntent called with malformed currency: ' + currency, + ); + return 'Invalid currency format; expected 3-letter ISO code like "usd"'; + } + + if (metadata !== undefined && typeof metadata !== 'object') { + this.logger.warn('createPaymentIntent called with invalid metadata'); + return 'Invalid metadata'; + } + + return ''; + } + + /** + * Create a payment intent. + * + * @param amount number - amount in smallest currency unit e.g. cents (required, positive integer) + * @param currency string - ISO currency code, e.g. 'usd' (required) + * @param metadata object - optional key/value metadata to attach to the payment + * @returns Promise resolving to a PaymentIntent-like object + */ + async createPaymentIntent( + amount: number, + currency: string, + metadata?: PaymentIntentMetadata, + ): Promise { + if (currency) { + currency = currency.toLowerCase(); + } + const errorMsg = this.validateCreatePaymentIntentParams( + amount, + currency, + metadata, + ); + if (errorMsg !== '') { + throw new Error(errorMsg); + } + + try { + const paymentIntent: Stripe.PaymentIntent = + await this.stripe.paymentIntents.create({ + amount, + currency, + metadata, + payment_method_types: ['card', 'us_bank_accounts'], + }); + + this.logger.debug( + `createPaymentIntent (${amount}, ${currency}, ${metadata}) -> ${paymentIntent.id}`, + ); + + return this.mapPaymentIntentToResponse(paymentIntent); + } catch (error) { + this.logger.error(`Error retrieving payment intent: ${error.message}`); + throw error; + } + } + + /** + * Validates the parameters for a subscription. + * + * @param customerId string - ID of the customer in the payment provider + * @param priceId string - ID of the price/product to subscribe the customer to + * @param interval enum RecurringInterval - billing interval + * ( 'weekly' | 'monthly' | 'bimonthly' | 'quarterly' | 'annually') + * @returns string - either an empty string to signify good paramters, or an error message + */ + private validateCreateSubscriptionParams( + customerId: string, + priceId: string, + ): string { + const customerIdPattern = /^cus_[a-zA-Z0-9]{14,}$/; + const priceIdPattern = /^price_[a-zA-Z0-9]{14,}$/; + + if (!customerId || typeof customerId !== 'string') { + this.logger.warn('createSubscription called with invalid customerId'); + return 'Invalid customerId'; + } + + if (!priceId || typeof priceId !== 'string') { + this.logger.warn('createSubscription called with invalid priceId'); + return 'Invalid priceId'; + } + + if (!customerIdPattern.test(customerId)) { + return 'Invalid customerId format'; + } + + if (!priceIdPattern.test(priceId)) { + return 'Invalid priceId format'; + } + + return ''; + } + + /** + * Creates a subscription for a customer. + * + * @param customerId string - ID of the customer in the payment provider + * @param priceId string - ID of the price/product to subscribe the customer to + * @returns Promise resolving to a Subscription-like object + */ + async createSubscription( + customerId: string, + priceId: string, + ): Promise<{ + id: string; + customerId: string; + priceId: string; + interval: RecurringInterval; + status: string; + }> { + try { + const errorMsg = this.validateCreateSubscriptionParams( + customerId, + priceId, + ); + if (errorMsg !== '') { + throw new Error(errorMsg); + } + const subscription: Stripe.Subscription = + await this.stripe.subscriptions.create({ + customer: customerId, + items: [ + { + price: priceId, + }, + ], + }); + + this.logger.debug(`createSubscription (stub) -> ${subscription.id}`); + return { + id: subscription.id, + customerId: subscription.customer as string, + priceId: subscription.items.data[0].price.id, + interval: subscription.items.data[0].price.recurring + .interval as RecurringInterval, + status: subscription.status, + }; + } catch (error) { + this.logger.error(`Error creating subscription: ${error.message}`); + throw error; + } + } + + /** + * Validates the parameters for retrieve payment intent + * + * @param paymentIntentId + * @param status + */ + private validateRetrievePaymentIntentParams(paymentIntentId: string): string { + if (!paymentIntentId || typeof paymentIntentId !== 'string') { + this.logger.warn('retrievePaymentIntent called with invalid id'); + return 'Invalid paymentIntentId'; + } + return ''; + } + + /** + * Retrieve a payment intent by id. + * + * @param paymentIntentId string - provider payment intent id + * @returns Promise resolving to a PaymentIntent-like object + */ + async retrievePaymentIntent( + paymentIntentId: string, + ): Promise { + try { + const errorMsg = + this.validateRetrievePaymentIntentParams(paymentIntentId); + if (errorMsg !== '') { + throw new Error(errorMsg); + } + const paymentIntent: Stripe.PaymentIntent = + await this.stripe.paymentIntents.retrieve(paymentIntentId); + + return this.mapPaymentIntentToResponse(paymentIntent); + } catch (err) { + this.logger.error(`Error retrieving payment intent: ${err.message}`); + throw err; + } + } + + /** + * Maps a Stripe PaymentIntent status to one of the four DonationStatus enum values + * + * @param stripeStatus The status string from Stripe PaymentIntent + * @returns The corresponding DonationStatus enum value (PENDING, SUCCEEDED, FAILED, CANCELLED) + */ + private mapStripeStatusToDonationStatus( + stripeStatus: string, + ): DonationStatus { + switch (stripeStatus) { + case 'succeeded': + return DonationStatus.SUCCEEDED; + + case 'canceled': + return DonationStatus.CANCELLED; + + case 'requires_payment_method': + return DonationStatus.FAILED; + + case 'processing': + case 'requires_confirmation': + case 'requires_action': + case 'requires_capture': + return DonationStatus.PENDING; + + default: + return DonationStatus.PENDING; + } + } + + /** + * Maps Stripe API payment Intent to response returned by service methods for a payment intent + * + * @param paymentIntent the payment intent object returned directly by the stripe api + * @returns A PaymentIntentResponse object that is closer to data used in backend + */ + private mapPaymentIntentToResponse( + paymentIntent: Stripe.PaymentIntent, + ): PaymentIntentResponse { + return { + id: paymentIntent.id, + clientSecret: paymentIntent.client_secret, + amount: paymentIntent.amount, + currency: paymentIntent.currency, + status: this.mapStripeStatusToDonationStatus(paymentIntent.status), + metadata: paymentIntent.metadata, + paymentMethodId: paymentIntent.payment_method as string, + paymentMethodTypes: paymentIntent.payment_method_types, + created: paymentIntent.created, + requiresAction: paymentIntent.status === 'requires_action', + nextAction: paymentIntent.next_action, + lastPaymentError: paymentIntent.last_payment_error + ? { + code: paymentIntent.last_payment_error.code, + message: paymentIntent.last_payment_error.message, + type: paymentIntent.last_payment_error.type, + } + : undefined, + canceledAt: paymentIntent.canceled_at, + }; + } +} diff --git a/example.env b/example.env index 211b147..8051f66 100644 --- a/example.env +++ b/example.env @@ -2,4 +2,7 @@ NX_DB_HOST=localhost, NX_DB_USERNAME=postgres, NX_DB_PASSWORD=, NX_DB_DATABASE=jumpstart, -NX_DB_PORT=5432, \ No newline at end of file +NX_DB_PORT=5432, +STRIPE_SECRET_KEY=, +STRIPE_WEBHOOK_SECRET=, +STRIPE_PUBLISHABLE_KEY=, diff --git a/package.json b/package.json index 91112ed..b185165 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@aws-sdk/client-cognito-identity-provider": "^3.410.0", "@nestjs/cli": "^10.1.17", "@nestjs/common": "^10.0.2", + "@nestjs/config": "^4.0.2", "@nestjs/core": "^10.0.2", "@nestjs/passport": "^10.0.2", "@nestjs/platform-express": "^10.0.2", @@ -56,6 +57,7 @@ "react-router-dom": "^6.15.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.0", + "stripe": "^19.1.0", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typeorm": "^0.3.17" diff --git a/yarn.lock b/yarn.lock index d4b8cf7..b9a6efe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2525,6 +2525,15 @@ iterare "1.2.1" tslib "2.6.2" +"@nestjs/config@^4.0.2": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@nestjs/config/-/config-4.0.2.tgz#a2777a1fd2d0d594bab3953f50fbca95c14cce52" + integrity sha512-McMW6EXtpc8+CwTUwFdg6h7dYcBUpH5iUILCclAsa+MbCEvC9ZKu4dCHRlJqALuhjLw97pbQu62l4+wRwGeZqA== + dependencies: + dotenv "16.4.7" + dotenv-expand "12.0.1" + lodash "4.17.21" + "@nestjs/core@^10.0.2": version "10.2.7" resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-10.2.7.tgz#26ca5cc63504b54a08c4cdc6da9300c9b8904fde" @@ -5339,6 +5348,14 @@ cachedir@^2.3.0: resolved "https://registry.yarnpkg.com/cachedir/-/cachedir-2.4.0.tgz#7fef9cf7367233d7c88068fe6e34ed0d355a610d" integrity sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ== +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.4, call-bind@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.5.tgz#6fa2b7845ce0ea49bf4d8b9ef64727a2c2e2e513" @@ -5348,6 +5365,14 @@ call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.4, call-bind@^1.0.5: get-intrinsic "^1.2.1" set-function-length "^1.1.1" +call-bound@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + callsites@^3.0.0, callsites@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -6369,21 +6394,47 @@ dot-case@^3.0.4: no-case "^3.0.4" tslib "^2.0.3" +dotenv-expand@12.0.1: + version "12.0.1" + resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-12.0.1.tgz#44bdfa204a368100689ec35d7385755f599ceeb1" + integrity sha512-LaKRbou8gt0RNID/9RoI+J2rvXsBRPMV7p+ElHlPhcSARbCPDYcYG2s1TIzAfWv4YSgyY5taidWzzs31lNV3yQ== + dependencies: + dotenv "^16.4.5" + dotenv-expand@~10.0.0: version "10.0.0" resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-10.0.0.tgz#12605d00fb0af6d0a592e6558585784032e4ef37" integrity sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A== +dotenv@16.4.7: + version "16.4.7" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.7.tgz#0e20c5b82950140aa99be360a8a5f52335f53c26" + integrity sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ== + dotenv@^16.0.3, dotenv@~16.3.1: version "16.3.1" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e" integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ== +dotenv@^16.4.5: + version "16.6.1" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.6.1.tgz#773f0e69527a8315c7285d5ee73c4459d20a8020" + integrity sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow== + dotenv@~10.0.0: version "10.0.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81" integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q== +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + duplexer@^0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" @@ -6550,6 +6601,16 @@ es-abstract@^1.22.1: unbox-primitive "^1.0.2" which-typed-array "^1.1.13" +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + es-get-iterator@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.3.tgz#3ef87523c5d464d41084b2c3c9c214f1199763d6" @@ -6590,6 +6651,13 @@ es-module-lexer@^1.2.1: resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.3.1.tgz#c1b0dd5ada807a3b3155315911f364dc4e909db1" integrity sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q== +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + es-set-tostringtag@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz#11f7cc9f63376930a5f20be4915834f4bc74f9c9" @@ -7451,11 +7519,35 @@ get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@ has-symbols "^1.0.3" hasown "^2.0.0" +get-intrinsic@^1.2.5, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + get-package-type@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + get-stream@^5.0.0, get-stream@^5.1.0: version "5.2.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" @@ -7642,6 +7734,11 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" @@ -7699,6 +7796,11 @@ has-symbols@^1.0.2, has-symbols@^1.0.3: resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== +has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + has-tostringtag@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25" @@ -7718,6 +7820,13 @@ hasown@^2.0.0: dependencies: function-bind "^1.1.2" +hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + he@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" @@ -9743,6 +9852,11 @@ makeerror@1.0.12: dependencies: tmpl "1.0.5" +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + mdn-data@2.0.28: version "2.0.28" resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.28.tgz#5ec48e7bef120654539069e1ae4ddc81ca490eba" @@ -10243,6 +10357,11 @@ object-inspect@^1.13.1, object-inspect@^1.9.0: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2" integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ== +object-inspect@^1.13.3: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + object-is@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.5.tgz#b9deeaa5fc7f1846a0faecdceec138e5778f53ac" @@ -11218,6 +11337,13 @@ qs@6.11.0: dependencies: side-channel "^1.0.4" +qs@^6.11.0: + version "6.14.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.0.tgz#c63fa40680d2c5c941412a0e899c89af60c0a930" + integrity sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w== + dependencies: + side-channel "^1.1.0" + qs@^6.4.0: version "6.11.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.2.tgz#64bea51f12c1f5da1bc01496f48ffcff7c69d7d9" @@ -11847,6 +11973,35 @@ shelljs@0.8.5: interpret "^1.0.0" rechoir "^0.6.2" +side-channel-list@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" + integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + side-channel@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" @@ -11856,6 +12011,17 @@ side-channel@^1.0.4: get-intrinsic "^1.0.2" object-inspect "^1.9.0" +side-channel@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + siginfo@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" @@ -12208,6 +12374,13 @@ strip-literal@^1.0.1: dependencies: acorn "^8.10.0" +stripe@^19.1.0: + version "19.1.0" + resolved "https://registry.yarnpkg.com/stripe/-/stripe-19.1.0.tgz#c9dfe8f27c57262fbc4afd07cfb26f718cc1e903" + integrity sha512-FjgIiE98dMMTNssfdjMvFdD4eZyEzdWAOwPYqzhPRNZeg9ggFWlPXmX1iJKD5pPIwZBaPlC3SayQQkwsPo6/YQ== + dependencies: + qs "^6.11.0" + strnum@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.0.5.tgz#5c4e829fe15ad4ff0d20c3db5ac97b73c9b072db"