зеркало из https://github.com/mozilla/fxa.git
chore(libs): restructure stripe lib into responsibility-driven structure
This commit is contained in:
Родитель
915fcd55ec
Коммит
de2ad977c9
|
@ -24,11 +24,15 @@ import {
|
|||
ResultAccountCustomerFactory,
|
||||
StripeClient,
|
||||
StripeCustomerFactory,
|
||||
StripeManager,
|
||||
CustomerManager,
|
||||
StripePriceFactory,
|
||||
StripeResponseFactory,
|
||||
SubplatInterval,
|
||||
TaxAddressFactory,
|
||||
InvoiceManager,
|
||||
PriceManager,
|
||||
SubscriptionManager,
|
||||
PromotionCodeManager,
|
||||
} from '@fxa/payments/stripe';
|
||||
import {
|
||||
ContentfulClient,
|
||||
|
@ -69,7 +73,8 @@ describe('CartService', () => {
|
|||
let accountCustomerManager: AccountCustomerManager;
|
||||
let eligibilityService: EligibilityService;
|
||||
let geodbManager: GeoDBManager;
|
||||
let stripeManager: StripeManager;
|
||||
let customerManager: CustomerManager;
|
||||
let invoiceManager: InvoiceManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
|
@ -90,7 +95,7 @@ describe('CartService', () => {
|
|||
EligibilityService,
|
||||
MockStripeConfigProvider,
|
||||
StripeClient,
|
||||
StripeManager,
|
||||
CustomerManager,
|
||||
MockPaypalClientConfigProvider,
|
||||
PayPalClient,
|
||||
PayPalManager,
|
||||
|
@ -99,8 +104,12 @@ describe('CartService', () => {
|
|||
GeoDBManager,
|
||||
GeoDBManagerConfig,
|
||||
MockGeoDBNestFactory,
|
||||
StripeManager,
|
||||
CustomerManager,
|
||||
InvoiceManager,
|
||||
AccountManager,
|
||||
PriceManager,
|
||||
SubscriptionManager,
|
||||
PromotionCodeManager,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
@ -111,7 +120,8 @@ describe('CartService', () => {
|
|||
eligibilityService = moduleRef.get(EligibilityService);
|
||||
geodbManager = moduleRef.get(GeoDBManager);
|
||||
contentfulService = moduleRef.get(ContentfulService);
|
||||
stripeManager = moduleRef.get(StripeManager);
|
||||
customerManager = moduleRef.get(CustomerManager);
|
||||
invoiceManager = moduleRef.get(InvoiceManager);
|
||||
});
|
||||
|
||||
describe('setupCart', () => {
|
||||
|
@ -143,11 +153,9 @@ describe('CartService', () => {
|
|||
jest
|
||||
.spyOn(contentfulService, 'retrieveStripePlanId')
|
||||
.mockResolvedValue(mockPrice.id);
|
||||
jest.spyOn(customerManager, 'retrieve').mockResolvedValue(mockCustomer);
|
||||
jest
|
||||
.spyOn(stripeManager, 'fetchActiveCustomer')
|
||||
.mockResolvedValue(mockCustomer);
|
||||
jest
|
||||
.spyOn(stripeManager, 'previewInvoice')
|
||||
.spyOn(invoiceManager, 'preview')
|
||||
.mockResolvedValue(mockInvoicePreview);
|
||||
jest.spyOn(cartManager, 'createCart').mockResolvedValue(mockResultCart);
|
||||
|
||||
|
@ -403,11 +411,9 @@ describe('CartService', () => {
|
|||
jest
|
||||
.spyOn(contentfulService, 'retrieveStripePlanId')
|
||||
.mockResolvedValue(mockPrice.id);
|
||||
jest.spyOn(customerManager, 'retrieve').mockResolvedValue(mockCustomer);
|
||||
jest
|
||||
.spyOn(stripeManager, 'fetchActiveCustomer')
|
||||
.mockResolvedValue(mockCustomer);
|
||||
jest
|
||||
.spyOn(stripeManager, 'previewInvoice')
|
||||
.spyOn(invoiceManager, 'preview')
|
||||
.mockResolvedValue(mockInvoicePreview);
|
||||
|
||||
const result = await cartService.getCart(mockCart.id);
|
||||
|
@ -421,10 +427,10 @@ describe('CartService', () => {
|
|||
mockCart.offeringConfigId,
|
||||
mockCart.interval
|
||||
);
|
||||
expect(stripeManager.fetchActiveCustomer).toHaveBeenCalledWith(
|
||||
expect(customerManager.retrieve).toHaveBeenCalledWith(
|
||||
mockCart.stripeCustomerId
|
||||
);
|
||||
expect(stripeManager.previewInvoice).toHaveBeenCalledWith({
|
||||
expect(invoiceManager.preview).toHaveBeenCalledWith({
|
||||
priceId: mockPrice.id,
|
||||
customer: mockCustomer,
|
||||
taxAddress: mockCart.taxAddress,
|
||||
|
@ -442,9 +448,9 @@ describe('CartService', () => {
|
|||
jest
|
||||
.spyOn(contentfulService, 'retrieveStripePlanId')
|
||||
.mockResolvedValue(mockPrice.id);
|
||||
jest.spyOn(stripeManager, 'fetchActiveCustomer');
|
||||
jest.spyOn(customerManager, 'retrieve');
|
||||
jest
|
||||
.spyOn(stripeManager, 'previewInvoice')
|
||||
.spyOn(invoiceManager, 'preview')
|
||||
.mockResolvedValue(mockInvoicePreview);
|
||||
|
||||
const result = await cartService.getCart(mockCart.id);
|
||||
|
@ -458,8 +464,8 @@ describe('CartService', () => {
|
|||
mockCart.offeringConfigId,
|
||||
mockCart.interval
|
||||
);
|
||||
expect(stripeManager.fetchActiveCustomer).not.toHaveBeenCalled();
|
||||
expect(stripeManager.previewInvoice).toHaveBeenCalledWith({
|
||||
expect(customerManager.retrieve).not.toHaveBeenCalled();
|
||||
expect(invoiceManager.preview).toHaveBeenCalledWith({
|
||||
priceId: mockPrice.id,
|
||||
customer: undefined,
|
||||
taxAddress: mockCart.taxAddress,
|
||||
|
|
|
@ -8,8 +8,9 @@ import { EligibilityService } from '@fxa/payments/eligibility';
|
|||
import {
|
||||
AccountCustomerManager,
|
||||
AccountCustomerNotFoundError,
|
||||
CustomerManager,
|
||||
InvoiceManager,
|
||||
StripeCustomer,
|
||||
StripeManager,
|
||||
SubplatInterval,
|
||||
TaxAddress,
|
||||
} from '@fxa/payments/stripe';
|
||||
|
@ -36,7 +37,8 @@ export class CartService {
|
|||
private geodbManager: GeoDBManager,
|
||||
private checkoutService: CheckoutService,
|
||||
private contentfulService: ContentfulService,
|
||||
private stripeManager: StripeManager
|
||||
private customerManager: CustomerManager,
|
||||
private invoiceManager: InvoiceManager
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
@ -70,7 +72,7 @@ export class CartService {
|
|||
|
||||
const stripeCustomerId = accountCustomer?.stripeCustomerId;
|
||||
const stripeCustomer = stripeCustomerId
|
||||
? await this.stripeManager.fetchActiveCustomer(stripeCustomerId)
|
||||
? await this.customerManager.retrieve(stripeCustomerId)
|
||||
: undefined;
|
||||
|
||||
const taxAddress = args.ip
|
||||
|
@ -82,7 +84,7 @@ export class CartService {
|
|||
args.interval
|
||||
);
|
||||
|
||||
const upcomingInvoice = await this.stripeManager.previewInvoice({
|
||||
const upcomingInvoice = await this.invoiceManager.preview({
|
||||
priceId: priceId,
|
||||
customer: stripeCustomer,
|
||||
taxAddress: taxAddress,
|
||||
|
@ -223,12 +225,10 @@ export class CartService {
|
|||
|
||||
let customer: StripeCustomer | undefined;
|
||||
if (cart.stripeCustomerId) {
|
||||
customer = await this.stripeManager.fetchActiveCustomer(
|
||||
cart.stripeCustomerId
|
||||
);
|
||||
customer = await this.customerManager.retrieve(cart.stripeCustomerId);
|
||||
}
|
||||
|
||||
const invoicePreview = await this.stripeManager.previewInvoice({
|
||||
const invoicePreview = await this.invoiceManager.preview({
|
||||
priceId,
|
||||
customer,
|
||||
taxAddress: cart.taxAddress as unknown as TaxAddress, // TODO: Fix the typings for taxAddress
|
||||
|
|
|
@ -11,7 +11,6 @@ import {
|
|||
StripeConfig,
|
||||
StripeCustomerFactory,
|
||||
StripeInvoiceFactory,
|
||||
StripeManager,
|
||||
StripePaymentIntentFactory,
|
||||
StripePaymentMethodFactory,
|
||||
StripePriceFactory,
|
||||
|
@ -22,6 +21,11 @@ import {
|
|||
InvoicePreviewFactory,
|
||||
AccountCustomerManager,
|
||||
ResultAccountCustomerFactory,
|
||||
CustomerManager,
|
||||
SubscriptionManager,
|
||||
InvoiceManager,
|
||||
PromotionCodeManager,
|
||||
PriceManager,
|
||||
} from '@fxa/payments/stripe';
|
||||
import {
|
||||
PayPalClient,
|
||||
|
@ -64,7 +68,10 @@ import { AccountManager } from '@fxa/shared/account/account';
|
|||
describe('CheckoutService', () => {
|
||||
let checkoutService: CheckoutService;
|
||||
let stripeClient: StripeClient;
|
||||
let stripeManager: StripeManager;
|
||||
let customerManager: CustomerManager;
|
||||
let subscriptionManager: SubscriptionManager;
|
||||
let invoiceManager: InvoiceManager;
|
||||
let promotionCodeManager: PromotionCodeManager;
|
||||
let paypalCustomerManager: PaypalCustomerManager;
|
||||
let paypalManager: PayPalManager;
|
||||
let cartManager: CartManager;
|
||||
|
@ -77,7 +84,11 @@ describe('CheckoutService', () => {
|
|||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
StripeClient,
|
||||
StripeManager,
|
||||
CustomerManager,
|
||||
SubscriptionManager,
|
||||
InvoiceManager,
|
||||
PromotionCodeManager,
|
||||
PriceManager,
|
||||
PaypalCustomerManager,
|
||||
PayPalManager,
|
||||
CheckoutService,
|
||||
|
@ -106,7 +117,10 @@ describe('CheckoutService', () => {
|
|||
paypalCustomerManager = moduleRef.get(PaypalCustomerManager);
|
||||
paypalManager = moduleRef.get(PayPalManager);
|
||||
stripeClient = moduleRef.get(StripeClient);
|
||||
stripeManager = moduleRef.get(StripeManager);
|
||||
customerManager = moduleRef.get(CustomerManager);
|
||||
subscriptionManager = moduleRef.get(SubscriptionManager);
|
||||
invoiceManager = moduleRef.get(InvoiceManager);
|
||||
promotionCodeManager = moduleRef.get(PromotionCodeManager);
|
||||
contentfulService = moduleRef.get(ContentfulService);
|
||||
accountManager = moduleRef.get(AccountManager);
|
||||
accountCustomerManager = moduleRef.get(AccountCustomerManager);
|
||||
|
@ -160,12 +174,8 @@ describe('CheckoutService', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
jest.spyOn(accountManager, 'createAccountStub').mockResolvedValue(uid);
|
||||
jest
|
||||
.spyOn(stripeManager, 'createPlainCustomer')
|
||||
.mockResolvedValue(mockCustomer);
|
||||
jest
|
||||
.spyOn(stripeManager, 'fetchActiveCustomer')
|
||||
.mockResolvedValue(mockCustomer);
|
||||
jest.spyOn(customerManager, 'create').mockResolvedValue(mockCustomer);
|
||||
jest.spyOn(customerManager, 'retrieve').mockResolvedValue(mockCustomer);
|
||||
jest
|
||||
.spyOn(accountCustomerManager, 'createAccountCustomer')
|
||||
.mockResolvedValue(mockAccountCustomer);
|
||||
|
@ -177,13 +187,13 @@ describe('CheckoutService', () => {
|
|||
.spyOn(contentfulService, 'retrieveStripePlanId')
|
||||
.mockResolvedValue(mockPrice.id);
|
||||
jest
|
||||
.spyOn(stripeManager, 'previewInvoice')
|
||||
.spyOn(invoiceManager, 'preview')
|
||||
.mockResolvedValue(mockInvoicePreview);
|
||||
jest
|
||||
.spyOn(stripeManager, 'cancelIncompleteSubscriptionsToPrice')
|
||||
.spyOn(subscriptionManager, 'cancelIncompleteSubscriptionsToPrice')
|
||||
.mockResolvedValue();
|
||||
jest
|
||||
.spyOn(stripeManager, 'getPromotionCodeByName')
|
||||
.spyOn(promotionCodeManager, 'retrieveByName')
|
||||
.mockResolvedValue(mockPromotionCode);
|
||||
});
|
||||
|
||||
|
@ -193,7 +203,7 @@ describe('CheckoutService', () => {
|
|||
});
|
||||
|
||||
it('fetches the customer', () => {
|
||||
expect(stripeManager.fetchActiveCustomer).toHaveBeenCalledWith(
|
||||
expect(customerManager.retrieve).toHaveBeenCalledWith(
|
||||
mockCart.stripeCustomerId
|
||||
);
|
||||
});
|
||||
|
@ -216,16 +226,16 @@ describe('CheckoutService', () => {
|
|||
|
||||
it('checks that customer does not have existing subscription to price', () => {
|
||||
expect(
|
||||
stripeManager.cancelIncompleteSubscriptionsToPrice
|
||||
subscriptionManager.cancelIncompleteSubscriptionsToPrice
|
||||
).toHaveBeenCalledWith(mockCustomer.id, mockPrice.id);
|
||||
});
|
||||
|
||||
it('checks if customer is stripe tax eligible', async () => {
|
||||
expect(stripeManager.isCustomerStripeTaxEligible).toBeTruthy();
|
||||
expect(customerManager.isTaxEligible).toBeTruthy();
|
||||
});
|
||||
|
||||
it('fetches promotion code by name', async () => {
|
||||
expect(stripeManager.getPromotionCodeByName).toHaveBeenCalledWith(
|
||||
expect(promotionCodeManager.retrieveByName).toHaveBeenCalledWith(
|
||||
mockCart.couponCode,
|
||||
true
|
||||
);
|
||||
|
@ -291,7 +301,7 @@ describe('CheckoutService', () => {
|
|||
});
|
||||
|
||||
it('creates a new stripe customer stub account', () => {
|
||||
expect(stripeManager.createPlainCustomer).toHaveBeenCalledWith({
|
||||
expect(customerManager.create).toHaveBeenCalledWith({
|
||||
uid: uid,
|
||||
email: mockCart.email,
|
||||
displayName: mockCustomerData.displayName,
|
||||
|
@ -403,7 +413,7 @@ describe('CheckoutService', () => {
|
|||
.spyOn(stripeClient, 'paymentIntentRetrieve')
|
||||
.mockResolvedValue(mockPaymentIntent);
|
||||
jest
|
||||
.spyOn(stripeManager, 'cancelSubscription')
|
||||
.spyOn(subscriptionManager, 'cancel')
|
||||
.mockResolvedValue(mockSubscription);
|
||||
});
|
||||
|
||||
|
@ -471,7 +481,7 @@ describe('CheckoutService', () => {
|
|||
});
|
||||
|
||||
it('does not cancel the subscription', () => {
|
||||
expect(stripeManager.cancelSubscription).not.toHaveBeenCalled();
|
||||
expect(subscriptionManager.cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -524,7 +534,7 @@ describe('CheckoutService', () => {
|
|||
.mockResolvedValue(mockInvoice);
|
||||
jest.spyOn(paypalManager, 'processInvoice').mockResolvedValue();
|
||||
jest
|
||||
.spyOn(stripeManager, 'cancelSubscription')
|
||||
.spyOn(subscriptionManager, 'cancel')
|
||||
.mockResolvedValue(mockSubscription);
|
||||
jest.spyOn(paypalManager, 'cancelBillingAgreement').mockResolvedValue();
|
||||
});
|
||||
|
@ -595,7 +605,7 @@ describe('CheckoutService', () => {
|
|||
});
|
||||
|
||||
it('does not cancel the subscription', () => {
|
||||
expect(stripeManager.cancelSubscription).not.toHaveBeenCalled();
|
||||
expect(subscriptionManager.cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not cancel the billing agreement', () => {
|
||||
|
|
|
@ -7,10 +7,13 @@ import { EligibilityService } from '@fxa/payments/eligibility';
|
|||
import { PayPalManager, PaypalCustomerManager } from '@fxa/payments/paypal';
|
||||
import {
|
||||
AccountCustomerManager,
|
||||
CustomerManager,
|
||||
InvoiceManager,
|
||||
PromotionCodeManager,
|
||||
StripeClient,
|
||||
StripeManager,
|
||||
StripeSubscription,
|
||||
SubplatInterval,
|
||||
SubscriptionManager,
|
||||
TaxAddress,
|
||||
} from '@fxa/payments/stripe';
|
||||
import { CheckoutError, CheckoutPaymentError } from './checkout.error';
|
||||
|
@ -29,7 +32,10 @@ import { AccountManager } from '@fxa/shared/account/account';
|
|||
export class CheckoutService {
|
||||
constructor(
|
||||
private stripeClient: StripeClient,
|
||||
private stripeManager: StripeManager,
|
||||
private customerManager: CustomerManager,
|
||||
private subscriptionManager: SubscriptionManager,
|
||||
private invoiceManager: InvoiceManager,
|
||||
private promotionCodeManager: PromotionCodeManager,
|
||||
private paypalCustomerManager: PaypalCustomerManager,
|
||||
private paypalManager: PayPalManager,
|
||||
private cartManager: CartManager,
|
||||
|
@ -62,7 +68,7 @@ export class CheckoutService {
|
|||
|
||||
// if stripeCustomerId not found, create plain stripe account
|
||||
if (!cart.stripeCustomerId) {
|
||||
customer = await this.stripeManager.createPlainCustomer({
|
||||
customer = await this.customerManager.create({
|
||||
uid,
|
||||
email: cart.email,
|
||||
displayName: customerData.displayName,
|
||||
|
@ -72,7 +78,7 @@ export class CheckoutService {
|
|||
stripeCustomerId = customer.id;
|
||||
} else {
|
||||
stripeCustomerId = cart.stripeCustomerId;
|
||||
customer = await this.stripeManager.fetchActiveCustomer(stripeCustomerId);
|
||||
customer = await this.customerManager.retrieve(stripeCustomerId);
|
||||
}
|
||||
|
||||
// create accountCustomer if it does not exist
|
||||
|
@ -119,7 +125,7 @@ export class CheckoutService {
|
|||
cart.interval as SubplatInterval
|
||||
);
|
||||
|
||||
const upcomingInvoice = await this.stripeManager.previewInvoice({
|
||||
const upcomingInvoice = await this.invoiceManager.preview({
|
||||
priceId: priceId,
|
||||
customer: customer,
|
||||
taxAddress: taxAddress,
|
||||
|
@ -134,16 +140,15 @@ export class CheckoutService {
|
|||
}
|
||||
|
||||
// check if customer already has subscription to price and cancel if they do
|
||||
await this.stripeManager.cancelIncompleteSubscriptionsToPrice(
|
||||
await this.subscriptionManager.cancelIncompleteSubscriptionsToPrice(
|
||||
stripeCustomerId,
|
||||
priceId
|
||||
);
|
||||
|
||||
const enableAutomaticTax =
|
||||
this.stripeManager.isCustomerStripeTaxEligible(customer);
|
||||
const enableAutomaticTax = this.customerManager.isTaxEligible(customer);
|
||||
|
||||
const promotionCode = cart.couponCode
|
||||
? await this.stripeManager.getPromotionCodeByName(cart.couponCode, true)
|
||||
? await this.promotionCodeManager.retrieveByName(cart.couponCode, true)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
|
@ -197,7 +202,7 @@ export class CheckoutService {
|
|||
// TODO: Generate and use idempotency key using util
|
||||
});
|
||||
|
||||
const paymentIntent = await this.stripeManager.getLatestPaymentIntent(
|
||||
const paymentIntent = await this.subscriptionManager.getLatestPaymentIntent(
|
||||
subscription
|
||||
);
|
||||
if (!paymentIntent) {
|
||||
|
@ -212,7 +217,7 @@ export class CheckoutService {
|
|||
}
|
||||
|
||||
if (paymentIntent.last_payment_error) {
|
||||
await this.stripeManager.cancelSubscription(subscription.id);
|
||||
await this.subscriptionManager.cancel(subscription.id);
|
||||
|
||||
throw new CheckoutPaymentError(
|
||||
'Checkout payment intent has error on payment attempt',
|
||||
|
@ -281,7 +286,7 @@ export class CheckoutService {
|
|||
try {
|
||||
this.paypalManager.processInvoice(latestInvoice);
|
||||
} catch (e) {
|
||||
await this.stripeManager.cancelSubscription(subscription.id);
|
||||
await this.subscriptionManager.cancel(subscription.id);
|
||||
await this.paypalManager.cancelBillingAgreement(billingAgreementId);
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsObject } from 'class-validator';
|
||||
import { Provider } from '@nestjs/common';
|
||||
|
||||
export class CurrencyConfig {
|
||||
@Transform(
|
||||
({ value }) => (value instanceof Object ? value : JSON.parse(value)),
|
||||
{ toClassOnly: true }
|
||||
)
|
||||
@IsObject()
|
||||
public readonly taxIds!: { [key: string]: string };
|
||||
}
|
||||
|
||||
export const MockCurrencyConfig = {
|
||||
taxIds: { EUR: 'EU1234' },
|
||||
} satisfies CurrencyConfig;
|
||||
|
||||
export const MockCurrencyConfigProvider = {
|
||||
provide: CurrencyConfig,
|
||||
useValue: MockCurrencyConfig,
|
||||
} satisfies Provider<CurrencyConfig>;
|
|
@ -11,16 +11,19 @@ import {
|
|||
CurrencyCountryMismatchError,
|
||||
} from './currency.error';
|
||||
import { CURRENCIES_TO_COUNTRIES } from './currency.constants';
|
||||
import { CurrencyConfig, MockCurrencyConfigProvider } from './currency.config';
|
||||
|
||||
describe('CurrencyManager', () => {
|
||||
let currencyManager: CurrencyManager;
|
||||
let mockCurrencyConfig: CurrencyConfig;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [CurrencyManager],
|
||||
providers: [MockCurrencyConfigProvider, CurrencyManager],
|
||||
}).compile();
|
||||
|
||||
currencyManager = module.get(CurrencyManager);
|
||||
mockCurrencyConfig = module.get(CurrencyConfig);
|
||||
});
|
||||
|
||||
describe('assertCurrencyCompatibleWithCountry', () => {
|
||||
|
@ -64,4 +67,18 @@ describe('CurrencyManager', () => {
|
|||
).toThrow(CurrencyCountryMismatchError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTaxId', () => {
|
||||
it('returns the correct tax id for currency', async () => {
|
||||
const mockCurrency = Object.entries(mockCurrencyConfig.taxIds)[0];
|
||||
|
||||
const result = currencyManager.getTaxId(mockCurrency[0]);
|
||||
expect(result).toEqual(mockCurrency[1]);
|
||||
});
|
||||
|
||||
it('returns empty string when no tax id found', async () => {
|
||||
const result = currencyManager.getTaxId('DOES NOT EXIST');
|
||||
expect(result).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -14,10 +14,14 @@ import {
|
|||
CountryCodeInvalidError,
|
||||
CurrencyCountryMismatchError,
|
||||
} from './currency.error';
|
||||
import { CurrencyConfig } from './currency.config';
|
||||
|
||||
@Injectable()
|
||||
export class CurrencyManager {
|
||||
constructor() {}
|
||||
private taxIds: { [key: string]: string };
|
||||
constructor(private config: CurrencyConfig) {
|
||||
this.taxIds = this.config.taxIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that provided source country and plan currency are compatible with
|
||||
|
@ -52,4 +56,8 @@ export class CurrencyManager {
|
|||
throw new CurrencyCountryMismatchError(currency, country);
|
||||
}
|
||||
}
|
||||
|
||||
getTaxId(currency: string) {
|
||||
return this.taxIds[currency.toUpperCase()];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import {
|
||||
StripeManager,
|
||||
PriceManager,
|
||||
StripePlan,
|
||||
StripePlanFactory,
|
||||
SubplatInterval,
|
||||
|
@ -34,7 +34,7 @@ describe('EligibilityManager', () => {
|
|||
let mockContentfulManager: ContentfulManager;
|
||||
let mockOfferingResult: EligibilityContentByOfferingResultUtil;
|
||||
let mockResult: EligibilityContentByPlanIdsResultUtil;
|
||||
let mockStripeManager: StripeManager;
|
||||
let mockPriceManager: PriceManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockOfferingResult = {} as EligibilityContentByOfferingResultUtil;
|
||||
|
@ -47,14 +47,14 @@ describe('EligibilityManager', () => {
|
|||
.fn()
|
||||
.mockResolvedValueOnce(mockResult),
|
||||
} as any;
|
||||
mockStripeManager = {
|
||||
getPlanByInterval: jest.fn(),
|
||||
mockPriceManager = {
|
||||
retrieveByInterval: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
{ provide: ContentfulManager, useValue: mockContentfulManager },
|
||||
{ provide: StripeManager, useValue: mockStripeManager },
|
||||
{ provide: PriceManager, useValue: mockPriceManager },
|
||||
EligibilityManager,
|
||||
],
|
||||
}).compile();
|
||||
|
@ -624,7 +624,7 @@ describe('EligibilityManager', () => {
|
|||
});
|
||||
const interval = SubplatInterval.Monthly;
|
||||
|
||||
mockStripeManager.getPlanByInterval = jest
|
||||
mockPriceManager.retrieveByInterval = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(mockPlan2);
|
||||
|
||||
|
@ -658,7 +658,7 @@ describe('EligibilityManager', () => {
|
|||
},
|
||||
] as OfferingOverlapProductResult[];
|
||||
|
||||
mockStripeManager.getPlanByInterval = jest
|
||||
mockPriceManager.retrieveByInterval = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(mockPlan2);
|
||||
|
||||
|
@ -692,7 +692,7 @@ describe('EligibilityManager', () => {
|
|||
},
|
||||
] as OfferingOverlapProductResult[];
|
||||
|
||||
mockStripeManager.getPlanByInterval = jest
|
||||
mockPriceManager.retrieveByInterval = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(mockPlan2);
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
StripeManager,
|
||||
PriceManager,
|
||||
StripePlan,
|
||||
SubplatInterval,
|
||||
} from '@fxa/payments/stripe';
|
||||
|
@ -27,7 +27,7 @@ import { intervalComparison, offeringComparison } from './utils';
|
|||
export class EligibilityManager {
|
||||
constructor(
|
||||
private contentfulManager: ContentfulManager,
|
||||
private stripeManager: StripeManager
|
||||
private priceManager: PriceManager
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
@ -116,7 +116,7 @@ export class EligibilityManager {
|
|||
return EligibilityStatus.DOWNGRADE;
|
||||
|
||||
const targetPlanIds = targetOffering.defaultPurchase.stripePlanChoices;
|
||||
const targetPlan = await this.stripeManager.getPlanByInterval(
|
||||
const targetPlan = await this.priceManager.retrieveByInterval(
|
||||
targetPlanIds,
|
||||
interval
|
||||
);
|
||||
|
|
|
@ -15,9 +15,10 @@ import {
|
|||
StripeClient,
|
||||
StripeConfig,
|
||||
StripeCustomerFactory,
|
||||
StripeManager,
|
||||
SubscriptionManager,
|
||||
StripeSubscriptionFactory,
|
||||
SubplatInterval,
|
||||
PriceManager,
|
||||
} from '@fxa/payments/stripe';
|
||||
import {
|
||||
ContentfulClient,
|
||||
|
@ -41,7 +42,7 @@ describe('EligibilityService', () => {
|
|||
let contentfulManager: ContentfulManager;
|
||||
let eligibilityManager: EligibilityManager;
|
||||
let eligibilityService: EligibilityService;
|
||||
let stripeManager: StripeManager;
|
||||
let subscriptionManager: SubscriptionManager;
|
||||
|
||||
let mockOfferingResult: EligibilityContentByOfferingResultUtil;
|
||||
|
||||
|
@ -55,10 +56,11 @@ describe('EligibilityService', () => {
|
|||
ContentfulClient,
|
||||
MockStatsDProvider,
|
||||
ContentfulManager,
|
||||
EligibilityManager,
|
||||
StripeConfig,
|
||||
StripeClient,
|
||||
StripeManager,
|
||||
SubscriptionManager,
|
||||
PriceManager,
|
||||
EligibilityManager,
|
||||
EligibilityService,
|
||||
],
|
||||
}).compile();
|
||||
|
@ -66,7 +68,7 @@ describe('EligibilityService', () => {
|
|||
contentfulManager = module.get<ContentfulManager>(ContentfulManager);
|
||||
eligibilityManager = module.get<EligibilityManager>(EligibilityManager);
|
||||
eligibilityService = module.get<EligibilityService>(EligibilityService);
|
||||
stripeManager = module.get<StripeManager>(StripeManager);
|
||||
subscriptionManager = module.get<SubscriptionManager>(SubscriptionManager);
|
||||
});
|
||||
|
||||
describe('checkEligibility', () => {
|
||||
|
@ -124,7 +126,7 @@ describe('EligibilityService', () => {
|
|||
mockOfferingResult.getOffering = jest.fn().mockReturnValue(mockOffering);
|
||||
|
||||
jest
|
||||
.spyOn(stripeManager, 'getSubscriptions')
|
||||
.spyOn(subscriptionManager, 'listForCustomer')
|
||||
.mockResolvedValue([mockSubscription]);
|
||||
|
||||
mockStripeUtil.getSubscribedPlans.mockReturnValue([]);
|
||||
|
@ -147,7 +149,7 @@ describe('EligibilityService', () => {
|
|||
expect(
|
||||
contentfulManager.getEligibilityContentByOffering
|
||||
).toHaveBeenCalledWith(mockOffering.apiIdentifier);
|
||||
expect(stripeManager.getSubscriptions).toHaveBeenCalledWith(
|
||||
expect(subscriptionManager.listForCustomer).toHaveBeenCalledWith(
|
||||
mockCustomer.id
|
||||
);
|
||||
expect(eligibilityManager.getProductIdOverlap).toHaveBeenCalledWith(
|
||||
|
|
|
@ -6,7 +6,7 @@ import { Injectable } from '@nestjs/common';
|
|||
import {
|
||||
getSubscribedPlans,
|
||||
getSubscribedProductIds,
|
||||
StripeManager,
|
||||
SubscriptionManager,
|
||||
SubplatInterval,
|
||||
} from '@fxa/payments/stripe';
|
||||
import { ContentfulManager } from '@fxa/shared/contentful';
|
||||
|
@ -18,7 +18,7 @@ export class EligibilityService {
|
|||
constructor(
|
||||
private contentfulManager: ContentfulManager,
|
||||
private eligibilityManager: EligibilityManager,
|
||||
private stripeManager: StripeManager
|
||||
private subscriptionManager: SubscriptionManager
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
@ -40,7 +40,7 @@ export class EligibilityService {
|
|||
|
||||
const targetOffering = targetOfferingResult.getOffering();
|
||||
|
||||
const subscriptions = await this.stripeManager.getSubscriptions(
|
||||
const subscriptions = await this.subscriptionManager.listForCustomer(
|
||||
stripeCustomerId
|
||||
);
|
||||
|
||||
|
|
|
@ -5,13 +5,15 @@ import { faker } from '@faker-js/faker';
|
|||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import {
|
||||
CustomerManager,
|
||||
InvoiceManager,
|
||||
MockStripeConfigProvider,
|
||||
StripeClient,
|
||||
StripeCustomerFactory,
|
||||
StripeInvoiceFactory,
|
||||
StripeManager,
|
||||
StripeResponseFactory,
|
||||
StripeSubscriptionFactory,
|
||||
SubscriptionManager,
|
||||
} from '@fxa/payments/stripe';
|
||||
import { MockAccountDatabaseNestFactory } from '@fxa/shared/db/mysql/account';
|
||||
|
||||
|
@ -35,8 +37,10 @@ import { MockPaypalClientConfigProvider } from './paypal.client.config';
|
|||
describe('PayPalManager', () => {
|
||||
let paypalManager: PayPalManager;
|
||||
let paypalClient: PayPalClient;
|
||||
let stripeManager: StripeManager;
|
||||
let paypalCustomerManager: PaypalCustomerManager;
|
||||
let customerManager: CustomerManager;
|
||||
let subscriptionManager: SubscriptionManager;
|
||||
let invoiceManager: InvoiceManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
|
@ -47,14 +51,18 @@ describe('PayPalManager', () => {
|
|||
PayPalManager,
|
||||
PayPalClient,
|
||||
StripeClient,
|
||||
StripeManager,
|
||||
CustomerManager,
|
||||
SubscriptionManager,
|
||||
InvoiceManager,
|
||||
PaypalCustomerManager,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
paypalManager = moduleRef.get(PayPalManager);
|
||||
paypalClient = moduleRef.get(PayPalClient);
|
||||
stripeManager = moduleRef.get(StripeManager);
|
||||
customerManager = moduleRef.get(CustomerManager);
|
||||
subscriptionManager = moduleRef.get(SubscriptionManager);
|
||||
invoiceManager = moduleRef.get(InvoiceManager);
|
||||
paypalCustomerManager = moduleRef.get(PaypalCustomerManager);
|
||||
});
|
||||
|
||||
|
@ -320,7 +328,7 @@ describe('PayPalManager', () => {
|
|||
const expected = [mockPayPalSubscription];
|
||||
|
||||
jest
|
||||
.spyOn(stripeManager, 'getSubscriptions')
|
||||
.spyOn(subscriptionManager, 'listForCustomer')
|
||||
.mockResolvedValue([mockPayPalSubscription]);
|
||||
|
||||
const result = await paypalManager.getCustomerPayPalSubscriptions(
|
||||
|
@ -333,7 +341,9 @@ describe('PayPalManager', () => {
|
|||
it('returns empty array when no subscriptions', async () => {
|
||||
const mockCustomer = StripeCustomerFactory();
|
||||
|
||||
jest.spyOn(stripeManager, 'getSubscriptions').mockResolvedValueOnce([]);
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'listForCustomer')
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await paypalManager.getCustomerPayPalSubscriptions(
|
||||
mockCustomer.id
|
||||
|
@ -367,13 +377,13 @@ describe('PayPalManager', () => {
|
|||
const mockInvoice = StripeResponseFactory(StripeInvoiceFactory());
|
||||
|
||||
jest
|
||||
.spyOn(stripeManager, 'finalizeInvoiceWithoutAutoAdvance')
|
||||
.spyOn(invoiceManager, 'finalizeWithoutAutoAdvance')
|
||||
.mockResolvedValue(mockInvoice);
|
||||
|
||||
const result = await paypalManager.processZeroInvoice(mockInvoice.id);
|
||||
|
||||
expect(result).toEqual(mockInvoice);
|
||||
expect(stripeManager.finalizeInvoiceWithoutAutoAdvance).toBeCalledWith(
|
||||
expect(invoiceManager.finalizeWithoutAutoAdvance).toBeCalledWith(
|
||||
mockInvoice.id
|
||||
);
|
||||
});
|
||||
|
@ -388,7 +398,7 @@ describe('PayPalManager', () => {
|
|||
})
|
||||
);
|
||||
|
||||
jest.spyOn(stripeManager, 'getMinimumAmount').mockReturnValue(10);
|
||||
jest.spyOn(subscriptionManager, 'getMinimumAmount').mockReturnValue(10);
|
||||
jest
|
||||
.spyOn(paypalManager, 'processZeroInvoice')
|
||||
.mockResolvedValue(mockInvoice);
|
||||
|
@ -406,10 +416,8 @@ describe('PayPalManager', () => {
|
|||
currency: 'usd',
|
||||
});
|
||||
|
||||
jest.spyOn(stripeManager, 'getMinimumAmount').mockReturnValue(10);
|
||||
jest
|
||||
.spyOn(stripeManager, 'fetchActiveCustomer')
|
||||
.mockResolvedValue(mockCustomer);
|
||||
jest.spyOn(subscriptionManager, 'getMinimumAmount').mockReturnValue(10);
|
||||
jest.spyOn(customerManager, 'retrieve').mockResolvedValue(mockCustomer);
|
||||
jest
|
||||
.spyOn(paypalManager, 'processZeroInvoice')
|
||||
.mockResolvedValue(StripeResponseFactory(mockInvoice));
|
||||
|
|
|
@ -6,9 +6,11 @@ import { Injectable } from '@nestjs/common';
|
|||
|
||||
import {
|
||||
ACTIVE_SUBSCRIPTION_STATUSES,
|
||||
CustomerManager,
|
||||
InvoiceManager,
|
||||
StripeCustomer,
|
||||
StripeInvoice,
|
||||
StripeManager,
|
||||
SubscriptionManager,
|
||||
} from '@fxa/payments/stripe';
|
||||
import { PayPalClient } from './paypal.client';
|
||||
import { BillingAgreement, BillingAgreementStatus } from './paypal.types';
|
||||
|
@ -21,7 +23,9 @@ import { PaypalManagerError } from './paypal.error';
|
|||
export class PayPalManager {
|
||||
constructor(
|
||||
private client: PayPalClient,
|
||||
private stripeManager: StripeManager,
|
||||
private customerManager: CustomerManager,
|
||||
private subscriptionManager: SubscriptionManager,
|
||||
private invoiceManager: InvoiceManager,
|
||||
private paypalCustomerManager: PaypalCustomerManager
|
||||
) {}
|
||||
|
||||
|
@ -122,11 +126,11 @@ export class PayPalManager {
|
|||
return firstRecord.billingAgreementId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves PayPal subscriptions
|
||||
*/
|
||||
// TODO: This should be moved to the subscription manager
|
||||
async getCustomerPayPalSubscriptions(customerId: string) {
|
||||
const subscriptions = await this.stripeManager.getSubscriptions(customerId);
|
||||
const subscriptions = await this.subscriptionManager.listForCustomer(
|
||||
customerId
|
||||
);
|
||||
if (!subscriptions) return [];
|
||||
return subscriptions.filter(
|
||||
(sub) =>
|
||||
|
@ -164,7 +168,7 @@ export class PayPalManager {
|
|||
// It appears for subscriptions that do not require payment, the invoice
|
||||
// transitions to paid automatially.
|
||||
// https://stripe.com/docs/billing/invoices/subscription#sub-invoice-lifecycle
|
||||
return this.stripeManager.finalizeInvoiceWithoutAutoAdvance(invoiceId);
|
||||
return this.invoiceManager.finalizeWithoutAutoAdvance(invoiceId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -176,14 +180,15 @@ export class PayPalManager {
|
|||
if (!invoice.customer) throw new Error('Customer not present on invoice');
|
||||
const amountInCents = invoice.amount_due;
|
||||
|
||||
if (amountInCents < this.stripeManager.getMinimumAmount(invoice.currency)) {
|
||||
if (
|
||||
amountInCents <
|
||||
this.subscriptionManager.getMinimumAmount(invoice.currency)
|
||||
) {
|
||||
await this.processZeroInvoice(invoice.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const customer = await this.stripeManager.fetchActiveCustomer(
|
||||
invoice.customer
|
||||
);
|
||||
const customer = await this.customerManager.retrieve(invoice.customer);
|
||||
await this.processNonZeroInvoice(customer, invoice);
|
||||
}
|
||||
|
||||
|
|
|
@ -36,7 +36,12 @@ export * from './lib/stripe.config';
|
|||
export * from './lib/stripe.constants';
|
||||
export * from './lib/stripe.error';
|
||||
export * from './lib/stripe.factories';
|
||||
export * from './lib/stripe.manager';
|
||||
export * from './lib/customer.manager';
|
||||
export * from './lib/invoice.manager';
|
||||
export * from './lib/price.manager';
|
||||
export * from './lib/product.manager';
|
||||
export * from './lib/promotionCode.manager';
|
||||
export * from './lib/subscription.manager';
|
||||
export * from './lib/stripe.service';
|
||||
export * from './lib/stripe.types';
|
||||
export * from './lib/stripe.util';
|
||||
|
|
|
@ -0,0 +1,232 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { StripeResponseFactory } from './factories/api-list.factory';
|
||||
import { StripeCustomerFactory } from './factories/customer.factory';
|
||||
import { TaxAddressFactory } from './factories/tax-address.factory';
|
||||
import { StripeClient } from './stripe.client';
|
||||
import { MockStripeConfigProvider } from './stripe.config';
|
||||
import { CustomerManager } from './customer.manager';
|
||||
import { MOZILLA_TAX_ID } from './stripe.constants';
|
||||
|
||||
describe('CustomerManager', () => {
|
||||
let customerManager: CustomerManager;
|
||||
let stripeClient: StripeClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [MockStripeConfigProvider, StripeClient, CustomerManager],
|
||||
}).compile();
|
||||
|
||||
customerManager = module.get(CustomerManager);
|
||||
stripeClient = module.get(StripeClient);
|
||||
});
|
||||
|
||||
describe('retrieve', () => {
|
||||
it('returns an existing, non-deleted customer from Stripe', async () => {
|
||||
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'customersRetrieve')
|
||||
.mockResolvedValueOnce(mockCustomer);
|
||||
|
||||
const result = await customerManager.retrieve(mockCustomer.id);
|
||||
expect(result).toEqual(mockCustomer);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update an existing customer from Stripe', async () => {
|
||||
const mockCustomer = StripeResponseFactory(
|
||||
StripeCustomerFactory({
|
||||
address: {
|
||||
city: faker.location.city(),
|
||||
country: faker.location.countryCode(),
|
||||
line1: faker.location.streetAddress(),
|
||||
line2: '',
|
||||
postal_code: faker.location.zipCode(),
|
||||
state: faker.location.state(),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'customersUpdate')
|
||||
.mockResolvedValue(mockCustomer);
|
||||
|
||||
const result = await customerManager.update(mockCustomer.id, {
|
||||
address: {
|
||||
city: faker.location.city(),
|
||||
country: faker.location.countryCode(),
|
||||
line1: faker.location.streetAddress(),
|
||||
line2: '',
|
||||
postal_code: faker.location.zipCode(),
|
||||
state: faker.location.state(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockCustomer);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates a customer within Stripe', async () => {
|
||||
const taxAddress = TaxAddressFactory();
|
||||
const mockCustomer = StripeResponseFactory(
|
||||
StripeCustomerFactory({
|
||||
name: faker.person.fullName(),
|
||||
shipping: {
|
||||
name: '',
|
||||
address: {
|
||||
city: faker.location.city(),
|
||||
country: taxAddress.countryCode,
|
||||
line1: faker.location.streetAddress(),
|
||||
line2: '',
|
||||
postal_code: taxAddress.postalCode,
|
||||
state: faker.location.state(),
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'customersCreate')
|
||||
.mockResolvedValue(mockCustomer);
|
||||
|
||||
const result = await customerManager.create({
|
||||
uid: faker.string.uuid(),
|
||||
email: faker.internet.email(),
|
||||
displayName: faker.person.fullName(),
|
||||
taxAddress: taxAddress,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockCustomer);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTaxEligible', () => {
|
||||
it('should return true for a taxable customer', async () => {
|
||||
const mockCustomer = StripeCustomerFactory({
|
||||
tax: {
|
||||
automatic_tax: 'supported',
|
||||
ip_address: null,
|
||||
location: { country: 'US', state: 'CA', source: 'billing_address' },
|
||||
},
|
||||
});
|
||||
|
||||
const result = customerManager.isTaxEligible(mockCustomer);
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
|
||||
it('should return true for a customer in a not-collecting location', async () => {
|
||||
const mockCustomer = StripeCustomerFactory({
|
||||
tax: {
|
||||
automatic_tax: 'not_collecting',
|
||||
ip_address: null,
|
||||
location: null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = customerManager.isTaxEligible(mockCustomer);
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTaxId', () => {
|
||||
it('returns customer tax id if found', async () => {
|
||||
const mockTaxIdValue = faker.string.uuid();
|
||||
const mockCustomer = StripeCustomerFactory({
|
||||
invoice_settings: {
|
||||
custom_fields: [{ name: 'Tax ID', value: mockTaxIdValue }],
|
||||
default_payment_method: null,
|
||||
footer: null,
|
||||
rendering_options: null,
|
||||
},
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'customersRetrieve')
|
||||
.mockResolvedValue(StripeResponseFactory(mockCustomer));
|
||||
|
||||
const result = await customerManager.getTaxId(mockCustomer.id);
|
||||
|
||||
expect(result).toEqual(mockTaxIdValue);
|
||||
});
|
||||
|
||||
it('returns undefined when customer tax id not found', async () => {
|
||||
const mockCustomer = StripeCustomerFactory();
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'customersRetrieve')
|
||||
.mockResolvedValue(StripeResponseFactory(mockCustomer));
|
||||
|
||||
const result = await customerManager.getTaxId(mockCustomer.id);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTaxId', () => {
|
||||
it('updates customer object with incoming tax id when match is not found', async () => {
|
||||
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
|
||||
const mockTaxId = 'EU1234';
|
||||
const mockUpdatedCustomer = StripeResponseFactory(
|
||||
StripeCustomerFactory({
|
||||
invoice_settings: {
|
||||
custom_fields: [{ name: 'Tax ID', value: mockTaxId }],
|
||||
default_payment_method: null,
|
||||
footer: null,
|
||||
rendering_options: null,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'customersRetrieve')
|
||||
.mockResolvedValue(mockCustomer);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'customersUpdate')
|
||||
.mockResolvedValue(mockUpdatedCustomer);
|
||||
|
||||
await customerManager.setTaxId(mockCustomer.id, mockTaxId);
|
||||
|
||||
expect(stripeClient.customersUpdate).toHaveBeenCalledWith(
|
||||
mockCustomer.id,
|
||||
{
|
||||
invoice_settings: {
|
||||
custom_fields: [{ name: MOZILLA_TAX_ID, value: mockTaxId }],
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('does not update customer object when incoming tax id matches existing tax id', async () => {
|
||||
const mockTaxId = 'T43CAK315A713';
|
||||
const mockCustomer = StripeCustomerFactory({
|
||||
invoice_settings: {
|
||||
custom_fields: [{ name: 'Tax ID', value: mockTaxId }],
|
||||
default_payment_method: null,
|
||||
footer: null,
|
||||
rendering_options: null,
|
||||
},
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'customersRetrieve')
|
||||
.mockResolvedValue(StripeResponseFactory(mockCustomer));
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'customersUpdate')
|
||||
.mockResolvedValue(StripeResponseFactory(mockCustomer));
|
||||
|
||||
await customerManager.setTaxId(mockCustomer.id, mockTaxId);
|
||||
|
||||
expect(stripeClient.customersUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,99 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Stripe } from 'stripe';
|
||||
|
||||
import { StripeClient } from './stripe.client';
|
||||
import { MOZILLA_TAX_ID } from './stripe.constants';
|
||||
import { CustomerDeletedError } from './stripe.error';
|
||||
import { TaxAddress } from './stripe.types';
|
||||
import { StripeCustomer } from './stripe.client.types';
|
||||
import { isCustomerTaxEligible } from './util/isCustomerTaxEligible';
|
||||
|
||||
@Injectable()
|
||||
export class CustomerManager {
|
||||
constructor(private client: StripeClient) {}
|
||||
|
||||
/**
|
||||
* Retrieves a customer record
|
||||
*/
|
||||
async retrieve(customerId: string) {
|
||||
const customer = await this.client.customersRetrieve(customerId);
|
||||
if (customer.deleted) throw new CustomerDeletedError();
|
||||
return customer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a customer record
|
||||
*/
|
||||
update(customerId: string, params?: Stripe.CustomerUpdateParams) {
|
||||
return this.client.customersUpdate(customerId, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a customer
|
||||
*/
|
||||
async create(args: {
|
||||
uid: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
taxAddress?: TaxAddress;
|
||||
}) {
|
||||
const { uid, email, displayName, taxAddress } = args;
|
||||
|
||||
const shipping = taxAddress
|
||||
? {
|
||||
name: email,
|
||||
address: {
|
||||
country: taxAddress.countryCode,
|
||||
postal_code: taxAddress.postalCode,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const customer = await this.client.customersCreate({
|
||||
email,
|
||||
name: displayName || '',
|
||||
description: uid,
|
||||
metadata: {
|
||||
userid: uid,
|
||||
geoip_date: taxAddress ? new Date().toString() : null,
|
||||
},
|
||||
shipping,
|
||||
});
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
async setTaxId(customerId: string, taxId: string) {
|
||||
const customerTaxId = await this.getTaxId(customerId);
|
||||
|
||||
if (!customerTaxId || customerTaxId !== taxId) {
|
||||
await this.client.customersUpdate(customerId, {
|
||||
invoice_settings: {
|
||||
custom_fields: [{ name: MOZILLA_TAX_ID, value: taxId }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async getTaxId(customerId: string) {
|
||||
const customer = await this.retrieve(customerId);
|
||||
|
||||
const customFields = customer.invoice_settings.custom_fields || [];
|
||||
|
||||
const taxIdFields = customFields.filter((customField: any) => {
|
||||
return customField.name === MOZILLA_TAX_ID;
|
||||
});
|
||||
|
||||
return taxIdFields.at(0)?.value;
|
||||
}
|
||||
|
||||
isTaxEligible(customer: StripeCustomer) {
|
||||
return isCustomerTaxEligible(customer);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const mockStripeUtil = {
|
||||
stripeInvoiceToFirstInvoicePreviewDTO: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock(
|
||||
'../../../stripe/src/lib/util/stripeInvoiceToFirstInvoicePreviewDTO',
|
||||
() => mockStripeUtil
|
||||
);
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { StripeResponseFactory } from './factories/api-list.factory';
|
||||
import { StripeCustomerFactory } from './factories/customer.factory';
|
||||
import { StripeInvoiceFactory } from './factories/invoice.factory';
|
||||
import { StripePriceFactory } from './factories/price.factory';
|
||||
import { StripeUpcomingInvoiceFactory } from './factories/upcoming-invoice.factory';
|
||||
import { TaxAddressFactory } from './factories/tax-address.factory';
|
||||
import { StripeClient } from './stripe.client';
|
||||
import { MockStripeConfigProvider } from './stripe.config';
|
||||
import { InvoicePreviewFactory } from './stripe.factories';
|
||||
import { InvoiceManager } from './invoice.manager';
|
||||
|
||||
describe('InvoiceManager', () => {
|
||||
let invoiceManager: InvoiceManager;
|
||||
let stripeClient: StripeClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [MockStripeConfigProvider, StripeClient, InvoiceManager],
|
||||
}).compile();
|
||||
|
||||
invoiceManager = module.get(InvoiceManager);
|
||||
stripeClient = module.get(StripeClient);
|
||||
});
|
||||
|
||||
describe('finalizeWithoutAutoAdvance', () => {
|
||||
it('works successfully', async () => {
|
||||
const mockInvoice = StripeResponseFactory(
|
||||
StripeInvoiceFactory({
|
||||
auto_advance: false,
|
||||
})
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'invoicesFinalizeInvoice')
|
||||
.mockResolvedValue(mockInvoice);
|
||||
|
||||
const result = await invoiceManager.finalizeWithoutAutoAdvance(
|
||||
mockInvoice.id
|
||||
);
|
||||
expect(result).toEqual(mockInvoice);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preview', () => {
|
||||
it('returns upcoming invoice', async () => {
|
||||
const mockCustomer = StripeCustomerFactory();
|
||||
const mockPrice = StripePriceFactory();
|
||||
const mockUpcomingInvoice = StripeResponseFactory(
|
||||
StripeUpcomingInvoiceFactory()
|
||||
);
|
||||
|
||||
const mockTaxAddress = TaxAddressFactory();
|
||||
const mockInvoicePreview = InvoicePreviewFactory();
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'invoicesRetrieveUpcoming')
|
||||
.mockResolvedValue(mockUpcomingInvoice);
|
||||
|
||||
mockStripeUtil.stripeInvoiceToFirstInvoicePreviewDTO.mockReturnValue(
|
||||
mockInvoicePreview
|
||||
);
|
||||
|
||||
const result = await invoiceManager.preview({
|
||||
priceId: mockPrice.id,
|
||||
customer: mockCustomer,
|
||||
taxAddress: mockTaxAddress,
|
||||
});
|
||||
expect(result).toEqual(mockInvoicePreview);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,67 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Stripe } from 'stripe';
|
||||
|
||||
import { StripeClient } from './stripe.client';
|
||||
import { StripeCustomer } from './stripe.client.types';
|
||||
import { TaxAddress } from './stripe.types';
|
||||
import { stripeInvoiceToFirstInvoicePreviewDTO } from './util/stripeInvoiceToFirstInvoicePreviewDTO';
|
||||
import { isCustomerTaxEligible } from './util/isCustomerTaxEligible';
|
||||
|
||||
@Injectable()
|
||||
export class InvoiceManager {
|
||||
constructor(private client: StripeClient) {}
|
||||
|
||||
async finalizeWithoutAutoAdvance(invoiceId: string) {
|
||||
return this.client.invoicesFinalizeInvoice(invoiceId, {
|
||||
auto_advance: false,
|
||||
});
|
||||
}
|
||||
|
||||
async preview({
|
||||
priceId,
|
||||
customer,
|
||||
taxAddress,
|
||||
}: {
|
||||
priceId: string;
|
||||
customer?: StripeCustomer;
|
||||
taxAddress?: TaxAddress;
|
||||
}) {
|
||||
const automaticTax = !!(
|
||||
(customer && isCustomerTaxEligible(customer)) ||
|
||||
(!customer && taxAddress)
|
||||
);
|
||||
|
||||
const shipping =
|
||||
!customer && taxAddress
|
||||
? {
|
||||
name: '',
|
||||
address: {
|
||||
country: taxAddress.countryCode,
|
||||
postal_code: taxAddress.postalCode,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const requestObject: Stripe.InvoiceRetrieveUpcomingParams = {
|
||||
customer: customer?.id,
|
||||
automatic_tax: {
|
||||
enabled: automaticTax,
|
||||
},
|
||||
customer_details: {
|
||||
tax_exempt: 'none', // Param required when shipping address not present
|
||||
shipping,
|
||||
},
|
||||
subscription_items: [{ price: priceId }],
|
||||
};
|
||||
|
||||
const upcomingInvoice = await this.client.invoicesRetrieveUpcoming(
|
||||
requestObject
|
||||
);
|
||||
|
||||
return stripeInvoiceToFirstInvoicePreviewDTO(upcomingInvoice);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { StripeResponseFactory } from './factories/api-list.factory';
|
||||
import { StripePlanFactory } from './factories/plan.factory';
|
||||
import { StripeClient } from './stripe.client';
|
||||
import { MockStripeConfigProvider } from './stripe.config';
|
||||
import { PlanIntervalMultiplePlansError } from './stripe.error';
|
||||
import { PriceManager } from './price.manager';
|
||||
import { SubplatInterval } from './stripe.types';
|
||||
|
||||
describe('PriceManager', () => {
|
||||
let priceManager: PriceManager;
|
||||
let stripeClient: StripeClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [MockStripeConfigProvider, StripeClient, PriceManager],
|
||||
}).compile();
|
||||
|
||||
priceManager = module.get(PriceManager);
|
||||
stripeClient = module.get(StripeClient);
|
||||
});
|
||||
|
||||
describe('retrieve', () => {
|
||||
it('returns plan', async () => {
|
||||
const mockPlan = StripeResponseFactory(StripePlanFactory());
|
||||
|
||||
jest.spyOn(stripeClient, 'plansRetrieve').mockResolvedValue(mockPlan);
|
||||
|
||||
const result = await priceManager.retrieve(mockPlan.id);
|
||||
expect(result).toEqual(mockPlan);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlanByInterval', () => {
|
||||
it('returns plan that matches interval', async () => {
|
||||
const mockPlan = StripeResponseFactory(
|
||||
StripePlanFactory({
|
||||
interval: 'month',
|
||||
interval_count: 1,
|
||||
})
|
||||
);
|
||||
const subplatInterval = SubplatInterval.Monthly;
|
||||
|
||||
jest.spyOn(priceManager, 'retrieve').mockResolvedValue(mockPlan);
|
||||
|
||||
const result = await priceManager.retrieveByInterval(
|
||||
[mockPlan.id],
|
||||
subplatInterval
|
||||
);
|
||||
expect(result).toEqual(mockPlan);
|
||||
});
|
||||
|
||||
it('throw error if interval returns multiple plans', async () => {
|
||||
const mockPlan1 = StripePlanFactory({
|
||||
interval: 'month',
|
||||
});
|
||||
const mockPlan2 = StripePlanFactory({
|
||||
interval: 'month',
|
||||
});
|
||||
const subplatInterval = SubplatInterval.Monthly;
|
||||
|
||||
jest
|
||||
.spyOn(priceManager, 'retrieve')
|
||||
.mockResolvedValue(StripeResponseFactory(mockPlan1));
|
||||
jest
|
||||
.spyOn(priceManager, 'retrieve')
|
||||
.mockResolvedValue(StripeResponseFactory(mockPlan2));
|
||||
|
||||
await expect(
|
||||
priceManager.retrieveByInterval(
|
||||
[mockPlan1.id, mockPlan2.id],
|
||||
subplatInterval
|
||||
)
|
||||
).rejects.toBeInstanceOf(PlanIntervalMultiplePlansError);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,37 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { StripeClient } from './stripe.client';
|
||||
import { StripePlan } from './stripe.client.types';
|
||||
import {
|
||||
PlanIntervalMultiplePlansError,
|
||||
PlanNotFoundError,
|
||||
} from './stripe.error';
|
||||
import { SubplatInterval } from './stripe.types';
|
||||
import { doesPlanMatchSubplatInterval } from './util/doesPlanMatchSubplatInterval';
|
||||
|
||||
@Injectable()
|
||||
export class PriceManager {
|
||||
constructor(private client: StripeClient) {}
|
||||
|
||||
async retrieve(planId: string) {
|
||||
const plan = await this.client.plansRetrieve(planId);
|
||||
if (!plan) throw new PlanNotFoundError();
|
||||
return plan;
|
||||
}
|
||||
|
||||
async retrieveByInterval(planIds: string[], interval: SubplatInterval) {
|
||||
const plans: StripePlan[] = [];
|
||||
for (const planId of planIds) {
|
||||
const plan = await this.retrieve(planId);
|
||||
if (doesPlanMatchSubplatInterval(plan, interval)) {
|
||||
plans.push(plan);
|
||||
}
|
||||
}
|
||||
if (plans.length > 1) throw new PlanIntervalMultiplePlansError();
|
||||
return plans.at(0);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { StripeResponseFactory } from './factories/api-list.factory';
|
||||
import { StripeProductFactory } from './factories/product.factory';
|
||||
import { StripeClient } from './stripe.client';
|
||||
import { MockStripeConfigProvider } from './stripe.config';
|
||||
import { ProductManager } from './product.manager';
|
||||
|
||||
describe('ProductManager', () => {
|
||||
let productManager: ProductManager;
|
||||
let stripeClient: StripeClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [MockStripeConfigProvider, StripeClient, ProductManager],
|
||||
}).compile();
|
||||
|
||||
productManager = module.get(ProductManager);
|
||||
stripeClient = module.get(StripeClient);
|
||||
});
|
||||
|
||||
describe('retrieve', () => {
|
||||
it('returns product', async () => {
|
||||
const mockProduct = StripeResponseFactory(StripeProductFactory());
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'productsRetrieve')
|
||||
.mockResolvedValue(mockProduct);
|
||||
|
||||
const result = await productManager.retrieve(mockProduct.id);
|
||||
expect(result).toEqual(mockProduct);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,19 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { StripeClient } from './stripe.client';
|
||||
import { ProductNotFoundError } from './stripe.error';
|
||||
|
||||
@Injectable()
|
||||
export class ProductManager {
|
||||
constructor(private client: StripeClient) {}
|
||||
|
||||
async retrieve(productId: string) {
|
||||
const product = await this.client.productsRetrieve(productId);
|
||||
if (!product) throw new ProductNotFoundError();
|
||||
return product;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import {
|
||||
StripeApiListFactory,
|
||||
StripeResponseFactory,
|
||||
} from './factories/api-list.factory';
|
||||
import { StripePromotionCodeFactory } from './factories/promotion-code.factory';
|
||||
import { StripeClient } from './stripe.client';
|
||||
import { MockStripeConfigProvider } from './stripe.config';
|
||||
import { PromotionCodeManager } from './promotionCode.manager';
|
||||
|
||||
describe('PromotionCodeManager', () => {
|
||||
let promotionCodeManager: PromotionCodeManager;
|
||||
let stripeClient: StripeClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [MockStripeConfigProvider, StripeClient, PromotionCodeManager],
|
||||
}).compile();
|
||||
|
||||
promotionCodeManager = module.get(PromotionCodeManager);
|
||||
stripeClient = module.get(StripeClient);
|
||||
});
|
||||
|
||||
describe('retrieve', () => {
|
||||
it('retrieves promotion code', async () => {
|
||||
const mockPromotionCode = StripePromotionCodeFactory();
|
||||
const mockResponse = StripeResponseFactory(mockPromotionCode);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'promotionCodesRetrieve')
|
||||
.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await promotionCodeManager.retrieve(mockPromotionCode.id);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retrieveByName', () => {
|
||||
it('queries for promotionCodes from stripe and returns first', async () => {
|
||||
const mockPromotionCode = StripePromotionCodeFactory();
|
||||
const mockPromotionCode2 = StripePromotionCodeFactory();
|
||||
const mockPromotionCodesResponse = StripeApiListFactory([
|
||||
mockPromotionCode,
|
||||
mockPromotionCode2,
|
||||
]);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'promotionCodesList')
|
||||
.mockResolvedValue(StripeResponseFactory(mockPromotionCodesResponse));
|
||||
|
||||
const result = await promotionCodeManager.retrieveByName(
|
||||
mockPromotionCode.code
|
||||
);
|
||||
expect(result).toEqual(mockPromotionCode);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,25 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { StripeClient } from './stripe.client';
|
||||
|
||||
@Injectable()
|
||||
export class PromotionCodeManager {
|
||||
constructor(private client: StripeClient) {}
|
||||
|
||||
async retrieve(id: string) {
|
||||
return this.client.promotionCodesRetrieve(id);
|
||||
}
|
||||
|
||||
async retrieveByName(code: string, active?: boolean) {
|
||||
const promotionCodes = await this.client.promotionCodesList({
|
||||
active,
|
||||
code,
|
||||
});
|
||||
|
||||
return promotionCodes.data.at(0);
|
||||
}
|
||||
}
|
|
@ -1,604 +0,0 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const mockStripeUtil = {
|
||||
stripeInvoiceToFirstInvoicePreviewDTO: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock(
|
||||
'../../../stripe/src/lib/util/stripeInvoiceToFirstInvoicePreviewDTO',
|
||||
() => mockStripeUtil
|
||||
);
|
||||
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import {
|
||||
StripeApiListFactory,
|
||||
StripeResponseFactory,
|
||||
} from './factories/api-list.factory';
|
||||
import { StripeCustomerFactory } from './factories/customer.factory';
|
||||
import { StripeInvoiceFactory } from './factories/invoice.factory';
|
||||
import { StripePaymentIntentFactory } from './factories/payment-intent.factory';
|
||||
import { StripePlanFactory } from './factories/plan.factory';
|
||||
import { StripePriceFactory } from './factories/price.factory';
|
||||
import { StripeProductFactory } from './factories/product.factory';
|
||||
import { StripePromotionCodeFactory } from './factories/promotion-code.factory';
|
||||
import { StripeSubscriptionFactory } from './factories/subscription.factory';
|
||||
import { StripeUpcomingInvoiceFactory } from './factories/upcoming-invoice.factory';
|
||||
import { TaxAddressFactory } from './factories/tax-address.factory';
|
||||
import { StripeClient } from './stripe.client';
|
||||
import { MockStripeConfigProvider } from './stripe.config';
|
||||
import { PlanIntervalMultiplePlansError } from './stripe.error';
|
||||
import { InvoicePreviewFactory } from './stripe.factories';
|
||||
import { StripeManager } from './stripe.manager';
|
||||
import { SubplatInterval } from './stripe.types';
|
||||
|
||||
describe('StripeManager', () => {
|
||||
let stripeManager: StripeManager;
|
||||
let stripeClient: StripeClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [MockStripeConfigProvider, StripeClient, StripeManager],
|
||||
}).compile();
|
||||
|
||||
stripeManager = module.get(StripeManager);
|
||||
stripeClient = module.get(StripeClient);
|
||||
});
|
||||
|
||||
describe('fetchActiveCustomer', () => {
|
||||
it('returns an existing customer from Stripe', async () => {
|
||||
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'customersRetrieve')
|
||||
.mockResolvedValueOnce(mockCustomer);
|
||||
|
||||
const result = await stripeManager.fetchActiveCustomer(mockCustomer.id);
|
||||
expect(result).toEqual(mockCustomer);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCustomer', () => {
|
||||
it('should update an existing customer from Stripe', async () => {
|
||||
const mockCustomer = StripeResponseFactory(
|
||||
StripeCustomerFactory({
|
||||
address: {
|
||||
city: faker.location.city(),
|
||||
country: faker.location.countryCode(),
|
||||
line1: faker.location.streetAddress(),
|
||||
line2: '',
|
||||
postal_code: faker.location.zipCode(),
|
||||
state: faker.location.state(),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'customersUpdate')
|
||||
.mockResolvedValue(mockCustomer);
|
||||
|
||||
const result = await stripeManager.updateCustomer(mockCustomer.id, {
|
||||
address: {
|
||||
city: faker.location.city(),
|
||||
country: faker.location.countryCode(),
|
||||
line1: faker.location.streetAddress(),
|
||||
line2: '',
|
||||
postal_code: faker.location.zipCode(),
|
||||
state: faker.location.state(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockCustomer);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPlainCustomer', () => {
|
||||
it('creates a plain customer from Stripe', async () => {
|
||||
const taxAddress = TaxAddressFactory();
|
||||
const mockCustomer = StripeResponseFactory(
|
||||
StripeCustomerFactory({
|
||||
name: faker.person.fullName(),
|
||||
shipping: {
|
||||
name: '',
|
||||
address: {
|
||||
city: faker.location.city(),
|
||||
country: taxAddress.countryCode,
|
||||
line1: faker.location.streetAddress(),
|
||||
line2: '',
|
||||
postal_code: taxAddress.postalCode,
|
||||
state: faker.location.state(),
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'customersCreate')
|
||||
.mockResolvedValue(mockCustomer);
|
||||
|
||||
const result = await stripeManager.createPlainCustomer({
|
||||
uid: faker.string.uuid(),
|
||||
email: faker.internet.email(),
|
||||
displayName: faker.person.fullName(),
|
||||
taxAddress: taxAddress,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockCustomer);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalizeInvoiceWithoutAutoAdvance', () => {
|
||||
it('works successfully', async () => {
|
||||
const mockInvoice = StripeResponseFactory(
|
||||
StripeInvoiceFactory({
|
||||
auto_advance: false,
|
||||
})
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'invoicesFinalizeInvoice')
|
||||
.mockResolvedValue(mockInvoice);
|
||||
|
||||
const result = await stripeManager.finalizeInvoiceWithoutAutoAdvance(
|
||||
mockInvoice.id
|
||||
);
|
||||
expect(result).toEqual(mockInvoice);
|
||||
});
|
||||
});
|
||||
|
||||
describe('previewInvoice', () => {
|
||||
it('returns upcoming invoice', async () => {
|
||||
const mockCustomer = StripeCustomerFactory();
|
||||
const mockPrice = StripePriceFactory();
|
||||
const mockUpcomingInvoice = StripeResponseFactory(
|
||||
StripeUpcomingInvoiceFactory()
|
||||
);
|
||||
|
||||
const mockTaxAddress = TaxAddressFactory();
|
||||
const mockInvoicePreview = InvoicePreviewFactory();
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'invoicesRetrieveUpcoming')
|
||||
.mockResolvedValue(mockUpcomingInvoice);
|
||||
|
||||
mockStripeUtil.stripeInvoiceToFirstInvoicePreviewDTO.mockReturnValue(
|
||||
mockInvoicePreview
|
||||
);
|
||||
|
||||
const result = await stripeManager.previewInvoice({
|
||||
priceId: mockPrice.id,
|
||||
customer: mockCustomer,
|
||||
taxAddress: mockTaxAddress,
|
||||
});
|
||||
expect(result).toEqual(mockInvoicePreview);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMinimumAmount', () => {
|
||||
it('returns minimum amout for valid currency', () => {
|
||||
const expected = 50;
|
||||
const result = stripeManager.getMinimumAmount('usd');
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should throw an error if currency is invalid', () => {
|
||||
expect(() => stripeManager.getMinimumAmount('fake')).toThrow(
|
||||
'Currency does not have a minimum charge amount available.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTaxIdForCurrency', () => {
|
||||
it('returns the correct tax id for currency', async () => {
|
||||
const mockCurrency = 'eur';
|
||||
|
||||
const result = stripeManager.getTaxIdForCurrency(mockCurrency);
|
||||
expect(result).toEqual('EU1234');
|
||||
});
|
||||
|
||||
it('returns empty string when no tax id found', async () => {
|
||||
const mockCurrency = faker.finance.currencyCode();
|
||||
|
||||
const result = stripeManager.getTaxIdForCurrency(mockCurrency);
|
||||
expect(result).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelIncompleteSubscriptionsToPrice', () => {
|
||||
it('cancels incomplete subscriptions', async () => {
|
||||
const mockCustomer = StripeCustomerFactory();
|
||||
const mockSubscription = StripeSubscriptionFactory({
|
||||
status: 'incomplete',
|
||||
});
|
||||
const mockSubscriptionList = [mockSubscription];
|
||||
const mockPrice = mockSubscription.items.data[0].price;
|
||||
const mockResponse = StripeResponseFactory(mockSubscription);
|
||||
|
||||
jest
|
||||
.spyOn(stripeManager, 'getSubscriptions')
|
||||
.mockResolvedValue(mockSubscriptionList);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'subscriptionsCancel')
|
||||
.mockResolvedValue(mockResponse);
|
||||
|
||||
await stripeManager.cancelIncompleteSubscriptionsToPrice(
|
||||
mockCustomer.id,
|
||||
mockPrice.id
|
||||
);
|
||||
|
||||
expect(stripeClient.subscriptionsCancel).toBeCalledWith(
|
||||
mockSubscription.id
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSubscriptions', () => {
|
||||
it('returns subscriptions', async () => {
|
||||
const mockSubscription = StripeSubscriptionFactory();
|
||||
const mockSubscriptionList = StripeApiListFactory([mockSubscription]);
|
||||
const mockCustomer = StripeCustomerFactory();
|
||||
|
||||
const expected = mockSubscriptionList.data;
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'subscriptionsList')
|
||||
.mockResolvedValue(mockSubscriptionList);
|
||||
|
||||
const result = await stripeManager.getSubscriptions(mockCustomer.id);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('returns empty array if no subscriptions exist', async () => {
|
||||
const mockCustomer = StripeCustomerFactory();
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'subscriptionsList')
|
||||
.mockResolvedValue(StripeApiListFactory([]));
|
||||
|
||||
const result = await stripeManager.getSubscriptions(mockCustomer.id);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelSubscription', () => {
|
||||
it('calls stripeclient', async () => {
|
||||
const mockSubscription = StripeSubscriptionFactory();
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'subscriptionsCancel')
|
||||
.mockResolvedValue(StripeResponseFactory(mockSubscription));
|
||||
|
||||
await stripeManager.cancelSubscription(mockSubscription.id);
|
||||
|
||||
expect(stripeClient.subscriptionsCancel).toBeCalledWith(
|
||||
mockSubscription.id
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retrieveSubscription', () => {
|
||||
it('calls stripeclient', async () => {
|
||||
const mockSubscription = StripeSubscriptionFactory();
|
||||
const mockResponse = StripeResponseFactory(mockSubscription);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'subscriptionsRetrieve')
|
||||
.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await stripeManager.retrieveSubscription(
|
||||
mockSubscription.id
|
||||
);
|
||||
|
||||
expect(stripeClient.subscriptionsRetrieve).toBeCalledWith(
|
||||
mockSubscription.id
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSubscription', () => {
|
||||
it('calls stripeclient', async () => {
|
||||
const mockParams = {
|
||||
description: 'This is an updated subscription',
|
||||
};
|
||||
const mockSubscription = StripeSubscriptionFactory(mockParams);
|
||||
const mockResponse = StripeResponseFactory(mockSubscription);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'subscriptionsUpdate')
|
||||
.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await stripeManager.updateSubscription(
|
||||
mockSubscription.id,
|
||||
mockParams
|
||||
);
|
||||
|
||||
expect(stripeClient.subscriptionsUpdate).toBeCalledWith(
|
||||
mockSubscription.id,
|
||||
mockParams
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCustomerStripeTaxEligible', () => {
|
||||
it('should return true for a taxable customer', async () => {
|
||||
const mockCustomer = StripeCustomerFactory({
|
||||
tax: {
|
||||
automatic_tax: 'supported',
|
||||
ip_address: null,
|
||||
location: { country: 'US', state: 'CA', source: 'billing_address' },
|
||||
},
|
||||
});
|
||||
|
||||
const result = stripeManager.isCustomerStripeTaxEligible(mockCustomer);
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
|
||||
it('should return true for a customer in a not-collecting location', async () => {
|
||||
const mockCustomer = StripeCustomerFactory({
|
||||
tax: {
|
||||
automatic_tax: 'not_collecting',
|
||||
ip_address: null,
|
||||
location: null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = stripeManager.isCustomerStripeTaxEligible(mockCustomer);
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPromotionCodeByName', () => {
|
||||
it('queries for promotionCodes from stripe and returns first', async () => {
|
||||
const mockPromotionCode = StripePromotionCodeFactory();
|
||||
const mockPromotionCode2 = StripePromotionCodeFactory();
|
||||
const mockPromotionCodesResponse = StripeApiListFactory([
|
||||
mockPromotionCode,
|
||||
mockPromotionCode2,
|
||||
]);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'promotionCodesList')
|
||||
.mockResolvedValue(StripeResponseFactory(mockPromotionCodesResponse));
|
||||
|
||||
const result = await stripeManager.getPromotionCodeByName(
|
||||
mockPromotionCode.code
|
||||
);
|
||||
expect(result).toEqual(mockPromotionCode);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retrievePromotionCode', () => {
|
||||
it('retrieves promotion code', async () => {
|
||||
const mockPromotionCode = StripePromotionCodeFactory();
|
||||
const mockResponse = StripeResponseFactory(mockPromotionCode);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'promotionCodesRetrieve')
|
||||
.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await stripeManager.retrievePromotionCode(
|
||||
mockPromotionCode.id
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCustomerTaxId', () => {
|
||||
it('returns customer tax id if found', async () => {
|
||||
const mockTaxIdValue = faker.string.uuid();
|
||||
const mockCustomer = StripeCustomerFactory({
|
||||
invoice_settings: {
|
||||
custom_fields: [{ name: 'Tax ID', value: mockTaxIdValue }],
|
||||
default_payment_method: null,
|
||||
footer: null,
|
||||
rendering_options: null,
|
||||
},
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'customersRetrieve')
|
||||
.mockResolvedValue(StripeResponseFactory(mockCustomer));
|
||||
|
||||
const result = await stripeManager.getCustomerTaxId(mockCustomer.id);
|
||||
|
||||
expect(result).toEqual(mockTaxIdValue);
|
||||
});
|
||||
|
||||
it('returns undefined when customer tax id not found', async () => {
|
||||
const mockCustomer = StripeCustomerFactory();
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'customersRetrieve')
|
||||
.mockResolvedValue(StripeResponseFactory(mockCustomer));
|
||||
|
||||
const result = await stripeManager.getCustomerTaxId(mockCustomer.id);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCustomerTaxId', () => {
|
||||
it('updates customer object with incoming tax id when match is not found', async () => {
|
||||
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
|
||||
const mockUpdatedCustomer = StripeResponseFactory(
|
||||
StripeCustomerFactory({
|
||||
invoice_settings: {
|
||||
custom_fields: [{ name: 'Tax ID', value: 'EU1234' }],
|
||||
default_payment_method: null,
|
||||
footer: null,
|
||||
rendering_options: null,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'customersRetrieve')
|
||||
.mockResolvedValue(mockCustomer);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'customersUpdate')
|
||||
.mockResolvedValue(mockUpdatedCustomer);
|
||||
|
||||
const result = await stripeManager.setCustomerTaxId(
|
||||
mockCustomer.id,
|
||||
'EU1234'
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockUpdatedCustomer);
|
||||
});
|
||||
|
||||
it('does not update customer object when incoming tax id matches existing tax id', async () => {
|
||||
const mockCustomer = StripeCustomerFactory({
|
||||
invoice_settings: {
|
||||
custom_fields: [{ name: 'Tax ID', value: 'T43CAK315A713' }],
|
||||
default_payment_method: null,
|
||||
footer: null,
|
||||
rendering_options: null,
|
||||
},
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'customersRetrieve')
|
||||
.mockResolvedValue(StripeResponseFactory(mockCustomer));
|
||||
|
||||
const result = await stripeManager.setCustomerTaxId(
|
||||
mockCustomer.id,
|
||||
'T43CAK315A713'
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlan', () => {
|
||||
it('returns plan', async () => {
|
||||
const mockPlan = StripeResponseFactory(StripePlanFactory());
|
||||
|
||||
jest.spyOn(stripeClient, 'plansRetrieve').mockResolvedValue(mockPlan);
|
||||
|
||||
const result = await stripeManager.getPlan(mockPlan.id);
|
||||
expect(result).toEqual(mockPlan);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlanByInterval', () => {
|
||||
it('returns plan that matches interval', async () => {
|
||||
const mockPlan = StripeResponseFactory(
|
||||
StripePlanFactory({
|
||||
interval: 'month',
|
||||
interval_count: 1,
|
||||
})
|
||||
);
|
||||
const subplatInterval = SubplatInterval.Monthly;
|
||||
|
||||
jest.spyOn(stripeManager, 'getPlan').mockResolvedValue(mockPlan);
|
||||
|
||||
const result = await stripeManager.getPlanByInterval(
|
||||
[mockPlan.id],
|
||||
subplatInterval
|
||||
);
|
||||
expect(result).toEqual(mockPlan);
|
||||
});
|
||||
|
||||
it('throw error if interval returns multiple plans', async () => {
|
||||
const mockPlan1 = StripePlanFactory({
|
||||
interval: 'month',
|
||||
});
|
||||
const mockPlan2 = StripePlanFactory({
|
||||
interval: 'month',
|
||||
});
|
||||
const subplatInterval = SubplatInterval.Monthly;
|
||||
|
||||
jest
|
||||
.spyOn(stripeManager, 'getPlan')
|
||||
.mockResolvedValue(StripeResponseFactory(mockPlan1));
|
||||
jest
|
||||
.spyOn(stripeManager, 'getPlan')
|
||||
.mockResolvedValue(StripeResponseFactory(mockPlan2));
|
||||
|
||||
await expect(
|
||||
stripeManager.getPlanByInterval(
|
||||
[mockPlan1.id, mockPlan2.id],
|
||||
subplatInterval
|
||||
)
|
||||
).rejects.toBeInstanceOf(PlanIntervalMultiplePlansError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retrieveProduct', () => {
|
||||
it('returns product', async () => {
|
||||
const mockProduct = StripeResponseFactory(StripeProductFactory());
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'productsRetrieve')
|
||||
.mockResolvedValue(mockProduct);
|
||||
|
||||
const result = await stripeManager.retrieveProduct(mockProduct.id);
|
||||
expect(result).toEqual(mockProduct);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLatestPaymentIntent', () => {
|
||||
it('fetches the latest payment intent for the subscription', async () => {
|
||||
const mockSubscription = StripeResponseFactory(
|
||||
StripeSubscriptionFactory()
|
||||
);
|
||||
const mockInvoice = StripeResponseFactory(StripeInvoiceFactory());
|
||||
const mockPaymentIntent = StripeResponseFactory(
|
||||
StripePaymentIntentFactory()
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'invoicesRetrieve')
|
||||
.mockResolvedValue(mockInvoice);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'paymentIntentRetrieve')
|
||||
.mockResolvedValue(mockPaymentIntent);
|
||||
|
||||
const result = await stripeManager.getLatestPaymentIntent(
|
||||
mockSubscription
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockPaymentIntent);
|
||||
});
|
||||
|
||||
it('returns undefined if no invoice on subscription', async () => {
|
||||
const mockSubscription = StripeSubscriptionFactory({
|
||||
latest_invoice: null,
|
||||
});
|
||||
|
||||
const result = await stripeManager.getLatestPaymentIntent(
|
||||
mockSubscription
|
||||
);
|
||||
|
||||
expect(result).toEqual(undefined);
|
||||
});
|
||||
|
||||
it('returns undefined if the invoice has no payment intent', async () => {
|
||||
const mockSubscription = StripeSubscriptionFactory();
|
||||
const mockInvoice = StripeResponseFactory(
|
||||
StripeInvoiceFactory({
|
||||
payment_intent: null,
|
||||
})
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'invoicesRetrieve')
|
||||
.mockResolvedValue(mockInvoice);
|
||||
|
||||
const result = await stripeManager.getLatestPaymentIntent(
|
||||
mockSubscription
|
||||
);
|
||||
|
||||
expect(result).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,321 +0,0 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Stripe } from 'stripe';
|
||||
|
||||
import { StripeClient } from './stripe.client';
|
||||
import {
|
||||
StripeCustomer,
|
||||
StripePlan,
|
||||
StripeSubscription,
|
||||
} from './stripe.client.types';
|
||||
import { StripeConfig } from './stripe.config';
|
||||
import {
|
||||
MOZILLA_TAX_ID,
|
||||
STRIPE_MINIMUM_CHARGE_AMOUNTS,
|
||||
} from './stripe.constants';
|
||||
import {
|
||||
CustomerDeletedError,
|
||||
CustomerNotFoundError,
|
||||
PlanIntervalMultiplePlansError,
|
||||
PlanNotFoundError,
|
||||
ProductNotFoundError,
|
||||
StripeNoMinimumChargeAmountAvailableError,
|
||||
} from './stripe.error';
|
||||
import { SubplatInterval, TaxAddress } from './stripe.types';
|
||||
import { doesPlanMatchSubplatInterval } from './util/doesPlanMatchSubplatInterval';
|
||||
import { stripeInvoiceToFirstInvoicePreviewDTO } from './util/stripeInvoiceToFirstInvoicePreviewDTO';
|
||||
|
||||
@Injectable()
|
||||
export class StripeManager {
|
||||
private taxIds: { [key: string]: string };
|
||||
constructor(private client: StripeClient, private config: StripeConfig) {
|
||||
this.taxIds = this.config.taxIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns minimum amount for valid currency
|
||||
* Throws error for invalid currency
|
||||
*/
|
||||
getMinimumAmount(currency: string): number {
|
||||
if (STRIPE_MINIMUM_CHARGE_AMOUNTS[currency]) {
|
||||
return STRIPE_MINIMUM_CHARGE_AMOUNTS[currency];
|
||||
}
|
||||
|
||||
throw new StripeNoMinimumChargeAmountAvailableError();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the correct tax id for currency
|
||||
*/
|
||||
getTaxIdForCurrency(currency: string) {
|
||||
return this.taxIds[currency.toUpperCase()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a customer record
|
||||
*/
|
||||
async fetchActiveCustomer(customerId: string) {
|
||||
const customer = await this.client.customersRetrieve(customerId);
|
||||
if (customer.deleted) throw new CustomerDeletedError();
|
||||
return customer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a customer record
|
||||
*/
|
||||
async updateCustomer(
|
||||
customerId: string,
|
||||
params?: Stripe.CustomerUpdateParams
|
||||
) {
|
||||
return await this.client.customersUpdate(customerId, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create customer stub account
|
||||
*/
|
||||
async createPlainCustomer(args: {
|
||||
uid: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
taxAddress?: TaxAddress;
|
||||
}) {
|
||||
const { uid, email, displayName, taxAddress } = args;
|
||||
|
||||
const shipping = taxAddress
|
||||
? {
|
||||
name: email,
|
||||
address: {
|
||||
country: taxAddress.countryCode,
|
||||
postal_code: taxAddress.postalCode,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const stripeCustomer = await this.client.customersCreate({
|
||||
email,
|
||||
name: displayName || '',
|
||||
description: uid,
|
||||
metadata: {
|
||||
userid: uid,
|
||||
geoip_date: new Date().toString(),
|
||||
},
|
||||
shipping,
|
||||
});
|
||||
|
||||
return stripeCustomer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalizes an invoice and marks auto_advance as false.
|
||||
*/
|
||||
async finalizeInvoiceWithoutAutoAdvance(invoiceId: string) {
|
||||
return this.client.invoicesFinalizeInvoice(invoiceId, {
|
||||
auto_advance: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns upcoming invoice
|
||||
*/
|
||||
async previewInvoice({
|
||||
priceId,
|
||||
customer,
|
||||
taxAddress,
|
||||
}: {
|
||||
priceId: string;
|
||||
customer?: StripeCustomer;
|
||||
taxAddress?: TaxAddress;
|
||||
}) {
|
||||
const automaticTax = !!(
|
||||
(customer && this.isCustomerStripeTaxEligible(customer)) ||
|
||||
(!customer && taxAddress)
|
||||
);
|
||||
|
||||
const shipping =
|
||||
!customer && taxAddress
|
||||
? {
|
||||
name: '',
|
||||
address: {
|
||||
country: taxAddress.countryCode,
|
||||
postal_code: taxAddress.postalCode,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const requestObject: Stripe.InvoiceRetrieveUpcomingParams = {
|
||||
customer: customer?.id,
|
||||
automatic_tax: {
|
||||
enabled: automaticTax,
|
||||
},
|
||||
customer_details: {
|
||||
tax_exempt: 'none', // Param required when shipping address not present
|
||||
shipping,
|
||||
},
|
||||
subscription_items: [{ price: priceId }],
|
||||
};
|
||||
|
||||
const upcomingInvoice = await this.client.invoicesRetrieveUpcoming(
|
||||
requestObject
|
||||
);
|
||||
|
||||
return stripeInvoiceToFirstInvoicePreviewDTO(upcomingInvoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels incomplete subscription
|
||||
*/
|
||||
async cancelIncompleteSubscriptionsToPrice(
|
||||
customerId: string,
|
||||
priceId: string
|
||||
) {
|
||||
const subscriptions = await this.getSubscriptions(customerId);
|
||||
const targetSubs = subscriptions.filter((sub) =>
|
||||
sub.items.data.find((item) => item.price.id === priceId)
|
||||
);
|
||||
|
||||
for (const sub of targetSubs) {
|
||||
if (sub && sub.status === 'incomplete') {
|
||||
await this.client.subscriptionsCancel(sub.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves subscriptions
|
||||
*/
|
||||
async getSubscriptions(customerId: string) {
|
||||
const result = await this.client.subscriptionsList({
|
||||
customer: customerId,
|
||||
});
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async cancelSubscription(subscriptionId: string) {
|
||||
return this.client.subscriptionsCancel(subscriptionId);
|
||||
}
|
||||
|
||||
async retrieveSubscription(subscriptionId: string) {
|
||||
return this.client.subscriptionsRetrieve(subscriptionId);
|
||||
}
|
||||
|
||||
async updateSubscription(
|
||||
subscriptionId: string,
|
||||
params?: Stripe.SubscriptionUpdateParams
|
||||
) {
|
||||
return this.client.subscriptionsUpdate(subscriptionId, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if customer's automatic tax status indicates that they're eligible for automatic tax.
|
||||
* Creating a subscription with automatic_tax enabled requires a customer with an address
|
||||
* that is in a recognized location with an active tax registration.
|
||||
*/
|
||||
isCustomerStripeTaxEligible(customer: StripeCustomer) {
|
||||
return (
|
||||
customer.tax.automatic_tax === 'supported' ||
|
||||
customer.tax.automatic_tax === 'not_collecting'
|
||||
);
|
||||
}
|
||||
|
||||
async getPromotionCodeByName(code: string, active?: boolean) {
|
||||
const promotionCodes = await this.client.promotionCodesList({
|
||||
active,
|
||||
code,
|
||||
});
|
||||
|
||||
return promotionCodes.data.at(0);
|
||||
}
|
||||
|
||||
async retrievePromotionCode(id: string) {
|
||||
return this.client.promotionCodesRetrieve(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates customer object with incoming tax ID if existing tax ID does not match
|
||||
*
|
||||
* @param customerId Customer ID of customer to be updated
|
||||
* @param taxId Customer tax ID to be updated if different from existing tax ID
|
||||
* @returns True if the customer was updated, false otherwise
|
||||
*/
|
||||
async setCustomerTaxId(customerId: string, taxId: string) {
|
||||
const customerTaxId = await this.getCustomerTaxId(customerId);
|
||||
|
||||
if (!customerTaxId || customerTaxId !== taxId) {
|
||||
return await this.client.customersUpdate(customerId, {
|
||||
invoice_settings: {
|
||||
custom_fields: [{ name: MOZILLA_TAX_ID, value: taxId }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns tax ID of customer
|
||||
*
|
||||
* @param customerId ID of customer to fetch and check existing tax ID
|
||||
* @returns The tax ID of customer or undefined if not found
|
||||
*/
|
||||
async getCustomerTaxId(customerId: string) {
|
||||
const customer = await this.client.customersRetrieve(customerId);
|
||||
|
||||
if (!customer) throw new CustomerNotFoundError();
|
||||
if (customer.deleted) throw new CustomerDeletedError();
|
||||
|
||||
const customFields = customer.invoice_settings.custom_fields || [];
|
||||
|
||||
const taxIdFields = customFields.filter((customField: any) => {
|
||||
return customField.name === MOZILLA_TAX_ID;
|
||||
});
|
||||
|
||||
return taxIdFields.at(0)?.value;
|
||||
}
|
||||
|
||||
async getPlan(planId: string) {
|
||||
const plan = await this.client.plansRetrieve(planId);
|
||||
if (!plan) throw new PlanNotFoundError();
|
||||
return plan;
|
||||
}
|
||||
|
||||
async getPlanByInterval(planIds: string[], interval: SubplatInterval) {
|
||||
const plans: StripePlan[] = [];
|
||||
for (const planId of planIds) {
|
||||
const plan = await this.getPlan(planId);
|
||||
if (doesPlanMatchSubplatInterval(plan, interval)) {
|
||||
plans.push(plan);
|
||||
}
|
||||
}
|
||||
if (plans.length > 1) throw new PlanIntervalMultiplePlansError();
|
||||
return plans.at(0);
|
||||
}
|
||||
|
||||
async retrieveProduct(productId: string) {
|
||||
const product = await this.client.productsRetrieve(productId);
|
||||
if (!product) throw new ProductNotFoundError();
|
||||
return product;
|
||||
}
|
||||
|
||||
async getLatestPaymentIntent(subscription: StripeSubscription) {
|
||||
if (!subscription.latest_invoice) {
|
||||
return;
|
||||
}
|
||||
const latestInvoice = await this.client.invoicesRetrieve(
|
||||
subscription.latest_invoice
|
||||
);
|
||||
|
||||
if (!latestInvoice.payment_intent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentIntent = await this.client.paymentIntentRetrieve(
|
||||
latestInvoice.payment_intent
|
||||
);
|
||||
|
||||
return paymentIntent;
|
||||
}
|
||||
}
|
|
@ -30,25 +30,33 @@ import {
|
|||
import { StripeClient } from './stripe.client';
|
||||
import { MockStripeConfigProvider } from './stripe.config';
|
||||
import { PromotionCodeCouldNotBeAttachedError } from './stripe.error';
|
||||
import { StripeManager } from './stripe.manager';
|
||||
import { ProductManager } from './product.manager';
|
||||
import { StripeService } from './stripe.service';
|
||||
import { STRIPE_PRICE_METADATA } from './stripe.types';
|
||||
import { SubscriptionManager } from './subscription.manager';
|
||||
import { PromotionCodeManager } from './promotionCode.manager';
|
||||
|
||||
describe('StripeService', () => {
|
||||
let stripeService: StripeService;
|
||||
let stripeManager: StripeManager;
|
||||
let productManager: ProductManager;
|
||||
let subscriptionManager: SubscriptionManager;
|
||||
let promotionCodeManager: PromotionCodeManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
MockStripeConfigProvider,
|
||||
StripeClient,
|
||||
StripeManager,
|
||||
ProductManager,
|
||||
SubscriptionManager,
|
||||
PromotionCodeManager,
|
||||
StripeService,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
stripeManager = module.get(StripeManager);
|
||||
productManager = module.get(ProductManager);
|
||||
subscriptionManager = module.get(SubscriptionManager);
|
||||
promotionCodeManager = module.get(PromotionCodeManager);
|
||||
stripeService = module.get(StripeService);
|
||||
});
|
||||
|
||||
|
@ -62,7 +70,7 @@ describe('StripeService', () => {
|
|||
const mockResponse = StripeResponseFactory(mockSubscription);
|
||||
|
||||
jest
|
||||
.spyOn(stripeManager, 'retrieveSubscription')
|
||||
.spyOn(subscriptionManager, 'retrieve')
|
||||
.mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(
|
||||
|
@ -83,7 +91,7 @@ describe('StripeService', () => {
|
|||
const mockResponse = StripeResponseFactory(mockSubscription);
|
||||
|
||||
jest
|
||||
.spyOn(stripeManager, 'retrieveSubscription')
|
||||
.spyOn(subscriptionManager, 'retrieve')
|
||||
.mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(
|
||||
|
@ -106,11 +114,11 @@ describe('StripeService', () => {
|
|||
const mockSubResponse = StripeResponseFactory(mockSubscription);
|
||||
|
||||
jest
|
||||
.spyOn(stripeManager, 'retrieveSubscription')
|
||||
.spyOn(subscriptionManager, 'retrieve')
|
||||
.mockResolvedValue(mockSubResponse);
|
||||
|
||||
jest
|
||||
.spyOn(stripeManager, 'retrievePromotionCode')
|
||||
.spyOn(promotionCodeManager, 'retrieve')
|
||||
.mockResolvedValue(mockPromoResponse);
|
||||
|
||||
mockStripeUtil.checkValidPromotionCode.mockImplementation(() => {
|
||||
|
@ -139,11 +147,11 @@ describe('StripeService', () => {
|
|||
const mockSubResponse = StripeResponseFactory(mockSubscription);
|
||||
|
||||
jest
|
||||
.spyOn(stripeManager, 'retrieveSubscription')
|
||||
.spyOn(subscriptionManager, 'retrieve')
|
||||
.mockResolvedValue(mockSubResponse);
|
||||
|
||||
jest
|
||||
.spyOn(stripeManager, 'retrievePromotionCode')
|
||||
.spyOn(promotionCodeManager, 'retrieve')
|
||||
.mockResolvedValue(mockPromoResponse);
|
||||
|
||||
mockStripeUtil.checkValidPromotionCode.mockReturnValue(true);
|
||||
|
@ -177,18 +185,18 @@ describe('StripeService', () => {
|
|||
const mockPromoResponse = StripeResponseFactory(mockPromotionCode);
|
||||
|
||||
jest
|
||||
.spyOn(stripeManager, 'retrieveSubscription')
|
||||
.spyOn(subscriptionManager, 'retrieve')
|
||||
.mockResolvedValue(mockSubResponse);
|
||||
|
||||
jest
|
||||
.spyOn(stripeManager, 'retrievePromotionCode')
|
||||
.spyOn(promotionCodeManager, 'retrieve')
|
||||
.mockResolvedValue(mockPromoResponse);
|
||||
|
||||
mockStripeUtil.checkValidPromotionCode.mockReturnValue(true);
|
||||
mockStripeUtil.getSubscribedPrice.mockReturnValue(mockPrice);
|
||||
|
||||
jest
|
||||
.spyOn(stripeManager, 'retrieveProduct')
|
||||
.spyOn(productManager, 'retrieve')
|
||||
.mockResolvedValue(StripeResponseFactory(mockProduct));
|
||||
|
||||
mockStripeUtil.checkSubscriptionPromotionCodes.mockImplementation(() => {
|
||||
|
@ -215,11 +223,9 @@ describe('StripeService', () => {
|
|||
type: 'invalid_request_error',
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(stripeManager, 'retrieveSubscription')
|
||||
.mockImplementation(() => {
|
||||
throw stripeError;
|
||||
});
|
||||
jest.spyOn(subscriptionManager, 'retrieve').mockImplementation(() => {
|
||||
throw stripeError;
|
||||
});
|
||||
|
||||
await expect(
|
||||
stripeService.applyPromoCodeToSubscription(
|
||||
|
@ -270,11 +276,11 @@ describe('StripeService', () => {
|
|||
const mockProduct = StripeProductFactory();
|
||||
|
||||
jest
|
||||
.spyOn(stripeManager, 'retrieveSubscription')
|
||||
.spyOn(subscriptionManager, 'retrieve')
|
||||
.mockResolvedValue(mockSubResponse1);
|
||||
|
||||
jest
|
||||
.spyOn(stripeManager, 'retrievePromotionCode')
|
||||
.spyOn(promotionCodeManager, 'retrieve')
|
||||
.mockResolvedValue(mockPromoCodeResponse);
|
||||
|
||||
mockStripeUtil.checkValidPromotionCode.mockReturnValue(true);
|
||||
|
@ -282,11 +288,11 @@ describe('StripeService', () => {
|
|||
mockStripeUtil.checkSubscriptionPromotionCodes.mockReturnValue(true);
|
||||
|
||||
jest
|
||||
.spyOn(stripeManager, 'retrieveProduct')
|
||||
.spyOn(productManager, 'retrieve')
|
||||
.mockResolvedValue(StripeResponseFactory(mockProduct));
|
||||
|
||||
jest
|
||||
.spyOn(stripeManager, 'updateSubscription')
|
||||
.spyOn(subscriptionManager, 'update')
|
||||
.mockResolvedValue(mockSubResponse2);
|
||||
|
||||
const result = await stripeService.applyPromoCodeToSubscription(
|
||||
|
|
|
@ -4,24 +4,31 @@
|
|||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PromotionCodeCouldNotBeAttachedError } from './stripe.error';
|
||||
import { StripeManager } from './stripe.manager';
|
||||
import {
|
||||
checkSubscriptionPromotionCodes,
|
||||
checkValidPromotionCode,
|
||||
getSubscribedPrice,
|
||||
} from './stripe.util';
|
||||
import { SubscriptionManager } from './subscription.manager';
|
||||
import { PromotionCodeManager } from './promotionCode.manager';
|
||||
import { ProductManager } from './product.manager';
|
||||
|
||||
@Injectable()
|
||||
export class StripeService {
|
||||
constructor(private stripeManager: StripeManager) {}
|
||||
constructor(
|
||||
private productManager: ProductManager,
|
||||
private subscriptionManager: SubscriptionManager,
|
||||
private promotionCodeManager: PromotionCodeManager
|
||||
) {}
|
||||
|
||||
// TODO: Remove this method & this service
|
||||
async applyPromoCodeToSubscription(
|
||||
customerId: string,
|
||||
subscriptionId: string,
|
||||
promotionId: string
|
||||
) {
|
||||
try {
|
||||
const subscription = await this.stripeManager.retrieveSubscription(
|
||||
const subscription = await this.subscriptionManager.retrieve(
|
||||
subscriptionId
|
||||
);
|
||||
if (subscription?.status !== 'active')
|
||||
|
@ -40,7 +47,7 @@ export class StripeService {
|
|||
}
|
||||
);
|
||||
|
||||
const promotionCode = await this.stripeManager.retrievePromotionCode(
|
||||
const promotionCode = await this.promotionCodeManager.retrieve(
|
||||
promotionId
|
||||
);
|
||||
|
||||
|
@ -48,11 +55,11 @@ export class StripeService {
|
|||
|
||||
const price = getSubscribedPrice(subscription);
|
||||
const productId = price.product;
|
||||
const product = await this.stripeManager.retrieveProduct(productId);
|
||||
const product = await this.productManager.retrieve(productId);
|
||||
|
||||
checkSubscriptionPromotionCodes(promotionCode, price, product);
|
||||
|
||||
const updatedSubscription = await this.stripeManager.updateSubscription(
|
||||
const updatedSubscription = await this.subscriptionManager.update(
|
||||
subscriptionId,
|
||||
{
|
||||
discounts: [
|
||||
|
|
|
@ -0,0 +1,219 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import {
|
||||
StripeApiListFactory,
|
||||
StripeResponseFactory,
|
||||
} from './factories/api-list.factory';
|
||||
import { StripeCustomerFactory } from './factories/customer.factory';
|
||||
import { StripeInvoiceFactory } from './factories/invoice.factory';
|
||||
import { StripePaymentIntentFactory } from './factories/payment-intent.factory';
|
||||
import { StripeSubscriptionFactory } from './factories/subscription.factory';
|
||||
import { StripeClient } from './stripe.client';
|
||||
import { MockStripeConfigProvider } from './stripe.config';
|
||||
import { SubscriptionManager } from './subscription.manager';
|
||||
|
||||
describe('SubscriptionManager', () => {
|
||||
let subscriptionManager: SubscriptionManager;
|
||||
let stripeClient: StripeClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [MockStripeConfigProvider, StripeClient, SubscriptionManager],
|
||||
}).compile();
|
||||
|
||||
subscriptionManager = module.get(SubscriptionManager);
|
||||
stripeClient = module.get(StripeClient);
|
||||
});
|
||||
|
||||
describe('getMinimumAmount', () => {
|
||||
it('returns minimum amout for valid currency', () => {
|
||||
const expected = 50;
|
||||
const result = subscriptionManager.getMinimumAmount('usd');
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should throw an error if currency is invalid', () => {
|
||||
expect(() => subscriptionManager.getMinimumAmount('fake')).toThrow(
|
||||
'Currency does not have a minimum charge amount available.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelIncompleteSubscriptionsToPrice', () => {
|
||||
it('cancels incomplete subscriptions', async () => {
|
||||
const mockCustomer = StripeCustomerFactory();
|
||||
const mockSubscription = StripeSubscriptionFactory({
|
||||
status: 'incomplete',
|
||||
});
|
||||
const mockSubscriptionList = [mockSubscription];
|
||||
const mockPrice = mockSubscription.items.data[0].price;
|
||||
const mockResponse = StripeResponseFactory(mockSubscription);
|
||||
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'listForCustomer')
|
||||
.mockResolvedValue(mockSubscriptionList);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'subscriptionsCancel')
|
||||
.mockResolvedValue(mockResponse);
|
||||
|
||||
await subscriptionManager.cancelIncompleteSubscriptionsToPrice(
|
||||
mockCustomer.id,
|
||||
mockPrice.id
|
||||
);
|
||||
|
||||
expect(stripeClient.subscriptionsCancel).toBeCalledWith(
|
||||
mockSubscription.id
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listForCustomer', () => {
|
||||
it('returns subscriptions', async () => {
|
||||
const mockSubscription = StripeSubscriptionFactory();
|
||||
const mockSubscriptionList = StripeApiListFactory([mockSubscription]);
|
||||
const mockCustomer = StripeCustomerFactory();
|
||||
|
||||
const expected = mockSubscriptionList.data;
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'subscriptionsList')
|
||||
.mockResolvedValue(mockSubscriptionList);
|
||||
|
||||
const result = await subscriptionManager.listForCustomer(mockCustomer.id);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('returns empty array if no subscriptions exist', async () => {
|
||||
const mockCustomer = StripeCustomerFactory();
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'subscriptionsList')
|
||||
.mockResolvedValue(StripeApiListFactory([]));
|
||||
|
||||
const result = await subscriptionManager.listForCustomer(mockCustomer.id);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('calls stripeclient', async () => {
|
||||
const mockSubscription = StripeSubscriptionFactory();
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'subscriptionsCancel')
|
||||
.mockResolvedValue(StripeResponseFactory(mockSubscription));
|
||||
|
||||
await subscriptionManager.cancel(mockSubscription.id);
|
||||
|
||||
expect(stripeClient.subscriptionsCancel).toBeCalledWith(
|
||||
mockSubscription.id
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retrieve', () => {
|
||||
it('calls stripeclient', async () => {
|
||||
const mockSubscription = StripeSubscriptionFactory();
|
||||
const mockResponse = StripeResponseFactory(mockSubscription);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'subscriptionsRetrieve')
|
||||
.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await subscriptionManager.retrieve(mockSubscription.id);
|
||||
|
||||
expect(stripeClient.subscriptionsRetrieve).toBeCalledWith(
|
||||
mockSubscription.id
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('calls stripeclient', async () => {
|
||||
const mockParams = {
|
||||
description: 'This is an updated subscription',
|
||||
};
|
||||
const mockSubscription = StripeSubscriptionFactory(mockParams);
|
||||
const mockResponse = StripeResponseFactory(mockSubscription);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'subscriptionsUpdate')
|
||||
.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await subscriptionManager.update(
|
||||
mockSubscription.id,
|
||||
mockParams
|
||||
);
|
||||
|
||||
expect(stripeClient.subscriptionsUpdate).toBeCalledWith(
|
||||
mockSubscription.id,
|
||||
mockParams
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLatestPaymentIntent', () => {
|
||||
it('fetches the latest payment intent for the subscription', async () => {
|
||||
const mockSubscription = StripeResponseFactory(
|
||||
StripeSubscriptionFactory()
|
||||
);
|
||||
const mockInvoice = StripeResponseFactory(StripeInvoiceFactory());
|
||||
const mockPaymentIntent = StripeResponseFactory(
|
||||
StripePaymentIntentFactory()
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'invoicesRetrieve')
|
||||
.mockResolvedValue(mockInvoice);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'paymentIntentRetrieve')
|
||||
.mockResolvedValue(mockPaymentIntent);
|
||||
|
||||
const result = await subscriptionManager.getLatestPaymentIntent(
|
||||
mockSubscription
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockPaymentIntent);
|
||||
});
|
||||
|
||||
it('returns undefined if no invoice on subscription', async () => {
|
||||
const mockSubscription = StripeSubscriptionFactory({
|
||||
latest_invoice: null,
|
||||
});
|
||||
|
||||
const result = await subscriptionManager.getLatestPaymentIntent(
|
||||
mockSubscription
|
||||
);
|
||||
|
||||
expect(result).toEqual(undefined);
|
||||
});
|
||||
|
||||
it('returns undefined if the invoice has no payment intent', async () => {
|
||||
const mockSubscription = StripeSubscriptionFactory();
|
||||
const mockInvoice = StripeResponseFactory(
|
||||
StripeInvoiceFactory({
|
||||
payment_intent: null,
|
||||
})
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'invoicesRetrieve')
|
||||
.mockResolvedValue(mockInvoice);
|
||||
|
||||
const result = await subscriptionManager.getLatestPaymentIntent(
|
||||
mockSubscription
|
||||
);
|
||||
|
||||
expect(result).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,86 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Stripe } from 'stripe';
|
||||
|
||||
import { StripeClient } from './stripe.client';
|
||||
import { StripeSubscription } from './stripe.client.types';
|
||||
import { STRIPE_MINIMUM_CHARGE_AMOUNTS } from './stripe.constants';
|
||||
import { StripeNoMinimumChargeAmountAvailableError } from './stripe.error';
|
||||
|
||||
@Injectable()
|
||||
export class SubscriptionManager {
|
||||
constructor(private client: StripeClient) {}
|
||||
|
||||
async cancel(subscriptionId: string) {
|
||||
return this.client.subscriptionsCancel(subscriptionId);
|
||||
}
|
||||
|
||||
async retrieve(subscriptionId: string) {
|
||||
return this.client.subscriptionsRetrieve(subscriptionId);
|
||||
}
|
||||
|
||||
async update(
|
||||
subscriptionId: string,
|
||||
params?: Stripe.SubscriptionUpdateParams
|
||||
) {
|
||||
return this.client.subscriptionsUpdate(subscriptionId, params);
|
||||
}
|
||||
|
||||
async listForCustomer(customerId: string) {
|
||||
const result = await this.client.subscriptionsList({
|
||||
customer: customerId,
|
||||
});
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns minimum charge amount for currency
|
||||
* Throws error for invalid currency
|
||||
*/
|
||||
getMinimumAmount(currency: string): number {
|
||||
if (STRIPE_MINIMUM_CHARGE_AMOUNTS[currency]) {
|
||||
return STRIPE_MINIMUM_CHARGE_AMOUNTS[currency];
|
||||
}
|
||||
|
||||
throw new StripeNoMinimumChargeAmountAvailableError();
|
||||
}
|
||||
|
||||
async cancelIncompleteSubscriptionsToPrice(
|
||||
customerId: string,
|
||||
priceId: string
|
||||
) {
|
||||
const subscriptions = await this.listForCustomer(customerId);
|
||||
const targetSubs = subscriptions.filter((sub) =>
|
||||
sub.items.data.find((item) => item.price.id === priceId)
|
||||
);
|
||||
|
||||
for (const sub of targetSubs) {
|
||||
if (sub && sub.status === 'incomplete') {
|
||||
await this.client.subscriptionsCancel(sub.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getLatestPaymentIntent(subscription: StripeSubscription) {
|
||||
if (!subscription.latest_invoice) {
|
||||
return;
|
||||
}
|
||||
const latestInvoice = await this.client.invoicesRetrieve(
|
||||
subscription.latest_invoice
|
||||
);
|
||||
|
||||
if (!latestInvoice.payment_intent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentIntent = await this.client.paymentIntentRetrieve(
|
||||
latestInvoice.payment_intent
|
||||
);
|
||||
|
||||
return paymentIntent;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import { StripeCustomer } from '../stripe.client.types';
|
||||
|
||||
export const isCustomerTaxEligible = (customer: StripeCustomer) => {
|
||||
return (
|
||||
customer.tax.automatic_tax === 'supported' ||
|
||||
customer.tax.automatic_tax === 'not_collecting'
|
||||
);
|
||||
};
|
|
@ -18,7 +18,12 @@ import {
|
|||
import {
|
||||
AccountCustomerManager,
|
||||
StripeClient,
|
||||
StripeManager,
|
||||
CustomerManager,
|
||||
InvoiceManager,
|
||||
ProductManager,
|
||||
PriceManager,
|
||||
SubscriptionManager,
|
||||
PromotionCodeManager,
|
||||
} from '@fxa/payments/stripe';
|
||||
import {
|
||||
ContentfulClient,
|
||||
|
@ -65,7 +70,12 @@ import { AccountManager } from '@fxa/shared/account/account';
|
|||
ContentfulClient,
|
||||
ContentfulManager,
|
||||
ContentfulService,
|
||||
StripeManager,
|
||||
CustomerManager,
|
||||
InvoiceManager,
|
||||
ProductManager,
|
||||
PriceManager,
|
||||
SubscriptionManager,
|
||||
PromotionCodeManager,
|
||||
StripeClient,
|
||||
PayPalClient,
|
||||
PaypalCustomerManager,
|
||||
|
|
|
@ -7,7 +7,7 @@ import { Test } from '@nestjs/testing';
|
|||
import {
|
||||
StripeClient,
|
||||
StripeConfig,
|
||||
StripeManager,
|
||||
PriceManager,
|
||||
StripePlanFactory,
|
||||
StripeResponseFactory,
|
||||
SubplatInterval,
|
||||
|
@ -32,7 +32,7 @@ import {
|
|||
describe('ContentfulService', () => {
|
||||
let contentfulManager: ContentfulManager;
|
||||
let contentfulService: ContentfulService;
|
||||
let stripeManager: StripeManager;
|
||||
let priceManager: PriceManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
|
@ -47,13 +47,13 @@ describe('ContentfulService', () => {
|
|||
MockStatsDProvider,
|
||||
StripeClient,
|
||||
StripeConfig,
|
||||
StripeManager,
|
||||
PriceManager,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
contentfulService = moduleRef.get(ContentfulService);
|
||||
contentfulManager = moduleRef.get(ContentfulManager);
|
||||
stripeManager = moduleRef.get(StripeManager);
|
||||
priceManager = moduleRef.get(PriceManager);
|
||||
});
|
||||
|
||||
describe('fetchContentfulData', () => {
|
||||
|
@ -88,7 +88,7 @@ describe('ContentfulService', () => {
|
|||
.mockResolvedValue([mockPlan.id]);
|
||||
|
||||
jest
|
||||
.spyOn(stripeManager, 'getPlanByInterval')
|
||||
.spyOn(priceManager, 'retrieveByInterval')
|
||||
.mockResolvedValue(mockPlan);
|
||||
|
||||
const result = await contentfulService.retrieveStripePlanId(
|
||||
|
@ -108,7 +108,7 @@ describe('ContentfulService', () => {
|
|||
.mockResolvedValue([mockPlan.id]);
|
||||
|
||||
jest
|
||||
.spyOn(stripeManager, 'getPlanByInterval')
|
||||
.spyOn(priceManager, 'retrieveByInterval')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { StripeManager, SubplatInterval } from '@fxa/payments/stripe';
|
||||
import { PriceManager, SubplatInterval } from '@fxa/payments/stripe';
|
||||
import { ContentfulServiceError } from './contentful.error';
|
||||
import { ContentfulManager } from './contentful.manager';
|
||||
import { ContentfulServiceConfig } from './contentful.service.config';
|
||||
|
@ -12,7 +12,7 @@ import { ContentfulServiceConfig } from './contentful.service.config';
|
|||
export class ContentfulService {
|
||||
constructor(
|
||||
private contentfulManager: ContentfulManager,
|
||||
private stripeManager: StripeManager,
|
||||
private priceManager: PriceManager,
|
||||
private contentfulServiceConfig: ContentfulServiceConfig
|
||||
) {}
|
||||
|
||||
|
@ -42,7 +42,7 @@ export class ContentfulService {
|
|||
const filteredPlanIds = planIds.filter((priceId) =>
|
||||
supportedListOfPriceIds.includes(priceId)
|
||||
);
|
||||
const plan = await this.stripeManager.getPlanByInterval(
|
||||
const plan = await this.priceManager.retrieveByInterval(
|
||||
filteredPlanIds,
|
||||
interval
|
||||
);
|
||||
|
|
|
@ -13,7 +13,10 @@ const { CapabilityManager } = require('@fxa/payments/capability');
|
|||
const { EligibilityManager } = require('@fxa/payments/eligibility');
|
||||
const {
|
||||
StripeClient,
|
||||
StripeManager,
|
||||
ProductManager,
|
||||
PriceManager,
|
||||
SubscriptionManager,
|
||||
PromotionCodeManager,
|
||||
StripeService,
|
||||
} = require('@fxa/payments/stripe');
|
||||
const {
|
||||
|
@ -127,6 +130,20 @@ async function run(config) {
|
|||
/** @type {undefined | import('../lib/payments/stripe').StripeHelper} */
|
||||
let stripeHelper = undefined;
|
||||
if (config.subscriptions && config.subscriptions.stripeApiKey) {
|
||||
const stripeClient = new StripeClient({
|
||||
apiKey: config.subscriptions.stripeApiKey,
|
||||
});
|
||||
const productManager = new ProductManager(stripeClient);
|
||||
const priceManager = new PriceManager(stripeClient);
|
||||
const subscriptionManager = new SubscriptionManager(stripeClient);
|
||||
const promotionCodeManager = new PromotionCodeManager(stripeClient);
|
||||
const stripeService = new StripeService(
|
||||
productManager,
|
||||
subscriptionManager,
|
||||
promotionCodeManager
|
||||
);
|
||||
Container.set(StripeService, stripeService);
|
||||
|
||||
if (
|
||||
config.contentful &&
|
||||
config.contentful.cdnUrl &&
|
||||
|
@ -152,23 +169,14 @@ async function run(config) {
|
|||
const contentfulManager = new ContentfulManager(contentfulClient, statsd);
|
||||
Container.set(ContentfulManager, contentfulManager);
|
||||
const capabilityManager = new CapabilityManager(contentfulManager);
|
||||
const eligibilityManager = new EligibilityManager(contentfulManager);
|
||||
const eligibilityManager = new EligibilityManager(
|
||||
contentfulManager,
|
||||
priceManager
|
||||
);
|
||||
Container.set(CapabilityManager, capabilityManager);
|
||||
Container.set(EligibilityManager, eligibilityManager);
|
||||
}
|
||||
|
||||
if (config.subscriptions.stripeApiKey && config.subscriptions.taxIds) {
|
||||
const stripeClient = new StripeClient({
|
||||
apiKey: config.subscriptions.stripeApiKey,
|
||||
});
|
||||
const stripeManager = new StripeManager(stripeClient, {
|
||||
apiKey: config.subscriptions.stripeApiKey,
|
||||
taxIds: config.subscriptions.taxIds,
|
||||
});
|
||||
const stripeService = new StripeService(stripeManager);
|
||||
Container.set(StripeService, stripeService);
|
||||
}
|
||||
|
||||
const { createStripeHelper } = require('../lib/payments/stripe');
|
||||
stripeHelper = createStripeHelper(log, config, statsd);
|
||||
Container.set(StripeHelper, stripeHelper);
|
||||
|
|
Загрузка…
Ссылка в новой задаче