зеркало из https://github.com/mozilla/fxa.git
Merge pull request #17961 from mozilla/FXA-10189-payPal-processNonZeroInvoice
setup paypal processnonzeroinvoice
This commit is contained in:
Коммит
b42e55ddb9
|
@ -40,6 +40,8 @@ import {
|
|||
AccountCustomerManager,
|
||||
StripeSubscriptionFactory,
|
||||
StripePaymentMethodFactory,
|
||||
StripePaymentIntentFactory,
|
||||
StripeInvoiceFactory,
|
||||
} from '@fxa/payments/stripe';
|
||||
import {
|
||||
MockProfileClientConfigProvider,
|
||||
|
@ -106,6 +108,7 @@ describe('CartService', () => {
|
|||
let productConfigurationManager: ProductConfigurationManager;
|
||||
let subscriptionManager: SubscriptionManager;
|
||||
let paymentMethodManager: PaymentMethodManager;
|
||||
let stripeClient: StripeClient;
|
||||
|
||||
const mockLogger = {
|
||||
error: jest.fn(),
|
||||
|
@ -173,6 +176,7 @@ describe('CartService', () => {
|
|||
productConfigurationManager = moduleRef.get(ProductConfigurationManager);
|
||||
subscriptionManager = moduleRef.get(SubscriptionManager);
|
||||
paymentMethodManager = moduleRef.get(PaymentMethodManager);
|
||||
stripeClient = moduleRef.get(StripeClient);
|
||||
});
|
||||
|
||||
describe('setupCart', () => {
|
||||
|
@ -470,6 +474,107 @@ describe('CartService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('pollCart', () => {
|
||||
it('returns cartState if cart is in failed state', async () => {
|
||||
const mockart = ResultCartFactory({ state: CartState.FAIL });
|
||||
|
||||
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockart);
|
||||
|
||||
const result = await cartService.pollCart(mockart.id);
|
||||
|
||||
expect(result).toEqual({ cartState: mockart.state });
|
||||
});
|
||||
|
||||
it('returns cartState if cart is in success state', async () => {
|
||||
const mockart = ResultCartFactory({ state: CartState.SUCCESS });
|
||||
|
||||
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockart);
|
||||
|
||||
const result = await cartService.pollCart(mockart.id);
|
||||
|
||||
expect(result).toEqual({ cartState: mockart.state });
|
||||
});
|
||||
|
||||
it('calls invoiceManager.processPayPalNonZeroInvoice for send_invoice subscriptions', async () => {
|
||||
const mockSubscriptionId = faker.string.uuid();
|
||||
const mockInvoiceId = faker.string.uuid();
|
||||
const mockCart = ResultCartFactory({
|
||||
state: CartState.PROCESSING,
|
||||
stripeSubscriptionId: mockSubscriptionId,
|
||||
});
|
||||
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
|
||||
const mockInvoice = StripeResponseFactory(
|
||||
StripeInvoiceFactory({
|
||||
id: mockInvoiceId,
|
||||
currency: 'usd',
|
||||
amount_due: 1000,
|
||||
tax: 100,
|
||||
})
|
||||
);
|
||||
const mockSubscription = StripeResponseFactory(
|
||||
StripeSubscriptionFactory({
|
||||
id: mockSubscriptionId,
|
||||
latest_invoice: mockInvoiceId,
|
||||
collection_method: 'send_invoice',
|
||||
})
|
||||
);
|
||||
|
||||
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'retrieve')
|
||||
.mockResolvedValue(mockSubscription);
|
||||
jest
|
||||
.spyOn(stripeClient, 'customersRetrieve')
|
||||
.mockResolvedValue(mockCustomer);
|
||||
jest.spyOn(invoiceManager, 'retrieve').mockResolvedValue(mockInvoice);
|
||||
jest
|
||||
.spyOn(invoiceManager, 'processPayPalNonZeroInvoice')
|
||||
.mockResolvedValue(mockInvoice);
|
||||
|
||||
const result = await cartService.pollCart(mockCart.id);
|
||||
|
||||
expect(invoiceManager.processPayPalNonZeroInvoice).toHaveBeenCalledWith(
|
||||
mockCustomer,
|
||||
mockInvoice
|
||||
);
|
||||
|
||||
expect(result).toEqual({ cartState: CartState.PROCESSING });
|
||||
});
|
||||
|
||||
it('calls subscriptionManager.processStripeSubscription for stripe subscriptions', async () => {
|
||||
const mockSubscriptionId = faker.string.uuid();
|
||||
const mockCart = ResultCartFactory({
|
||||
state: CartState.PROCESSING,
|
||||
stripeSubscriptionId: mockSubscriptionId,
|
||||
});
|
||||
const mockSubscription = StripeResponseFactory(
|
||||
StripeSubscriptionFactory({
|
||||
id: mockSubscriptionId,
|
||||
collection_method: 'charge_automatically',
|
||||
})
|
||||
);
|
||||
const mockPaymentIntent = StripeResponseFactory(
|
||||
StripePaymentIntentFactory({ status: 'processing' })
|
||||
);
|
||||
|
||||
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'retrieve')
|
||||
.mockResolvedValue(mockSubscription);
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'processStripeSubscription')
|
||||
.mockResolvedValue(mockPaymentIntent);
|
||||
|
||||
const result = await cartService.pollCart(mockCart.id);
|
||||
|
||||
expect(
|
||||
subscriptionManager.processStripeSubscription
|
||||
).toHaveBeenCalledWith(mockSubscription);
|
||||
|
||||
expect(result).toEqual({ cartState: CartState.PROCESSING });
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalizeCartWithError', () => {
|
||||
it('calls cartManager.finishErrorCart', async () => {
|
||||
const mockCart = ResultCartFactory();
|
||||
|
|
|
@ -262,12 +262,9 @@ export class CartService {
|
|||
throw new CartStateProcessingError(cartId, e);
|
||||
}
|
||||
|
||||
// Intentionally left out of try/catch block to so that the rest of the logic
|
||||
// is non-blocking and can be handled asynchronously.
|
||||
this.checkoutService
|
||||
.payWithPaypal(updatedCart, customerData, token)
|
||||
.catch(async () => {
|
||||
// TODO: Handle errors and provide an associated reason for failure
|
||||
await this.cartManager.finishErrorCart(cartId, {
|
||||
errorReasonId: CartErrorReasonId.Unknown,
|
||||
});
|
||||
|
@ -299,6 +296,18 @@ export class CartService {
|
|||
|
||||
// PayPal payment method collection
|
||||
if (subscription.collection_method === 'send_invoice') {
|
||||
if (!cart.stripeCustomerId) {
|
||||
throw new CartError('Invalid stripe customer id on cart', {
|
||||
cartId,
|
||||
});
|
||||
}
|
||||
if (subscription.latest_invoice) {
|
||||
const invoice = await this.invoiceManager.retrieve(
|
||||
subscription.latest_invoice
|
||||
);
|
||||
await this.invoiceManager.processPayPalInvoice(invoice);
|
||||
}
|
||||
|
||||
return { cartState: cart.state };
|
||||
}
|
||||
|
||||
|
|
|
@ -536,6 +536,7 @@ describe('CheckoutService', () => {
|
|||
},
|
||||
],
|
||||
payment_behavior: 'default_incomplete',
|
||||
currency: mockCart.currency,
|
||||
metadata: {
|
||||
amount: mockCart.amount,
|
||||
currency: mockCart.currency,
|
||||
|
@ -642,6 +643,7 @@ describe('CheckoutService', () => {
|
|||
customer: mockCustomer,
|
||||
promotionCode: mockPromotionCode,
|
||||
price: mockPrice,
|
||||
version: mockCart.version + 1,
|
||||
})
|
||||
);
|
||||
jest
|
||||
|
@ -666,6 +668,7 @@ describe('CheckoutService', () => {
|
|||
jest.spyOn(subscriptionManager, 'cancel');
|
||||
jest.spyOn(paypalBillingAgreementManager, 'cancel').mockResolvedValue();
|
||||
jest.spyOn(checkoutService, 'postPaySteps').mockResolvedValue();
|
||||
jest.spyOn(cartManager, 'updateFreshCart').mockResolvedValue();
|
||||
});
|
||||
|
||||
describe('success', () => {
|
||||
|
@ -710,6 +713,7 @@ describe('CheckoutService', () => {
|
|||
price: mockPrice.id,
|
||||
},
|
||||
],
|
||||
currency: mockCart.currency,
|
||||
metadata: {
|
||||
amount: mockCart.amount,
|
||||
currency: mockCart.currency,
|
||||
|
@ -755,6 +759,33 @@ describe('CheckoutService', () => {
|
|||
it('does not cancel the billing agreement', () => {
|
||||
expect(paypalBillingAgreementManager.cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates the customers paypal agreement id', () => {
|
||||
expect(customerManager.update).toHaveBeenCalledWith(mockCustomer.id, {
|
||||
metadata: {
|
||||
[STRIPE_CUSTOMER_METADATA.PaypalAgreement]: mockBillingAgreementId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('calls updateFreshCart', () => {
|
||||
expect(cartManager.updateFreshCart).toHaveBeenCalledWith(
|
||||
mockCart.id,
|
||||
mockCart.version + 1,
|
||||
{
|
||||
stripeSubscriptionId: mockSubscription.id,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('calls postPaySteps with the correct arguments', () => {
|
||||
expect(checkoutService.postPaySteps).toHaveBeenCalledWith(
|
||||
mockCart,
|
||||
mockCart.version + 2,
|
||||
mockSubscription,
|
||||
mockCart.uid
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -128,8 +128,8 @@ export class CheckoutService {
|
|||
// Cart only needs to be updated if we created a customer
|
||||
if (!cart.uid || !cart.stripeCustomerId) {
|
||||
await this.cartManager.updateFreshCart(cart.id, cart.version, {
|
||||
uid: uid,
|
||||
stripeCustomerId: stripeCustomerId,
|
||||
uid,
|
||||
stripeCustomerId,
|
||||
});
|
||||
version += 1;
|
||||
}
|
||||
|
@ -260,6 +260,7 @@ export class CheckoutService {
|
|||
},
|
||||
],
|
||||
payment_behavior: 'default_incomplete',
|
||||
currency: cart.currency ?? undefined,
|
||||
metadata: {
|
||||
// Note: These fields are due to missing Fivetran support on Stripe multi-currency plans
|
||||
[STRIPE_SUBSCRIPTION_METADATA.Amount]: cart.amount,
|
||||
|
@ -313,83 +314,97 @@ export class CheckoutService {
|
|||
customerData: CheckoutCustomerData,
|
||||
token?: string
|
||||
) {
|
||||
const {
|
||||
uid,
|
||||
customer,
|
||||
enableAutomaticTax,
|
||||
promotionCode,
|
||||
price,
|
||||
version: updatedVersion,
|
||||
} = await this.prePaySteps(cart, customerData);
|
||||
|
||||
const paypalSubscriptions =
|
||||
await this.subscriptionManager.getCustomerPayPalSubscriptions(
|
||||
customer.id
|
||||
);
|
||||
|
||||
const billingAgreementId =
|
||||
await this.paypalBillingAgreementManager.retrieveOrCreateId(
|
||||
uid,
|
||||
!!paypalSubscriptions.length,
|
||||
token
|
||||
);
|
||||
|
||||
this.statsd.increment('stripe_subscription', {
|
||||
payment_provider: 'paypal',
|
||||
});
|
||||
|
||||
const subscription = await this.subscriptionManager.create(
|
||||
{
|
||||
customer: customer.id,
|
||||
automatic_tax: {
|
||||
enabled: enableAutomaticTax,
|
||||
},
|
||||
collection_method: 'send_invoice',
|
||||
days_until_due: 1,
|
||||
promotion_code: promotionCode?.id,
|
||||
items: [
|
||||
{
|
||||
price: price.id,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
// Note: These fields are due to missing Fivetran support on Stripe multi-currency plans
|
||||
[STRIPE_SUBSCRIPTION_METADATA.Amount]: cart.amount,
|
||||
[STRIPE_SUBSCRIPTION_METADATA.Currency]: cart.currency,
|
||||
},
|
||||
},
|
||||
{
|
||||
idempotencyKey: cart.id,
|
||||
}
|
||||
);
|
||||
|
||||
await this.paypalCustomerManager.deletePaypalCustomersByUid(uid);
|
||||
await this.paypalCustomerManager.createPaypalCustomer({
|
||||
uid,
|
||||
billingAgreementId,
|
||||
status: 'active',
|
||||
endedAt: null,
|
||||
});
|
||||
|
||||
await this.customerManager.update(customer.id, {
|
||||
metadata: {
|
||||
[STRIPE_CUSTOMER_METADATA.PaypalAgreement]: billingAgreementId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!subscription.latest_invoice) {
|
||||
throw new CheckoutError('latest_invoice does not exist on subscription');
|
||||
}
|
||||
const latestInvoice = await this.invoiceManager.retrieve(
|
||||
subscription.latest_invoice
|
||||
);
|
||||
try {
|
||||
this.invoiceManager.processPayPalInvoice(latestInvoice);
|
||||
} catch (e) {
|
||||
await this.subscriptionManager.cancel(subscription.id);
|
||||
await this.paypalBillingAgreementManager.cancel(billingAgreementId);
|
||||
}
|
||||
const {
|
||||
uid,
|
||||
customer,
|
||||
enableAutomaticTax,
|
||||
promotionCode,
|
||||
price,
|
||||
version,
|
||||
} = await this.prePaySteps(cart, customerData);
|
||||
|
||||
await this.postPaySteps(cart, updatedVersion, subscription, uid);
|
||||
const paypalSubscriptions =
|
||||
await this.subscriptionManager.getCustomerPayPalSubscriptions(
|
||||
customer.id
|
||||
);
|
||||
|
||||
const billingAgreementId =
|
||||
await this.paypalBillingAgreementManager.retrieveOrCreateId(
|
||||
uid,
|
||||
!!paypalSubscriptions.length,
|
||||
token
|
||||
);
|
||||
|
||||
this.statsd.increment('stripe_subscription', {
|
||||
payment_provider: 'paypal',
|
||||
});
|
||||
|
||||
const subscription = await this.subscriptionManager.create(
|
||||
{
|
||||
customer: customer.id,
|
||||
automatic_tax: {
|
||||
enabled: enableAutomaticTax,
|
||||
},
|
||||
collection_method: 'send_invoice',
|
||||
days_until_due: 1,
|
||||
promotion_code: promotionCode?.id,
|
||||
items: [
|
||||
{
|
||||
price: price.id,
|
||||
},
|
||||
],
|
||||
currency: cart.currency ?? undefined,
|
||||
metadata: {
|
||||
// Note: These fields are due to missing Fivetran support on Stripe multi-currency plans
|
||||
[STRIPE_SUBSCRIPTION_METADATA.Amount]: cart.amount,
|
||||
[STRIPE_SUBSCRIPTION_METADATA.Currency]: cart.currency,
|
||||
},
|
||||
},
|
||||
{
|
||||
idempotencyKey: cart.id,
|
||||
}
|
||||
);
|
||||
|
||||
await this.paypalCustomerManager.deletePaypalCustomersByUid(uid);
|
||||
await Promise.all([
|
||||
this.paypalCustomerManager.createPaypalCustomer({
|
||||
uid,
|
||||
billingAgreementId,
|
||||
status: 'active',
|
||||
endedAt: null,
|
||||
}),
|
||||
this.customerManager.update(customer.id, {
|
||||
metadata: {
|
||||
[STRIPE_CUSTOMER_METADATA.PaypalAgreement]: billingAgreementId,
|
||||
},
|
||||
}),
|
||||
this.cartManager.updateFreshCart(cart.id, version, {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
const updatedVersion = version + 1;
|
||||
|
||||
if (!subscription.latest_invoice) {
|
||||
throw new CheckoutError(
|
||||
'latest_invoice does not exist on subscription'
|
||||
);
|
||||
}
|
||||
const latestInvoice = await this.invoiceManager.retrieve(
|
||||
subscription.latest_invoice
|
||||
);
|
||||
try {
|
||||
this.invoiceManager.processPayPalInvoice(latestInvoice);
|
||||
} catch (e) {
|
||||
await this.subscriptionManager.cancel(subscription.id);
|
||||
await this.paypalBillingAgreementManager.cancel(billingAgreementId);
|
||||
}
|
||||
|
||||
await this.postPaySteps(cart, updatedVersion, subscription, uid);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -92,3 +92,21 @@ export class InvalidPaymentIntentError extends PaymentsCustomerError {
|
|||
super('Invalid payment intent');
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidInvoiceError extends PaymentsCustomerError {
|
||||
constructor() {
|
||||
super('Invalid invoice');
|
||||
}
|
||||
}
|
||||
|
||||
export class StripePayPalAgreementNotFoundError extends PaymentsCustomerError {
|
||||
constructor(customerId: string) {
|
||||
super(`PayPal agreement not found for Stripe customer ${customerId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class PayPalPaymentFailedError extends PaymentsCustomerError {
|
||||
constructor(status?: string) {
|
||||
super(`PayPal payment failed with status ${status ?? 'undefined'}`);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,6 +21,12 @@ import { InvoicePreviewFactory } from './invoice.factories';
|
|||
import { InvoiceManager } from './invoice.manager';
|
||||
import { stripeInvoiceToInvoicePreviewDTO } from './util/stripeInvoiceToFirstInvoicePreviewDTO';
|
||||
import { getMinimumChargeAmountForCurrency } from '../lib/util/getMinimumChargeAmountForCurrency';
|
||||
import {
|
||||
ChargeResponseFactory,
|
||||
PayPalClient,
|
||||
PaypalClientConfig,
|
||||
} from '@fxa/payments/paypal';
|
||||
import { STRIPE_CUSTOMER_METADATA, STRIPE_INVOICE_METADATA } from './types';
|
||||
|
||||
jest.mock('../lib/util/stripeInvoiceToFirstInvoicePreviewDTO');
|
||||
const mockedStripeInvoiceToFirstInvoicePreviewDTO = jest.mocked(
|
||||
|
@ -35,14 +41,22 @@ const mockedGetMinimumChargeAmountForCurrency = jest.mocked(
|
|||
describe('InvoiceManager', () => {
|
||||
let invoiceManager: InvoiceManager;
|
||||
let stripeClient: StripeClient;
|
||||
let paypalClient: PayPalClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [StripeClient, MockStripeConfigProvider, InvoiceManager],
|
||||
providers: [
|
||||
StripeClient,
|
||||
PayPalClient,
|
||||
PaypalClientConfig,
|
||||
MockStripeConfigProvider,
|
||||
InvoiceManager,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
invoiceManager = module.get(InvoiceManager);
|
||||
stripeClient = module.get(StripeClient);
|
||||
paypalClient = module.get(PayPalClient);
|
||||
});
|
||||
|
||||
describe('finalizeWithoutAutoAdvance', () => {
|
||||
|
@ -160,7 +174,7 @@ describe('InvoiceManager', () => {
|
|||
.mockResolvedValue(mockInvoice);
|
||||
jest
|
||||
.spyOn(invoiceManager, 'processPayPalNonZeroInvoice')
|
||||
.mockResolvedValue();
|
||||
.mockResolvedValue(StripeResponseFactory(mockInvoice));
|
||||
|
||||
await invoiceManager.processPayPalInvoice(mockInvoice);
|
||||
expect(invoiceManager.processPayPalZeroInvoice).toBeCalledWith(
|
||||
|
@ -185,7 +199,7 @@ describe('InvoiceManager', () => {
|
|||
.mockResolvedValue(StripeResponseFactory(mockInvoice));
|
||||
jest
|
||||
.spyOn(invoiceManager, 'processPayPalNonZeroInvoice')
|
||||
.mockResolvedValue();
|
||||
.mockResolvedValue(StripeResponseFactory(mockInvoice));
|
||||
|
||||
await invoiceManager.processPayPalInvoice(mockInvoice);
|
||||
|
||||
|
@ -215,4 +229,238 @@ describe('InvoiceManager', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processNonZeroInvoice', () => {
|
||||
it('successfully processes non-zero invoice', async () => {
|
||||
const mockPaymentAttemptCount = 1;
|
||||
const mockCustomer = StripeResponseFactory(
|
||||
StripeCustomerFactory({
|
||||
metadata: {
|
||||
[STRIPE_CUSTOMER_METADATA.PaypalAgreement]: '1',
|
||||
},
|
||||
})
|
||||
);
|
||||
const mockInvoice = StripeResponseFactory(
|
||||
StripeInvoiceFactory({
|
||||
status: 'open',
|
||||
currency: 'usd',
|
||||
metadata: {
|
||||
[STRIPE_INVOICE_METADATA.RetryAttempts]: String(
|
||||
mockPaymentAttemptCount
|
||||
),
|
||||
},
|
||||
})
|
||||
);
|
||||
const mockPayPalCharge = ChargeResponseFactory({
|
||||
paymentStatus: 'Completed',
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(paypalClient, 'chargeCustomer')
|
||||
.mockResolvedValue(mockPayPalCharge);
|
||||
jest
|
||||
.spyOn(stripeClient, 'invoicesFinalizeInvoice')
|
||||
.mockResolvedValue(mockInvoice);
|
||||
jest.spyOn(stripeClient, 'invoicesUpdate').mockResolvedValue(mockInvoice);
|
||||
jest.spyOn(stripeClient, 'invoicesPay').mockResolvedValue(mockInvoice);
|
||||
|
||||
const result = await invoiceManager.processPayPalNonZeroInvoice(
|
||||
mockCustomer,
|
||||
mockInvoice
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockInvoice);
|
||||
|
||||
expect(paypalClient.chargeCustomer).toHaveBeenCalledWith({
|
||||
amountInCents: mockInvoice.amount_due,
|
||||
billingAgreementId:
|
||||
mockCustomer.metadata[STRIPE_CUSTOMER_METADATA.PaypalAgreement],
|
||||
invoiceNumber: mockInvoice.id,
|
||||
currencyCode: mockInvoice.currency,
|
||||
idempotencyKey: `${mockInvoice.id}-${mockPaymentAttemptCount}`,
|
||||
taxAmountInCents: mockInvoice.tax,
|
||||
});
|
||||
expect(stripeClient.invoicesFinalizeInvoice).toHaveBeenCalledWith(
|
||||
mockInvoice.id
|
||||
);
|
||||
expect(stripeClient.invoicesUpdate).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockInvoice.id,
|
||||
{
|
||||
metadata: {
|
||||
[STRIPE_INVOICE_METADATA.RetryAttempts]:
|
||||
mockPaymentAttemptCount + 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(stripeClient.invoicesUpdate).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
mockInvoice.id,
|
||||
{
|
||||
metadata: {
|
||||
[STRIPE_INVOICE_METADATA.PaypalTransactionId]:
|
||||
mockPayPalCharge.transactionId,
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(stripeClient.invoicesPay).toHaveBeenCalledWith(mockInvoice.id);
|
||||
});
|
||||
it('throws an error if the customer has no paypal agreement id', async () => {
|
||||
const mockCustomer = StripeResponseFactory(
|
||||
StripeCustomerFactory({ metadata: {} })
|
||||
);
|
||||
const mockInvoice = StripeResponseFactory(StripeInvoiceFactory());
|
||||
|
||||
await expect(
|
||||
invoiceManager.processPayPalNonZeroInvoice(mockCustomer, mockInvoice)
|
||||
).rejects.toThrowError();
|
||||
});
|
||||
it('throws an error for an already-paid invoice', async () => {
|
||||
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
|
||||
const mockInvoice = StripeResponseFactory(
|
||||
StripeInvoiceFactory({ status: 'paid' })
|
||||
);
|
||||
|
||||
await expect(
|
||||
invoiceManager.processPayPalNonZeroInvoice(mockCustomer, mockInvoice)
|
||||
).rejects.toThrowError();
|
||||
});
|
||||
it('throws an error for an uncollectible invoice', async () => {
|
||||
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
|
||||
const mockInvoice = StripeResponseFactory(
|
||||
StripeInvoiceFactory({ status: 'uncollectible' })
|
||||
);
|
||||
|
||||
await expect(
|
||||
invoiceManager.processPayPalNonZeroInvoice(mockCustomer, mockInvoice)
|
||||
).rejects.toThrowError();
|
||||
});
|
||||
it('returns on pending invoices without marking it as paid', async () => {
|
||||
const mockPaymentAttemptCount = 1;
|
||||
const mockCustomer = StripeResponseFactory(
|
||||
StripeCustomerFactory({
|
||||
metadata: {
|
||||
[STRIPE_CUSTOMER_METADATA.PaypalAgreement]: '1',
|
||||
},
|
||||
})
|
||||
);
|
||||
const mockInvoice = StripeResponseFactory(
|
||||
StripeInvoiceFactory({
|
||||
status: 'open',
|
||||
currency: 'usd',
|
||||
metadata: {
|
||||
[STRIPE_INVOICE_METADATA.RetryAttempts]: String(
|
||||
mockPaymentAttemptCount
|
||||
),
|
||||
},
|
||||
})
|
||||
);
|
||||
const mockPayPalCharge = ChargeResponseFactory({
|
||||
paymentStatus: 'Pending',
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(paypalClient, 'chargeCustomer')
|
||||
.mockResolvedValue(mockPayPalCharge);
|
||||
jest
|
||||
.spyOn(stripeClient, 'invoicesFinalizeInvoice')
|
||||
.mockResolvedValue(mockInvoice);
|
||||
jest.spyOn(stripeClient, 'invoicesUpdate').mockResolvedValue(mockInvoice);
|
||||
jest.spyOn(stripeClient, 'invoicesPay').mockResolvedValue(mockInvoice);
|
||||
|
||||
const result = await invoiceManager.processPayPalNonZeroInvoice(
|
||||
mockCustomer,
|
||||
mockInvoice
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockInvoice);
|
||||
|
||||
expect(paypalClient.chargeCustomer).toHaveBeenCalledWith({
|
||||
amountInCents: mockInvoice.amount_due,
|
||||
billingAgreementId:
|
||||
mockCustomer.metadata[STRIPE_CUSTOMER_METADATA.PaypalAgreement],
|
||||
invoiceNumber: mockInvoice.id,
|
||||
currencyCode: mockInvoice.currency,
|
||||
idempotencyKey: `${mockInvoice.id}-${mockPaymentAttemptCount}`,
|
||||
taxAmountInCents: mockInvoice.tax,
|
||||
});
|
||||
expect(stripeClient.invoicesFinalizeInvoice).toHaveBeenCalledWith(
|
||||
mockInvoice.id
|
||||
);
|
||||
expect(stripeClient.invoicesUpdate).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockInvoice.id,
|
||||
{
|
||||
metadata: {
|
||||
[STRIPE_INVOICE_METADATA.RetryAttempts]:
|
||||
mockPaymentAttemptCount + 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(stripeClient.invoicesUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(stripeClient.invoicesPay).not.toHaveBeenCalled();
|
||||
});
|
||||
it('throws an error for "Denied" paypal transaction state', async () => {
|
||||
const mockPaymentAttemptCount = 1;
|
||||
const mockCustomer = StripeResponseFactory(
|
||||
StripeCustomerFactory({
|
||||
metadata: {
|
||||
[STRIPE_CUSTOMER_METADATA.PaypalAgreement]: '1',
|
||||
},
|
||||
})
|
||||
);
|
||||
const mockInvoice = StripeResponseFactory(
|
||||
StripeInvoiceFactory({
|
||||
status: 'open',
|
||||
currency: 'usd',
|
||||
metadata: {
|
||||
[STRIPE_INVOICE_METADATA.RetryAttempts]: String(
|
||||
mockPaymentAttemptCount
|
||||
),
|
||||
},
|
||||
})
|
||||
);
|
||||
const mockPayPalCharge = ChargeResponseFactory({
|
||||
paymentStatus: 'Failed',
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(paypalClient, 'chargeCustomer')
|
||||
.mockResolvedValue(mockPayPalCharge);
|
||||
jest
|
||||
.spyOn(stripeClient, 'invoicesFinalizeInvoice')
|
||||
.mockResolvedValue(mockInvoice);
|
||||
jest.spyOn(stripeClient, 'invoicesUpdate').mockResolvedValue(mockInvoice);
|
||||
jest.spyOn(stripeClient, 'invoicesPay').mockResolvedValue(mockInvoice);
|
||||
|
||||
await expect(
|
||||
invoiceManager.processPayPalNonZeroInvoice(mockCustomer, mockInvoice)
|
||||
).rejects.toThrowError();
|
||||
|
||||
expect(paypalClient.chargeCustomer).toHaveBeenCalledWith({
|
||||
amountInCents: mockInvoice.amount_due,
|
||||
billingAgreementId:
|
||||
mockCustomer.metadata[STRIPE_CUSTOMER_METADATA.PaypalAgreement],
|
||||
invoiceNumber: mockInvoice.id,
|
||||
currencyCode: mockInvoice.currency,
|
||||
idempotencyKey: `${mockInvoice.id}-${mockPaymentAttemptCount}`,
|
||||
taxAmountInCents: mockInvoice.tax,
|
||||
});
|
||||
expect(stripeClient.invoicesFinalizeInvoice).toHaveBeenCalledWith(
|
||||
mockInvoice.id
|
||||
);
|
||||
expect(stripeClient.invoicesUpdate).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockInvoice.id,
|
||||
{
|
||||
metadata: {
|
||||
[STRIPE_INVOICE_METADATA.RetryAttempts]:
|
||||
mockPaymentAttemptCount + 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(stripeClient.invoicesUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(stripeClient.invoicesPay).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -11,14 +11,33 @@ import {
|
|||
StripeInvoice,
|
||||
StripePromotionCode,
|
||||
} from '@fxa/payments/stripe';
|
||||
import { InvoicePreview, TaxAddress } from './types';
|
||||
import {
|
||||
ChargeOptions,
|
||||
ChargeResponse,
|
||||
PayPalClient,
|
||||
PayPalClientError,
|
||||
} from '@fxa/payments/paypal';
|
||||
import {
|
||||
InvoicePreview,
|
||||
STRIPE_CUSTOMER_METADATA,
|
||||
STRIPE_INVOICE_METADATA,
|
||||
TaxAddress,
|
||||
} from './types';
|
||||
import { isCustomerTaxEligible } from './util/isCustomerTaxEligible';
|
||||
import { stripeInvoiceToInvoicePreviewDTO } from './util/stripeInvoiceToFirstInvoicePreviewDTO';
|
||||
import { getMinimumChargeAmountForCurrency } from './util/getMinimumChargeAmountForCurrency';
|
||||
import {
|
||||
InvalidInvoiceError,
|
||||
PayPalPaymentFailedError,
|
||||
StripePayPalAgreementNotFoundError,
|
||||
} from './error';
|
||||
|
||||
@Injectable()
|
||||
export class InvoiceManager {
|
||||
constructor(private stripeClient: StripeClient) {}
|
||||
constructor(
|
||||
private stripeClient: StripeClient,
|
||||
private paypalClient: PayPalClient
|
||||
) {}
|
||||
|
||||
async finalizeWithoutAutoAdvance(invoiceId: string) {
|
||||
return this.stripeClient.invoicesFinalizeInvoice(invoiceId, {
|
||||
|
@ -104,9 +123,76 @@ export class InvoiceManager {
|
|||
invoice: StripeInvoice,
|
||||
ipaddress?: string
|
||||
) {
|
||||
// TODO in M3b: Implement legacy processInvoice as processNonZeroInvoice here
|
||||
// TODO: Add spec
|
||||
console.log(customer, invoice, ipaddress);
|
||||
if (!customer.metadata[STRIPE_CUSTOMER_METADATA.PaypalAgreement]) {
|
||||
throw new StripePayPalAgreementNotFoundError(customer.id);
|
||||
}
|
||||
if (!['draft', 'open'].includes(invoice.status ?? '')) {
|
||||
throw new InvalidInvoiceError();
|
||||
}
|
||||
|
||||
// PayPal allows for idempotent retries on payment attempts to prevent double charging.
|
||||
const paymentAttemptCount = parseInt(
|
||||
invoice?.metadata?.[STRIPE_INVOICE_METADATA.RetryAttempts] ?? '0'
|
||||
);
|
||||
const idempotencyKey = `${invoice.id}-${paymentAttemptCount}`;
|
||||
|
||||
// Charge the customer on PayPal
|
||||
const chargeOptions = {
|
||||
amountInCents: invoice.amount_due,
|
||||
billingAgreementId:
|
||||
customer.metadata[STRIPE_CUSTOMER_METADATA.PaypalAgreement],
|
||||
invoiceNumber: invoice.id,
|
||||
currencyCode: invoice.currency,
|
||||
idempotencyKey,
|
||||
...(ipaddress && { ipaddress }),
|
||||
...(invoice.tax && { taxAmountInCents: invoice.tax }),
|
||||
} satisfies ChargeOptions;
|
||||
let paypalCharge: ChargeResponse;
|
||||
try {
|
||||
[paypalCharge] = await Promise.all([
|
||||
this.paypalClient.chargeCustomer(chargeOptions),
|
||||
this.stripeClient.invoicesFinalizeInvoice(invoice.id),
|
||||
]);
|
||||
} catch (error) {
|
||||
if (PayPalClientError.hasPayPalNVPError(error)) {
|
||||
PayPalClientError.throwPaypalCodeError(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// update Stripe payment charge attempt count
|
||||
const updatedPaymentAttemptCount = paymentAttemptCount + 1;
|
||||
let updatedInvoice = await this.stripeClient.invoicesUpdate(invoice.id, {
|
||||
metadata: {
|
||||
[STRIPE_INVOICE_METADATA.RetryAttempts]: updatedPaymentAttemptCount,
|
||||
},
|
||||
});
|
||||
|
||||
// Process the transaction by PayPal charge status
|
||||
switch (paypalCharge.paymentStatus) {
|
||||
case 'Completed':
|
||||
case 'Processed':
|
||||
[updatedInvoice] = await Promise.all([
|
||||
this.stripeClient.invoicesUpdate(invoice.id, {
|
||||
metadata: {
|
||||
[STRIPE_INVOICE_METADATA.PaypalTransactionId]:
|
||||
paypalCharge.transactionId,
|
||||
},
|
||||
}),
|
||||
this.stripeClient.invoicesPay(invoice.id),
|
||||
]);
|
||||
|
||||
return updatedInvoice;
|
||||
case 'Pending':
|
||||
case 'In-Progress':
|
||||
return updatedInvoice;
|
||||
case 'Denied':
|
||||
case 'Failed':
|
||||
case 'Voided':
|
||||
case 'Expired':
|
||||
default:
|
||||
throw new PayPalPaymentFailedError(paypalCharge.paymentStatus);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -12,6 +12,7 @@ export type InvoicePreview = {
|
|||
discountEnd?: number | null;
|
||||
discountType?: string;
|
||||
number: string | null; // customer-facing invoice identifier
|
||||
paypalTransactionId?: string;
|
||||
};
|
||||
|
||||
export interface TaxAmount {
|
||||
|
@ -40,6 +41,11 @@ export enum STRIPE_SUBSCRIPTION_METADATA {
|
|||
Amount = 'amount',
|
||||
}
|
||||
|
||||
export enum STRIPE_INVOICE_METADATA {
|
||||
RetryAttempts = 'paymentAttempts',
|
||||
PaypalTransactionId = 'paypalTransactionId',
|
||||
}
|
||||
|
||||
export enum SubplatInterval {
|
||||
Daily = 'daily',
|
||||
Weekly = 'weekly',
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { StripeInvoice, StripeUpcomingInvoice } from '@fxa/payments/stripe';
|
||||
import { InvoicePreview } from '../types';
|
||||
import { InvoicePreview, STRIPE_INVOICE_METADATA } from '../types';
|
||||
|
||||
/**
|
||||
* Formats a Stripe Invoice to the FirstInvoicePreview DTO format.
|
||||
|
@ -34,5 +34,7 @@ export function stripeInvoiceToInvoicePreviewDTO(
|
|||
discountEnd: invoice.discount?.end,
|
||||
discountType: invoice.discount?.coupon.duration,
|
||||
number: invoice.number,
|
||||
paypalTransactionId:
|
||||
invoice.metadata?.[STRIPE_INVOICE_METADATA.PaypalTransactionId],
|
||||
};
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
export * from './lib/paypalCustomer/paypalCustomer.manager';
|
||||
export * from './lib/paypalCustomer/paypalCustomer.factories';
|
||||
export * from './lib/factories';
|
||||
export * from './lib/checkoutToken.manager';
|
||||
export * from './lib/paypal.client';
|
||||
export * from './lib/paypal.client.config';
|
||||
|
|
|
@ -19,6 +19,7 @@ import {
|
|||
NVPErrorSeverity,
|
||||
TransactionSearchResult,
|
||||
} from './paypal.client.types';
|
||||
import { ChargeOptions, ChargeResponse } from './paypal.types';
|
||||
|
||||
export const NVPBaseResponseFactory = (
|
||||
override?: Partial<NVPBaseResponse>
|
||||
|
@ -154,3 +155,33 @@ export const NVPTransactionSearchResponseFactory = (
|
|||
L: [TransactionSearchResultFactory(), TransactionSearchResultFactory()],
|
||||
...override,
|
||||
});
|
||||
|
||||
export const ChargeResponseFactory = (
|
||||
override?: Partial<ChargeResponse>
|
||||
): ChargeResponse => ({
|
||||
avsCode: faker.string.alpha(1).toUpperCase(),
|
||||
currencyCode: faker.finance.currencyCode(),
|
||||
cvv2Match: faker.finance.creditCardCVV(),
|
||||
transactionId: faker.string.uuid(),
|
||||
parentTransactionId: faker.string.uuid(),
|
||||
transactionType: 'express-checkout',
|
||||
paymentType: faker.finance.transactionType(),
|
||||
orderTime: faker.date.recent().toISOString(),
|
||||
amount: faker.finance.amount(),
|
||||
paymentStatus: 'Completed',
|
||||
pendingReason: 'none',
|
||||
reasonCode: 'none',
|
||||
...override,
|
||||
});
|
||||
|
||||
export const ChargeOptionsFactory = (
|
||||
override?: Partial<ChargeOptions>
|
||||
): ChargeOptions => ({
|
||||
amountInCents: faker.number.int({ max: 100000000 }),
|
||||
billingAgreementId: faker.string.uuid(),
|
||||
currencyCode: faker.finance.currencyCode(),
|
||||
idempotencyKey: faker.string.uuid(),
|
||||
invoiceNumber: faker.string.uuid(),
|
||||
taxAmountInCents: faker.number.int({ max: 100000000 }),
|
||||
...override,
|
||||
});
|
||||
|
|
|
@ -16,6 +16,8 @@ import {
|
|||
import { PayPalClient } from './paypal.client';
|
||||
import { PayPalClientError, PayPalNVPError } from './paypal.error';
|
||||
import {
|
||||
ChargeOptionsFactory,
|
||||
ChargeResponseFactory,
|
||||
NVPBAUpdateTransactionResponseFactory,
|
||||
NVPDoReferenceTransactionResponseFactory,
|
||||
NVPErrorFactory,
|
||||
|
@ -37,6 +39,7 @@ import {
|
|||
import { objectToNVP, toIsoString } from './util';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { MockPaypalClientConfigProvider } from './paypal.client.config';
|
||||
import { ChargeResponse } from './paypal.types';
|
||||
|
||||
describe('PayPalClient', () => {
|
||||
const commonPayloadArgs = {
|
||||
|
@ -423,4 +426,38 @@ describe('PayPalClient', () => {
|
|||
expect(actual).toMatchObject<PayPalNVPError[]>(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chargeCustomer', () => {
|
||||
it('calls API with valid message', async () => {
|
||||
const mockOptions = ChargeOptionsFactory();
|
||||
const mockReferenceTransactionResponse =
|
||||
NVPDoReferenceTransactionResponseFactory();
|
||||
const mockChargeResponse = ChargeResponseFactory({
|
||||
amount: mockReferenceTransactionResponse.AMT,
|
||||
currencyCode: mockReferenceTransactionResponse.CURRENCYCODE,
|
||||
avsCode: mockReferenceTransactionResponse.AVSCODE,
|
||||
cvv2Match: mockReferenceTransactionResponse.CVV2MATCH,
|
||||
orderTime: mockReferenceTransactionResponse.ORDERTIME,
|
||||
parentTransactionId:
|
||||
mockReferenceTransactionResponse.PARENTTRANSACTIONID,
|
||||
paymentStatus:
|
||||
mockReferenceTransactionResponse.PAYMENTSTATUS as ChargeResponse['paymentStatus'],
|
||||
paymentType: mockReferenceTransactionResponse.PAYMENTTYPE,
|
||||
pendingReason:
|
||||
mockReferenceTransactionResponse.PENDINGREASON as ChargeResponse['pendingReason'],
|
||||
reasonCode:
|
||||
mockReferenceTransactionResponse.REASONCODE as ChargeResponse['reasonCode'],
|
||||
transactionId: mockReferenceTransactionResponse.TRANSACTIONID,
|
||||
transactionType:
|
||||
mockReferenceTransactionResponse.TRANSACTIONTYPE as ChargeResponse['transactionType'],
|
||||
});
|
||||
jest
|
||||
.spyOn(paypalClient, 'doReferenceTransaction')
|
||||
.mockResolvedValue(mockReferenceTransactionResponse);
|
||||
|
||||
const result = await paypalClient.chargeCustomer(mockOptions);
|
||||
|
||||
expect(result).toMatchObject(mockChargeResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -37,6 +37,8 @@ import {
|
|||
} from './paypal.client.types';
|
||||
import { nvpToObject, objectToNVP, toIsoString } from './util';
|
||||
import { PaypalClientConfig } from './paypal.client.config';
|
||||
import { ChargeOptions, ChargeResponse } from './paypal.types';
|
||||
import { getPayPalAmountStringFromAmountInCents } from './util/getPayPalAmountStringFromAmountInCents';
|
||||
|
||||
@Injectable()
|
||||
export class PayPalClient {
|
||||
|
@ -327,4 +329,46 @@ export class PayPalClient {
|
|||
data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge customer based on an existing Billing Agreement.
|
||||
*/
|
||||
public async chargeCustomer(options: ChargeOptions): Promise<ChargeResponse> {
|
||||
// Processes a payment from a buyer's account, identified by the billingAgreementId
|
||||
const doReferenceTransactionOptions: DoReferenceTransactionOptions = {
|
||||
amount: getPayPalAmountStringFromAmountInCents(
|
||||
options.amountInCents,
|
||||
options.currencyCode
|
||||
),
|
||||
billingAgreementId: options.billingAgreementId,
|
||||
currencyCode: options.currencyCode,
|
||||
idempotencyKey: options.idempotencyKey,
|
||||
invoiceNumber: options.invoiceNumber,
|
||||
...(options.ipaddress && { ipaddress: options.ipaddress }),
|
||||
...(options.taxAmountInCents && {
|
||||
taxAmount: getPayPalAmountStringFromAmountInCents(
|
||||
options.taxAmountInCents,
|
||||
options.currencyCode
|
||||
),
|
||||
}),
|
||||
};
|
||||
const response = await this.doReferenceTransaction(
|
||||
doReferenceTransactionOptions
|
||||
);
|
||||
return {
|
||||
amount: response.AMT,
|
||||
currencyCode: response.CURRENCYCODE,
|
||||
avsCode: response.AVSCODE,
|
||||
cvv2Match: response.CVV2MATCH,
|
||||
orderTime: response.ORDERTIME,
|
||||
parentTransactionId: response.PARENTTRANSACTIONID,
|
||||
paymentStatus: response.PAYMENTSTATUS as ChargeResponse['paymentStatus'],
|
||||
paymentType: response.PAYMENTTYPE,
|
||||
pendingReason: response.PENDINGREASON as ChargeResponse['pendingReason'],
|
||||
reasonCode: response.REASONCODE as ChargeResponse['reasonCode'],
|
||||
transactionId: response.TRANSACTIONID,
|
||||
transactionType:
|
||||
response.TRANSACTIONTYPE as ChargeResponse['transactionType'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,10 +4,7 @@
|
|||
import { faker } from '@faker-js/faker';
|
||||
import { MultiError, VError } from 'verror';
|
||||
|
||||
import {
|
||||
NVPErrorFactory,
|
||||
NVPErrorResponseFactory,
|
||||
} from '../../../../payments/paypal/src/lib/factories';
|
||||
import { NVPErrorFactory, NVPErrorResponseFactory } from './factories';
|
||||
import { PayPalClientError, PayPalNVPError } from './paypal.error';
|
||||
|
||||
describe('PayPal Errors', () => {
|
||||
|
|
|
@ -4,6 +4,55 @@
|
|||
import { BaseError, BaseMultiError } from '@fxa/shared/error';
|
||||
import { NVPErrorResponse } from './paypal.client.types';
|
||||
|
||||
/// PayPal error codes
|
||||
/**
|
||||
* Error Codes representing an error that is temporary to PayPal
|
||||
* and should be retried again without changes.
|
||||
*/
|
||||
export const PAYPAL_RETRY_ERRORS = [10014, 10445, 11453, 11612];
|
||||
|
||||
/**
|
||||
* Errors Codes representing an error with the customers funding
|
||||
* source, such as the AVS, CVV2, funding, etc. not being valid.
|
||||
*
|
||||
* The customer should be prompted to login to PayPal and fix their
|
||||
* funding source.
|
||||
*/
|
||||
export const PAYPAL_SOURCE_ERRORS = [
|
||||
10069, 10203, 10204, 10205, 10207, 10210, 10212, 10216, 10417, 10502, 10504,
|
||||
10507, 10525, 10527, 10537, 10546, 10554, 10555, 10556, 10560, 10567, 10600,
|
||||
10601, 10606, 10606, 10748, 10752, 11084, 11091, 11458, 11611, 13109, 13122,
|
||||
15012, 18014,
|
||||
];
|
||||
|
||||
/**
|
||||
* Error codes representing an error in how we called PayPal and/or
|
||||
* the arguments we passed them. These can only be fixed by fixing our
|
||||
* code.
|
||||
*/
|
||||
export const PAYPAL_APP_ERRORS = [
|
||||
10004, 10009, 10211, 10213, 10214, 10402, 10406, 10412, 10414, 10443, 10538,
|
||||
10539, 10613, 10747, 10755, 11302, 11452,
|
||||
];
|
||||
|
||||
/**
|
||||
* Returned when the paypal billing agreement is no longer valid.
|
||||
*/
|
||||
export const PAYPAL_BILLING_AGREEMENT_INVALID = 10201;
|
||||
|
||||
/**
|
||||
* Returned with a transaction if the message sub id was seen before.
|
||||
*/
|
||||
export const PAYPAL_REPEAT_MESSAGE_SUB_ID = 11607;
|
||||
|
||||
/**
|
||||
* Returned with a transaction where the billing agreement was created
|
||||
* with a different business account.
|
||||
*
|
||||
* Should only occur when using multiple dev apps on the same Stripe account.
|
||||
*/
|
||||
export const PAYPAL_BILLING_TRANSACTION_WRONG_ACCOUNT = 11451;
|
||||
|
||||
export class PayPalClientError extends BaseMultiError {
|
||||
public raw: string;
|
||||
public data: NVPErrorResponse;
|
||||
|
@ -26,6 +75,24 @@ export class PayPalClientError extends BaseMultiError {
|
|||
err.getPrimaryError() instanceof PayPalNVPError
|
||||
);
|
||||
}
|
||||
|
||||
static throwPaypalCodeError(err: PayPalClientError) {
|
||||
const primaryError = err.getPrimaryError();
|
||||
const code = primaryError.errorCode;
|
||||
if (!code || PAYPAL_APP_ERRORS.includes(code)) {
|
||||
throw new UnexpectedPayPalErrorCode(err);
|
||||
}
|
||||
if (
|
||||
PAYPAL_SOURCE_ERRORS.includes(code) ||
|
||||
code === PAYPAL_BILLING_AGREEMENT_INVALID
|
||||
) {
|
||||
throw new PayPalPaymentMethodError(err);
|
||||
}
|
||||
if (PAYPAL_RETRY_ERRORS.includes(code)) {
|
||||
throw new PayPalServiceUnavailableError(err);
|
||||
}
|
||||
throw new UnexpectedPayPalError(err);
|
||||
}
|
||||
}
|
||||
|
||||
export class PayPalNVPError extends BaseError {
|
||||
|
@ -50,13 +117,21 @@ export class PayPalNVPError extends BaseError {
|
|||
}
|
||||
}
|
||||
|
||||
export class PaymentsCustomError extends BaseError {
|
||||
constructor(message: string, cause?: Error) {
|
||||
super(message, {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class PaypalBillingAgreementManagerError extends BaseError {
|
||||
constructor(...args: ConstructorParameters<typeof BaseError>) {
|
||||
super(...args);
|
||||
}
|
||||
}
|
||||
|
||||
export class AmountExceedsPayPalCharLimitError extends PaypalBillingAgreementManagerError {
|
||||
export class AmountExceedsPayPalCharLimitError extends BaseError {
|
||||
constructor(amountInCents: number) {
|
||||
super('Amount must be less than 10 characters', {
|
||||
info: {
|
||||
|
@ -65,3 +140,27 @@ export class AmountExceedsPayPalCharLimitError extends PaypalBillingAgreementMan
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class UnexpectedPayPalError extends PaymentsCustomError {
|
||||
constructor(error: Error) {
|
||||
super('An unexpected PayPal error occured', error);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnexpectedPayPalErrorCode extends PaymentsCustomError {
|
||||
constructor(error: Error) {
|
||||
super('Encountered an unexpected PayPal error code', error);
|
||||
}
|
||||
}
|
||||
|
||||
export class PayPalPaymentMethodError extends PaymentsCustomError {
|
||||
constructor(error: Error) {
|
||||
super('PayPal payment method failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
export class PayPalServiceUnavailableError extends PaymentsCustomError {
|
||||
constructor(error: Error) {
|
||||
super('PayPal service is temporarily unavailable', error);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,3 +18,59 @@ export interface BillingAgreement {
|
|||
street2: string;
|
||||
zip: string;
|
||||
}
|
||||
|
||||
export interface ChargeOptions {
|
||||
amountInCents: number;
|
||||
billingAgreementId: string;
|
||||
currencyCode: string;
|
||||
idempotencyKey: string;
|
||||
invoiceNumber: string;
|
||||
ipaddress?: string;
|
||||
taxAmountInCents?: number;
|
||||
}
|
||||
|
||||
export interface ChargeResponse {
|
||||
avsCode: string;
|
||||
currencyCode: string;
|
||||
cvv2Match: string;
|
||||
transactionId: string;
|
||||
parentTransactionId: string | undefined;
|
||||
transactionType: 'cart' | 'express-checkout';
|
||||
paymentType: string;
|
||||
orderTime: string;
|
||||
amount: string;
|
||||
paymentStatus:
|
||||
| 'None'
|
||||
| 'Canceled-Reversal'
|
||||
| 'Completed'
|
||||
| 'Denied'
|
||||
| 'Expired'
|
||||
| 'Failed'
|
||||
| 'In-Progress'
|
||||
| 'Partially-Refunded'
|
||||
| 'Pending'
|
||||
| 'Refunded'
|
||||
| 'Reversed'
|
||||
| 'Processed'
|
||||
| 'Voided';
|
||||
pendingReason:
|
||||
| 'none'
|
||||
| 'address'
|
||||
| 'authorization'
|
||||
| 'echeck'
|
||||
| 'intl'
|
||||
| 'multi-currency'
|
||||
| 'order'
|
||||
| 'paymentreview'
|
||||
| 'regulatoryreview'
|
||||
| 'unilateral'
|
||||
| 'verify'
|
||||
| 'other';
|
||||
reasonCode:
|
||||
| 'none'
|
||||
| 'chargeback'
|
||||
| 'guarantee'
|
||||
| 'buyer-complaint'
|
||||
| 'refund'
|
||||
| 'other';
|
||||
}
|
||||
|
|
|
@ -4,11 +4,6 @@
|
|||
import { faker } from '@faker-js/faker';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import {
|
||||
CustomerManager,
|
||||
InvoiceManager,
|
||||
SubscriptionManager,
|
||||
} from '@fxa/payments/customer';
|
||||
import { MockStripeConfigProvider, StripeClient } from '@fxa/payments/stripe';
|
||||
import { MockAccountDatabaseNestFactory } from '@fxa/shared/db/mysql/account';
|
||||
|
||||
|
@ -33,8 +28,6 @@ describe('PaypalBillingAgreementManager', () => {
|
|||
beforeEach(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
CustomerManager,
|
||||
InvoiceManager,
|
||||
PaypalBillingAgreementManager,
|
||||
PayPalClient,
|
||||
PaypalCustomerManager,
|
||||
|
@ -42,7 +35,6 @@ describe('PaypalBillingAgreementManager', () => {
|
|||
MockStripeConfigProvider,
|
||||
MockPaypalClientConfigProvider,
|
||||
StripeClient,
|
||||
SubscriptionManager,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
|
|
@ -7,19 +7,19 @@ import { getPayPalAmountStringFromAmountInCents } from './getPayPalAmountStringF
|
|||
|
||||
describe('getPayPalAmountStringFromAmountInCents', () => {
|
||||
it('returns correctly formatted string', () => {
|
||||
const amountInCents = 9999999999;
|
||||
const amountInCents = 999999999;
|
||||
const expectedResult = (amountInCents / 100).toFixed(2);
|
||||
|
||||
const result = getPayPalAmountStringFromAmountInCents(amountInCents);
|
||||
const result = getPayPalAmountStringFromAmountInCents(amountInCents, 'USD');
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it('throws an error if number exceeds digit limit', () => {
|
||||
const amountInCents = 12345678910;
|
||||
const amountInCents = 1234567890;
|
||||
|
||||
expect(() => {
|
||||
getPayPalAmountStringFromAmountInCents(amountInCents);
|
||||
getPayPalAmountStringFromAmountInCents(amountInCents, 'USD');
|
||||
}).toThrow(AmountExceedsPayPalCharLimitError);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,14 +7,19 @@ import { AmountExceedsPayPalCharLimitError } from '../paypal.error';
|
|||
/*
|
||||
* Convert amount in cents to paypal AMT string.
|
||||
* We use Stripe to manage everything and plans are recorded in an AmountInCents.
|
||||
* PayPal AMT field requires a string of 10 characters or less, as documented here:
|
||||
* PayPal AMT field requires a string of 9 characters or less, as documented here:
|
||||
* https://developer.paypal.com/docs/nvp-soap-api/do-reference-transaction-nvp/#payment-details-fields
|
||||
* https://developer.paypal.com/docs/api/payments/v1/#definition-amount
|
||||
*/
|
||||
export function getPayPalAmountStringFromAmountInCents(
|
||||
amountInCents: number
|
||||
amountInCents: number,
|
||||
currencyCode: string
|
||||
): string {
|
||||
if (amountInCents.toString().length > 10) {
|
||||
// HUF, JPY, TWD do not support decimals.
|
||||
if (['HUF', 'JPY', 'TWD'].includes(currencyCode.toUpperCase())) {
|
||||
return String(amountInCents);
|
||||
}
|
||||
if (amountInCents.toString().length > 9) {
|
||||
throw new AmountExceedsPayPalCharLimitError(amountInCents);
|
||||
}
|
||||
// Left pad with zeros if necessary, so we always get a minimum of 0.01.
|
||||
|
|
|
@ -166,6 +166,28 @@ export class StripeClient {
|
|||
return result as StripeResponse<StripeInvoice>;
|
||||
}
|
||||
|
||||
async invoicesUpdate(invoiceId: string, params?: Stripe.InvoiceUpdateParams) {
|
||||
const result = await this.stripe.invoices.update(invoiceId, {
|
||||
...params,
|
||||
expand: undefined,
|
||||
});
|
||||
return result as StripeResponse<StripeInvoice>;
|
||||
}
|
||||
|
||||
async invoicesPay(invoiceId: string) {
|
||||
try {
|
||||
await this.stripe.invoices.pay(invoiceId, {
|
||||
paid_out_of_band: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.message.includes('Invoice is already paid')) {
|
||||
// This was already marked paid, we can ignore the error.
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async paymentIntentRetrieve(
|
||||
paymentIntentId: string,
|
||||
params?: Stripe.PaymentIntentRetrieveParams
|
||||
|
|
|
@ -32,7 +32,7 @@ export function CartPoller() {
|
|||
retries = await pollCart(checkoutParams, retries, stripe);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
retries += 1;
|
||||
}
|
||||
|
||||
if (retries > 5) {
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
"paths": {
|
||||
"@fxa/payments/legacy": ["libs/payments/legacy/src/index"],
|
||||
"@fxa/payments/stripe": ["libs/payments/stripe/src/index"],
|
||||
"@fxa/payments/paypal": ["libs/payments/paypal/src/index"],
|
||||
"@fxa/payments/customer": ["libs/payments/customer/src/index"],
|
||||
"@fxa/shared/cms": ["libs/shared/cms/src/index"],
|
||||
"@fxa/shared/cloud-tasks": ["libs/shared/cloud-tasks/src/index"],
|
||||
|
|
Загрузка…
Ссылка в новой задаче