fix(auth-server): paypal customer fix script

Because:

* Credit notes issued as account balance aren't refunded in paypal
  correctly.

This commit:

* Locates invoices since launch that had a credit note applied that was
  credited to the balance instead of refunded and fixes the balance,
  issues a paypal refund, and cancels the subscription.

Closes #8544
This commit is contained in:
Ben Bangert 2021-05-11 13:25:40 -07:00
Родитель 9eca7f70b8
Коммит 42a7a3c9d6
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 340D6D716D25CCA6
1 изменённых файлов: 189 добавлений и 0 удалений

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

@ -0,0 +1,189 @@
/* 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 { AuthLogger } from 'fxa-auth-server/lib/types';
import { ACTIVE_SUBSCRIPTION_STATUSES } from 'fxa-shared/subscriptions/stripe';
import { StatsD } from 'hot-shots';
import stripe from 'stripe';
import Container from 'typedi';
import { CurrencyHelper } from '../lib/payments/currencies';
import { PayPalHelper } from '../lib/payments/paypal';
import { PayPalClient } from '../lib/payments/paypal-client';
import { STRIPE_INVOICE_METADATA, StripeHelper } from '../lib/payments/stripe';
import { configureSentry } from '../lib/sentry';
const config = require('../config').getProperties();
class PayPalFixer {
private stripe: stripe;
constructor(
private log: AuthLogger,
private stripeHelper: StripeHelper,
private paypalHelper: PayPalHelper
) {
this.stripe = (this.stripeHelper as any).stripe as stripe;
}
private activeSubscriptions(
subscriptions: stripe.Subscription[] | undefined
) {
if (!subscriptions) {
return [];
}
return subscriptions.filter((sub) =>
ACTIVE_SUBSCRIPTION_STATUSES.includes(sub.status)
);
}
private async *nonRefundedPaypalInvoices(limit?: number) {
const startDate = new Date(2021, 3, 20).getTime() / 1000;
let count = 0;
for await (const invoice of this.stripe.invoices.list({
limit: 100,
status: 'paid',
collection_method: 'send_invoice',
created: { gt: startDate },
})) {
if (
!invoice.metadata![STRIPE_INVOICE_METADATA.PAYPAL_TRANSACTION_ID] ||
invoice.metadata![
STRIPE_INVOICE_METADATA.PAYPAL_REFUND_TRANSACTION_ID
] ||
invoice.post_payment_credit_notes_amount === 0
) {
continue;
}
yield invoice;
count++;
if (limit && count >= limit) {
break;
}
}
}
async zeroAccountBalance(customer: stripe.Customer) {
if (customer.balance >= 0) {
return;
}
const zeroAmount = Math.abs(customer.balance);
await this.stripe.customers.createBalanceTransaction(customer.id, {
amount: zeroAmount,
currency: customer.currency!,
});
}
async issueRefund(invoice: stripe.Invoice) {
const transactionId = this.stripeHelper.getInvoicePaypalTransactionId(
invoice
);
if (!transactionId) {
return;
}
const refundResponse = await this.paypalHelper.refundTransaction({
idempotencyKey: invoice.id,
transactionId: transactionId,
});
const success = ['instant', 'delayed'];
if (success.includes(refundResponse.refundStatus.toLowerCase())) {
await this.stripeHelper.updateInvoiceWithPaypalRefundTransactionId(
invoice,
refundResponse.refundTransactionId
);
} else {
this.log.error('issueRefund', {
message: 'PayPal refund transaction unsuccessful',
invoiceId: invoice.id,
transactionId,
refundResponse,
});
}
}
async fixAffectedCustomers() {
let i = 0;
for await (const invoice of this.nonRefundedPaypalInvoices()) {
const customer = await this.stripe.customers.retrieve(
invoice.customer as string,
{ expand: ['subscriptions'] }
);
const subs = customer.deleted
? []
: this.activeSubscriptions(customer.subscriptions?.data);
this.log.info('Invoice found', {
invoiceId: invoice.id,
customerId: customer.id,
balance: customer.deleted ? 0 : customer.balance,
subs: subs.length,
deleted: !!customer.deleted,
});
i++;
if (!customer.deleted) {
await this.zeroAccountBalance(customer);
}
if (subs.length === 1) {
const sub = subs[0];
if (sub.id === invoice.subscription) {
await this.stripeHelper.cancelSubscription(sub.id);
} else {
this.log.info('Skipping cancellation of unrelated subscription.', {});
}
}
// Issue the refund
try {
await this.issueRefund(invoice);
} catch (err) {
this.log.error('Error handling invoice.', {
err,
invoiceId: invoice.id,
});
}
}
this.log.info('Total non-refunded paypal transactions', { total: i });
}
}
export async function init() {
configureSentry(undefined, config);
const statsd = config.statsd.enabled
? new StatsD({
...config.statsd,
errorHandler: (err) => {
// eslint-disable-next-line no-use-before-define
log.error('statsd.error', err);
},
})
: (({
increment: () => {},
timing: () => {},
close: () => {},
} as unknown) as StatsD);
Container.set(StatsD, statsd);
const log = require('../lib/log')({ ...config.log, statsd });
const currencyHelper = new CurrencyHelper(config);
Container.set(CurrencyHelper, currencyHelper);
const stripeHelper = new StripeHelper(log, config, statsd);
Container.set(StripeHelper, stripeHelper);
const paypalClient = new PayPalClient(
config.subscriptions.paypalNvpSigCredentials
);
Container.set(PayPalClient, paypalClient);
const paypalHelper = new PayPalHelper({ log });
Container.set(PayPalHelper, paypalHelper);
const fixer = new PayPalFixer(log, stripeHelper, paypalHelper);
await fixer.fixAffectedCustomers();
return 0;
}
if (require.main === module) {
init()
.catch((err) => {
console.error(err);
process.exit(1);
})
.then((result) => process.exit(result));
}