Merge pull request #17961 from mozilla/FXA-10189-payPal-processNonZeroInvoice

setup paypal processnonzeroinvoice
This commit is contained in:
Davey Alvarez 2024-11-19 12:56:31 -08:00 коммит произвёл GitHub
Родитель cc70f3d98c 5a753ffaf0
Коммит b42e55ddb9
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
22 изменённых файлов: 916 добавлений и 111 удалений

Просмотреть файл

@ -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"],