зеркало из https://github.com/mozilla/fxa.git
Merge pull request #16292 from mozilla/fxa-8841-paypel-refund-on-delete
feat(auth): Refund Stripe and PayPal invoices on delete
This commit is contained in:
Коммит
1e50382016
|
@ -184,18 +184,22 @@ export class AccountDeleteManager {
|
|||
* @param uid string
|
||||
* @param options AccountDeleteOptions optional object with an optional `notify` function that will be called after the account's removed from MySQL.
|
||||
*/
|
||||
public async deleteAccount(uid: string, options?: AccountDeleteOptions) {
|
||||
public async deleteAccount(
|
||||
uid: string,
|
||||
customerId?: string,
|
||||
options?: AccountDeleteOptions
|
||||
) {
|
||||
await this.deleteAccountFromDb(uid, options);
|
||||
await this.deleteOAuthTokens(uid);
|
||||
// see comment in the function on why we are not awaiting
|
||||
this.deletePushboxRecords(uid);
|
||||
|
||||
await this.deleteSubscriptions(uid);
|
||||
await this.deleteSubscriptions(uid, customerId);
|
||||
await this.deleteFirestoreCustomer(uid);
|
||||
}
|
||||
|
||||
public async cleanupAccount(uid: string) {
|
||||
await this.deleteSubscriptions(uid);
|
||||
public async cleanupAccount(uid: string, customerId?: string) {
|
||||
await this.deleteSubscriptions(uid, customerId);
|
||||
await this.deleteFirestoreCustomer(uid);
|
||||
await this.deleteOAuthTokens(uid);
|
||||
}
|
||||
|
@ -242,15 +246,68 @@ export class AccountDeleteManager {
|
|||
});
|
||||
}
|
||||
|
||||
public async refundSubscriptions(
|
||||
deleteReason: ReasonForDeletion,
|
||||
customerId?: string,
|
||||
refundPeriod?: number
|
||||
) {
|
||||
this.log.debug('AccountDeleteManager.refundSubscriptions.start', {
|
||||
customerId,
|
||||
});
|
||||
|
||||
// Currently only support auto refund of invoices for unverified accounts
|
||||
if (deleteReason !== 'fxa_unverified_account_delete' || !customerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const createdDate = refundPeriod
|
||||
? new Date(new Date().setDate(new Date().getDate() - refundPeriod))
|
||||
: undefined;
|
||||
const invoices =
|
||||
await this.stripeHelper?.fetchInvoicesForActiveSubscriptions(
|
||||
customerId,
|
||||
'paid',
|
||||
createdDate
|
||||
);
|
||||
|
||||
if (!invoices?.length) {
|
||||
return;
|
||||
}
|
||||
this.log.debug('AccountDeleteManager.refundSubscriptions', {
|
||||
customerId,
|
||||
invoicesToRefund: invoices.length,
|
||||
});
|
||||
|
||||
// Attempt Stripe and PayPal refunds
|
||||
const results = await Promise.allSettled([
|
||||
this.stripeHelper?.refundInvoices(invoices),
|
||||
this.paypalHelper?.refundInvoices(invoices),
|
||||
]);
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'rejected') {
|
||||
throw result.reason;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the account's subscriptions from Stripe and PayPal.
|
||||
* This will cancel any active subscriptions and remove the customer.
|
||||
*
|
||||
* @param accountRecord
|
||||
* @param uid - Account UID
|
||||
* @param deleteReason -- @@TODO temporary default deleteReason, remove if necessary
|
||||
* @param refundPeriod -- @@TODO temporary default to 30. Remove if not necessary
|
||||
*/
|
||||
public async deleteSubscriptions(uid: string) {
|
||||
public async deleteSubscriptions(
|
||||
uid: string,
|
||||
customerId?: string,
|
||||
deleteReason: ReasonForDeletion = 'fxa_user_requested_account_delete',
|
||||
refundPeriod?: number
|
||||
) {
|
||||
if (this.config.subscriptions?.enabled && this.stripeHelper) {
|
||||
try {
|
||||
// Before removing the Stripe Customer, refund the subscriptions if necessary
|
||||
await this.refundSubscriptions(deleteReason, customerId, refundPeriod);
|
||||
await this.stripeHelper.removeCustomer(uid);
|
||||
} catch (err) {
|
||||
if (err.message === 'Customer not available') {
|
||||
|
|
|
@ -169,6 +169,15 @@ function throwPaypalCodeError(err: PayPalClientError) {
|
|||
);
|
||||
}
|
||||
|
||||
export class RefundError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'RefundError';
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_REFUND_DAYS = 180;
|
||||
|
||||
export class PayPalHelper {
|
||||
private log: Logger;
|
||||
private client: PayPalClient;
|
||||
|
@ -608,4 +617,64 @@ export class PayPalHelper {
|
|||
message: 'PayPal refund transaction unsuccessful',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to refund all of the invoices passed, provided they're created via PayPal
|
||||
* This will invisibly do nothing if the invoice is not billed through PayPal, so be mindful
|
||||
* if using it elsewhere and need confirmation of a refund.
|
||||
*/
|
||||
public async refundInvoices(invoices: Stripe.Invoice[]) {
|
||||
this.log.debug('PayPalHelper.refundInvoices', {
|
||||
numberOfInvoices: invoices.length,
|
||||
});
|
||||
const minCreated = Math.floor(
|
||||
new Date().setDate(new Date().getDate() - MAX_REFUND_DAYS) / 1000
|
||||
);
|
||||
const payPalInvoices = invoices.filter(
|
||||
(invoice) => invoice.collection_method === 'send_invoice'
|
||||
);
|
||||
|
||||
for (const invoice of payPalInvoices) {
|
||||
this.log.debug('PayPalHelper.refundInvoices', { invoiceId: invoice.id });
|
||||
try {
|
||||
if (invoice.created < minCreated) {
|
||||
throw new RefundError(
|
||||
'Invoice created outside of maximum refund period'
|
||||
);
|
||||
}
|
||||
const transactionId =
|
||||
this.stripeHelper.getInvoicePaypalTransactionId(invoice);
|
||||
if (!transactionId) {
|
||||
throw new RefundError('Missing transactionId');
|
||||
}
|
||||
const refundTransactionId =
|
||||
this.stripeHelper.getInvoicePaypalRefundTransactionId(invoice);
|
||||
if (refundTransactionId) {
|
||||
throw new RefundError('Invoice already refunded with PayPal');
|
||||
}
|
||||
|
||||
await this.issueRefund(invoice, transactionId, RefundType.Full);
|
||||
|
||||
this.log.info('refundInvoices', {
|
||||
invoiceId: invoice.id,
|
||||
priceId: this.stripeHelper.getPriceIdFromInvoice(invoice),
|
||||
total: invoice.total,
|
||||
currency: invoice.currency,
|
||||
});
|
||||
} catch (error) {
|
||||
this.log.error('PayPalHelper.refundInvoices', {
|
||||
error,
|
||||
invoiceId: invoice.id,
|
||||
});
|
||||
if (
|
||||
!(error instanceof RefusedError) &&
|
||||
!(error instanceof RefundError)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1201,6 +1201,56 @@ export class StripeHelper extends StripeHelperBase {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to refund all of the invoices passed, provided they're created via Stripe
|
||||
* This will invisibly do nothing if the invoice is not billed through Stripe, so be mindful
|
||||
* if using it elsewhere and need confirmation of a refund.
|
||||
*/
|
||||
async refundInvoices(invoices: Stripe.Invoice[]) {
|
||||
const stripeInvoices = invoices.filter(
|
||||
(invoice) => invoice.collection_method === 'charge_automatically'
|
||||
);
|
||||
for (const invoice of stripeInvoices) {
|
||||
const chargeId =
|
||||
typeof invoice.charge === 'string'
|
||||
? invoice.charge
|
||||
: invoice.charge?.id;
|
||||
if (!chargeId) continue;
|
||||
|
||||
const charge = await this.stripe.charges.retrieve(chargeId);
|
||||
if (charge.refunded) continue;
|
||||
|
||||
try {
|
||||
await this.stripe.refunds.create({
|
||||
charge: chargeId,
|
||||
});
|
||||
this.log.info('refundInvoices', {
|
||||
invoiceId: invoice.id,
|
||||
priceId: this.getPriceIdFromInvoice(invoice),
|
||||
total: invoice.total,
|
||||
currency: invoice.currency,
|
||||
});
|
||||
} catch (error) {
|
||||
this.log.error('StripeHelper.refundInvoices', {
|
||||
error,
|
||||
invoiceId: invoice.id,
|
||||
});
|
||||
if (
|
||||
[
|
||||
'StripeRateLimitError',
|
||||
'StripeAPIError',
|
||||
'StripeConnectionError',
|
||||
'StripeAuthenticationError',
|
||||
].includes(error.type)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates invoice metadata with the PayPal Transaction ID.
|
||||
*/
|
||||
|
@ -1246,6 +1296,13 @@ export class StripeHelper extends StripeHelperBase {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Paypal transaction id for the invoice if one exists.
|
||||
*/
|
||||
getInvoicePaypalRefundTransactionId(invoice: Stripe.Invoice) {
|
||||
return invoice.metadata?.paypalRefundTransactionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Paypal transaction id for the invoice if one exists.
|
||||
*/
|
||||
|
@ -1717,6 +1774,40 @@ export class StripeHelper extends StripeHelperBase {
|
|||
}
|
||||
}
|
||||
|
||||
async fetchInvoicesForActiveSubscriptions(
|
||||
customerId: string,
|
||||
status: Stripe.InvoiceListParams.Status,
|
||||
earliestCreatedDate?: Date
|
||||
) {
|
||||
const customer = await this.fetchCustomer(customerId, ['subscriptions']);
|
||||
const subscriptions = customer?.subscriptions?.data;
|
||||
if (subscriptions) {
|
||||
const activeSubscriptionIds = subscriptions.map((sub) => sub.id);
|
||||
const created = earliestCreatedDate
|
||||
? { gte: Math.floor(earliestCreatedDate.getTime() / 1000) }
|
||||
: undefined;
|
||||
const invoices = await this.stripe.invoices.list({
|
||||
customer: customerId,
|
||||
status,
|
||||
created,
|
||||
});
|
||||
|
||||
return invoices.data.filter((invoice) => {
|
||||
if (!invoice?.subscription) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const subscriptionId =
|
||||
typeof invoice.subscription === 'string'
|
||||
? invoice.subscription
|
||||
: invoice.subscription.id;
|
||||
return activeSubscriptionIds.includes(subscriptionId);
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* On FxA deletion, if the user is a Stripe Customer:
|
||||
* - delete the stripe customer to delete
|
||||
|
@ -2427,6 +2518,15 @@ export class StripeHelper extends StripeHelperBase {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PriceId of subscription from invoice
|
||||
*/
|
||||
getPriceIdFromInvoice(invoice: Stripe.Invoice) {
|
||||
return invoice.lines.data.find(
|
||||
(invoiceLine) => invoiceLine.type === 'subscription'
|
||||
)?.price?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract invoice details for billing emails.
|
||||
*
|
||||
|
|
|
@ -1858,7 +1858,7 @@ export class AccountHandler {
|
|||
}
|
||||
};
|
||||
|
||||
await this.accountDeleteManager.deleteAccount(uid, { notify });
|
||||
await this.accountDeleteManager.deleteAccount(uid, '', { notify });
|
||||
|
||||
return {};
|
||||
}
|
||||
|
|
|
@ -79,15 +79,22 @@ export class CloudTaskHandler {
|
|||
}
|
||||
};
|
||||
// the account still exists in MySQL, delete as usual
|
||||
await this.accountDeleteManager.deleteAccount(taskPayload.uid, {
|
||||
notify,
|
||||
});
|
||||
await this.accountDeleteManager.deleteAccount(
|
||||
taskPayload.uid,
|
||||
taskPayload.customerId,
|
||||
{
|
||||
notify,
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
// if the account is already deleted from the db, then try to clean up
|
||||
// some potentially remaining other records
|
||||
if (err.errno === ERRNO.ACCOUNT_UNKNOWN) {
|
||||
this.log.info('accountCleanup.byCloudTask', { uid: taskPayload.uid });
|
||||
await this.accountDeleteManager.cleanupAccount(taskPayload.uid);
|
||||
await this.accountDeleteManager.cleanupAccount(
|
||||
taskPayload.uid,
|
||||
taskPayload.customerId
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ const uuid = require('uuid');
|
|||
|
||||
const email = 'foo@example.com';
|
||||
const uid = uuid.v4({}, Buffer.alloc(16)).toString('hex');
|
||||
const customerId = 'cus_123';
|
||||
const expectedSubscriptions = [
|
||||
{ uid, subscriptionId: '123' },
|
||||
{ uid, subscriptionId: '456' },
|
||||
|
@ -75,8 +76,13 @@ describe('AccountDeleteManager', function () {
|
|||
]);
|
||||
mockStripeHelper.removeCustomer = sandbox.stub().resolves();
|
||||
mockStripeHelper.removeFirestoreCustomer = sandbox.stub().resolves();
|
||||
mockStripeHelper.fetchInvoicesForActiveSubscriptions = sandbox
|
||||
.stub()
|
||||
.resolves();
|
||||
mockStripeHelper.refundInvoices = sandbox.stub().resolves();
|
||||
mockPaypalHelper = mocks.mockPayPalHelper(['cancelBillingAgreement']);
|
||||
mockPaypalHelper.cancelBillingAgreement = sandbox.stub().resolves();
|
||||
mockPaypalHelper.refundInvoices = sandbox.stub().resolves();
|
||||
mockAuthModels = {};
|
||||
mockAuthModels.getAllPayPalBAByUid = sinon.spy(async () => {
|
||||
return [{ status: 'Active', billingAgreementId: 'B-test' }];
|
||||
|
@ -223,7 +229,7 @@ describe('AccountDeleteManager', function () {
|
|||
it('should delete the account', async () => {
|
||||
const options = { notify: sandbox.stub().resolves() };
|
||||
|
||||
await accountDeleteManager.deleteAccount(uid, options);
|
||||
await accountDeleteManager.deleteAccount(uid, customerId, options);
|
||||
|
||||
sinon.assert.calledWithMatch(mockFxaDb.deleteAccount, {
|
||||
uid,
|
||||
|
@ -304,4 +310,101 @@ describe('AccountDeleteManager', function () {
|
|||
sinon.assert.calledOnceWithExactly(mockOAuthDb.removeTokensAndCodes, uid);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refundSubscriptions', () => {
|
||||
it('returns immediately when delete reason is not for unverified account', async () => {
|
||||
await accountDeleteManager.refundSubscriptions('invalid_reason');
|
||||
sinon.assert.notCalled(
|
||||
mockStripeHelper.fetchInvoicesForActiveSubscriptions
|
||||
);
|
||||
});
|
||||
|
||||
it('returns if no invoices are found', async () => {
|
||||
mockStripeHelper.fetchInvoicesForActiveSubscriptions.resolves([]);
|
||||
await accountDeleteManager.refundSubscriptions(
|
||||
'fxa_unverified_account_delete',
|
||||
'customerid'
|
||||
);
|
||||
sinon.assert.calledOnceWithExactly(
|
||||
mockStripeHelper.fetchInvoicesForActiveSubscriptions,
|
||||
'customerid',
|
||||
'paid',
|
||||
undefined
|
||||
);
|
||||
sinon.assert.notCalled(mockStripeHelper.refundInvoices);
|
||||
});
|
||||
|
||||
it('attempts refunds on invoices created within refundPeriod', async () => {
|
||||
mockStripeHelper.fetchInvoicesForActiveSubscriptions.resolves([]);
|
||||
await accountDeleteManager.refundSubscriptions(
|
||||
'fxa_unverified_account_delete',
|
||||
'customerid',
|
||||
34
|
||||
);
|
||||
sinon.assert.calledOnceWithExactly(
|
||||
mockStripeHelper.fetchInvoicesForActiveSubscriptions,
|
||||
'customerid',
|
||||
'paid',
|
||||
sinon.match.date
|
||||
);
|
||||
sinon.assert.calledOnce(
|
||||
mockStripeHelper.fetchInvoicesForActiveSubscriptions
|
||||
);
|
||||
sinon.assert.notCalled(mockStripeHelper.refundInvoices);
|
||||
});
|
||||
|
||||
it('attempts refunds on invoices', async () => {
|
||||
const expectedInvoices = ['invoice1', 'invoice2'];
|
||||
const expectedRefundResult = [
|
||||
{
|
||||
invoiceId: 'id1',
|
||||
priceId: 'priceId1',
|
||||
total: '123',
|
||||
currency: 'usd',
|
||||
},
|
||||
];
|
||||
mockStripeHelper.fetchInvoicesForActiveSubscriptions.resolves(
|
||||
expectedInvoices
|
||||
);
|
||||
mockStripeHelper.refundInvoices.resolves(expectedRefundResult);
|
||||
await accountDeleteManager.refundSubscriptions(
|
||||
'fxa_unverified_account_delete',
|
||||
'customerId'
|
||||
);
|
||||
sinon.assert.calledOnceWithExactly(
|
||||
mockStripeHelper.refundInvoices,
|
||||
expectedInvoices
|
||||
);
|
||||
sinon.assert.calledOnceWithExactly(
|
||||
mockPaypalHelper.refundInvoices,
|
||||
expectedInvoices
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects on refundInvoices handler exception', async () => {
|
||||
const expectedInvoices = ['invoice1', 'invoice2'];
|
||||
const expectedError = new Error('expected');
|
||||
mockStripeHelper.fetchInvoicesForActiveSubscriptions.resolves(
|
||||
expectedInvoices
|
||||
);
|
||||
mockStripeHelper.refundInvoices.rejects(expectedError);
|
||||
try {
|
||||
await accountDeleteManager.refundSubscriptions(
|
||||
'fxa_unverified_account_delete',
|
||||
'customerId'
|
||||
);
|
||||
assert.fail('expecting refundSubscriptions exception');
|
||||
} catch (error) {
|
||||
sinon.assert.calledOnceWithExactly(
|
||||
mockStripeHelper.refundInvoices,
|
||||
expectedInvoices
|
||||
);
|
||||
sinon.assert.calledOnceWithExactly(
|
||||
mockPaypalHelper.refundInvoices,
|
||||
expectedInvoices
|
||||
);
|
||||
assert.deepEqual(error, expectedError);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -35,6 +35,7 @@ const {
|
|||
PAYPAL_APP_ERRORS,
|
||||
PAYPAL_RETRY_ERRORS,
|
||||
} = require('../../../lib/payments/paypal/error-codes');
|
||||
const { RefundError } = require('../../../lib/payments/paypal/helper');
|
||||
|
||||
describe('PayPalHelper', () => {
|
||||
/** @type PayPalHelper */
|
||||
|
@ -484,6 +485,163 @@ describe('PayPalHelper', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('refundInvoices', () => {
|
||||
const validInvoice = {
|
||||
id: 'id1',
|
||||
collection_method: 'send_invoice',
|
||||
created: Date.now(),
|
||||
};
|
||||
beforeEach(() => {
|
||||
paypalHelper.log = {
|
||||
debug: sinon.fake.returns({}),
|
||||
info: sinon.fake.returns({}),
|
||||
error: sinon.fake.returns({}),
|
||||
};
|
||||
paypalHelper.issueRefund = sinon.fake.resolves();
|
||||
});
|
||||
it('returns empty array if no payPalInvoices exist', async () => {
|
||||
await paypalHelper.refundInvoices([{ collection_method: 'notpaypal' }]);
|
||||
sinon.assert.notCalled(paypalHelper.issueRefund);
|
||||
});
|
||||
|
||||
it('returns on empty array input', async () => {
|
||||
await paypalHelper.refundInvoices([]);
|
||||
sinon.assert.notCalled(paypalHelper.issueRefund);
|
||||
});
|
||||
|
||||
it('does not refund when created date older than 180 days', async () => {
|
||||
const expectedErrorMessage =
|
||||
'Invoice created outside of maximum refund period';
|
||||
await paypalHelper.refundInvoices([
|
||||
{
|
||||
id: validInvoice.id,
|
||||
collection_method: 'send_invoice',
|
||||
created: Math.floor(
|
||||
new Date().setDate(new Date().getDate() - 200) / 1000
|
||||
),
|
||||
},
|
||||
]);
|
||||
sinon.assert.notCalled(paypalHelper.issueRefund);
|
||||
sinon.assert.calledWithExactly(
|
||||
paypalHelper.log.error,
|
||||
'PayPalHelper.refundInvoices',
|
||||
{
|
||||
error: sinon.match
|
||||
.instanceOf(RefundError)
|
||||
.and(sinon.match.has('message', expectedErrorMessage)),
|
||||
invoiceId: validInvoice.id,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('logs error and continues if transactionId is missing', async () => {
|
||||
const expectedErrorMessage = 'Missing transactionId';
|
||||
mockStripeHelper.getInvoicePaypalTransactionId =
|
||||
sinon.fake.returns(undefined);
|
||||
await paypalHelper.refundInvoices([validInvoice]);
|
||||
sinon.assert.notCalled(paypalHelper.issueRefund);
|
||||
sinon.assert.calledWithExactly(
|
||||
paypalHelper.log.error,
|
||||
'PayPalHelper.refundInvoices',
|
||||
{
|
||||
error: sinon.match
|
||||
.instanceOf(RefundError)
|
||||
.and(sinon.match.has('message', expectedErrorMessage)),
|
||||
invoiceId: validInvoice.id,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('continues if refundTransactionId exists', async () => {
|
||||
const expectedErrorMessage = 'Invoice already refunded with PayPal';
|
||||
mockStripeHelper.getInvoicePaypalTransactionId = sinon.fake.returns(123);
|
||||
mockStripeHelper.getInvoicePaypalRefundTransactionId =
|
||||
sinon.fake.returns(123);
|
||||
await paypalHelper.refundInvoices([validInvoice]);
|
||||
sinon.assert.calledOnce(mockStripeHelper.getInvoicePaypalTransactionId);
|
||||
sinon.assert.calledOnce(
|
||||
mockStripeHelper.getInvoicePaypalRefundTransactionId
|
||||
);
|
||||
sinon.assert.notCalled(paypalHelper.issueRefund);
|
||||
sinon.assert.calledWithExactly(
|
||||
paypalHelper.log.error,
|
||||
'PayPalHelper.refundInvoices',
|
||||
{
|
||||
error: sinon.match
|
||||
.instanceOf(RefundError)
|
||||
.and(sinon.match.has('message', expectedErrorMessage)),
|
||||
invoiceId: validInvoice.id,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('returns on non-retry-able error', async () => {
|
||||
const expectedError = new RefusedError('Helper error');
|
||||
mockStripeHelper.getInvoicePaypalTransactionId = sinon.fake.returns(123);
|
||||
mockStripeHelper.getInvoicePaypalRefundTransactionId =
|
||||
sinon.fake.returns(undefined);
|
||||
paypalHelper.issueRefund = sinon.fake.rejects(expectedError);
|
||||
await paypalHelper.refundInvoices([validInvoice]);
|
||||
sinon.assert.calledWithExactly(
|
||||
paypalHelper.log.error,
|
||||
'PayPalHelper.refundInvoices',
|
||||
{
|
||||
error: sinon.match
|
||||
.instanceOf(RefusedError)
|
||||
.and(sinon.match.has('message', 'Helper error')),
|
||||
invoiceId: validInvoice.id,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects on error that can be retried', async () => {
|
||||
const expectedError = new Error('Helper error');
|
||||
mockStripeHelper.getInvoicePaypalTransactionId =
|
||||
sinon.fake.throws(expectedError);
|
||||
try {
|
||||
await paypalHelper.refundInvoices([validInvoice]);
|
||||
assert.fail('Should throw error on failure');
|
||||
} catch (error) {
|
||||
sinon.assert.calledWithExactly(
|
||||
paypalHelper.log.error,
|
||||
'PayPalHelper.refundInvoices',
|
||||
{
|
||||
error: sinon.match
|
||||
.instanceOf(Error)
|
||||
.and(sinon.match.has('message', 'Helper error')),
|
||||
invoiceId: validInvoice.id,
|
||||
}
|
||||
);
|
||||
assert.deepEqual(error, expectedError);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns refund results on success', async () => {
|
||||
const expectedInvoiceResults = {
|
||||
invoiceId: validInvoice.id,
|
||||
priceId: 'priceId1',
|
||||
total: 400,
|
||||
currency: 'usd',
|
||||
};
|
||||
mockStripeHelper.getInvoicePaypalTransactionId =
|
||||
sinon.fake.returns('123');
|
||||
mockStripeHelper.getInvoicePaypalRefundTransactionId =
|
||||
sinon.fake.returns(undefined);
|
||||
mockStripeHelper.getPriceIdFromInvoice = sinon.fake.returns(
|
||||
expectedInvoiceResults.priceId
|
||||
);
|
||||
await paypalHelper.refundInvoices([
|
||||
{ ...validInvoice, ...expectedInvoiceResults },
|
||||
]);
|
||||
sinon.assert.calledOnceWithExactly(
|
||||
paypalHelper.log.info,
|
||||
'refundInvoices',
|
||||
expectedInvoiceResults
|
||||
);
|
||||
sinon.assert.notCalled(paypalHelper.log.error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelBillingAgreement', () => {
|
||||
it('cancels an agreement', async () => {
|
||||
paypalHelper.client.doRequest = sinon.fake.resolves(
|
||||
|
|
|
@ -2534,6 +2534,54 @@ describe('#integration - StripeHelper', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('refundInvoices', () => {
|
||||
it('refunds invoice with charge unexpanded', async () => {
|
||||
sandbox.stub(stripeHelper.stripe.refunds, 'create').resolves({});
|
||||
sandbox
|
||||
.stub(stripeHelper.stripe.charges, 'retrieve')
|
||||
.resolves({ refunded: false });
|
||||
await stripeHelper.refundInvoices([
|
||||
{
|
||||
...paidInvoice,
|
||||
collection_method: 'charge_automatically',
|
||||
},
|
||||
]);
|
||||
sinon.assert.calledOnceWithExactly(stripeHelper.stripe.refunds.create, {
|
||||
charge: paidInvoice.charge,
|
||||
});
|
||||
});
|
||||
|
||||
it('refunds invoice with charge expanded', async () => {
|
||||
sandbox.stub(stripeHelper.stripe.refunds, 'create').resolves({});
|
||||
sandbox
|
||||
.stub(stripeHelper.stripe.charges, 'retrieve')
|
||||
.resolves({ refunded: false });
|
||||
await stripeHelper.refundInvoices([
|
||||
{
|
||||
...paidInvoice,
|
||||
collection_method: 'charge_automatically',
|
||||
charge: {
|
||||
id: paidInvoice.charge,
|
||||
},
|
||||
},
|
||||
]);
|
||||
sinon.assert.calledOnceWithExactly(stripeHelper.stripe.refunds.create, {
|
||||
charge: paidInvoice.charge,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not refund invoice from PayPal', async () => {
|
||||
sandbox.stub(stripeHelper.stripe.refunds, 'create').resolves({});
|
||||
await stripeHelper.refundInvoices([
|
||||
{
|
||||
...paidInvoice,
|
||||
collection_method: 'send_invoice',
|
||||
},
|
||||
]);
|
||||
sinon.assert.notCalled(stripeHelper.stripe.refunds.create);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateInvoiceWithPaypalTransactionId', () => {
|
||||
it('works successfully', async () => {
|
||||
sandbox.stub(stripeHelper.stripe.invoices, 'update').resolves({});
|
||||
|
@ -4037,6 +4085,95 @@ describe('#integration - StripeHelper', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('fetchInvoicesForActiveSubscriptions', () => {
|
||||
it('returns empty array if no stripe customer', async () => {
|
||||
const noCustomer = '192499bcb0cf4da2bf1b37f1a37f3b88';
|
||||
const result = await stripeHelper.fetchInvoicesForActiveSubscriptions(
|
||||
noCustomer,
|
||||
'paid'
|
||||
);
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
it('returns empty array if customer has no active subscriptions', async () => {
|
||||
sandbox.stub(stripeHelper, 'fetchCustomer').resolves({});
|
||||
const result = await stripeHelper.fetchInvoicesForActiveSubscriptions(
|
||||
existingUid,
|
||||
'paid'
|
||||
);
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
it('fetches invoices no older than earliestCreatedDate', async () => {
|
||||
sandbox.stub(stripeHelper, 'fetchCustomer').resolves({
|
||||
subscriptions: { data: [] },
|
||||
});
|
||||
sandbox.stub(stripeHelper.stripe.invoices, 'list').resolves({ data: [] });
|
||||
const expectedDateTime = 1706667661086;
|
||||
const expectedDate = new Date(expectedDateTime);
|
||||
|
||||
const result = await stripeHelper.fetchInvoicesForActiveSubscriptions(
|
||||
'customerId',
|
||||
'paid',
|
||||
expectedDate
|
||||
);
|
||||
|
||||
assert.deepEqual(result, []);
|
||||
sinon.assert.calledOnceWithExactly(stripeHelper.stripe.invoices.list, {
|
||||
customer: 'customerId',
|
||||
status: 'paid',
|
||||
created: { gte: Math.floor(expectedDateTime / 1000) },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns only invoices of active subscriptions', async () => {
|
||||
const expectedExpanded = {
|
||||
id: 'idExpanded',
|
||||
subscription: {
|
||||
id: 'subIdExpanded',
|
||||
},
|
||||
};
|
||||
const expectedString = {
|
||||
id: 'idString',
|
||||
subscription: 'idSub',
|
||||
};
|
||||
sandbox.stub(stripeHelper, 'fetchCustomer').resolves({
|
||||
subscriptions: {
|
||||
data: [
|
||||
{
|
||||
id: 'idNull',
|
||||
},
|
||||
{
|
||||
id: 'subIdExpanded',
|
||||
},
|
||||
{
|
||||
id: 'idSub',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
sandbox.stub(stripeHelper.stripe.invoices, 'list').resolves({
|
||||
data: [
|
||||
{
|
||||
id: 'idNull',
|
||||
subscription: null,
|
||||
},
|
||||
{
|
||||
...expectedExpanded,
|
||||
},
|
||||
{
|
||||
...expectedString,
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await stripeHelper.fetchInvoicesForActiveSubscriptions(
|
||||
existingUid,
|
||||
'paid'
|
||||
);
|
||||
assert.deepEqual(result, [expectedExpanded, expectedString]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeCustomer', () => {
|
||||
let stripeCustomerDel;
|
||||
const email = 'test@example.com';
|
||||
|
|
|
@ -34,9 +34,11 @@ describe('/cloud-tasks/accounts/delete', () => {
|
|||
mockPush = mocks.mockPush();
|
||||
sandbox.reset();
|
||||
|
||||
deleteAccountStub = sandbox.stub().callsFake((uid, { notify }) => {
|
||||
notify();
|
||||
});
|
||||
deleteAccountStub = sandbox
|
||||
.stub()
|
||||
.callsFake((uid, customerId, { notify }) => {
|
||||
notify();
|
||||
});
|
||||
cleanupAccountStub = sandbox.stub().resolves();
|
||||
Container.set(AccountDeleteManager, {
|
||||
deleteAccount: deleteAccountStub,
|
||||
|
@ -86,6 +88,7 @@ describe('/cloud-tasks/accounts/delete', () => {
|
|||
event: 'account.deleted',
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
assert.fail('An error should not have been thrown.');
|
||||
}
|
||||
});
|
||||
|
|
Загрузка…
Ссылка в новой задаче