mirror of
https://github.com/mozilla/fxa.git
synced 2025-12-13 20:36:41 +01:00
8034 lines
263 KiB
JavaScript
8034 lines
263 KiB
JavaScript
/* 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/. */
|
|
|
|
'use strict';
|
|
|
|
const sinon = require('sinon');
|
|
const Sentry = require('@sentry/node');
|
|
const sentryModule = require('../../../lib/sentry');
|
|
const { assert } = require('chai');
|
|
const Chance = require('chance');
|
|
const { setupAuthDatabase } = require('fxa-shared/db');
|
|
const Knex = require('knex');
|
|
const { mockLog, asyncIterable } = require('../../mocks');
|
|
const { AppError: error } = require('@fxa/accounts/errors');
|
|
const stripeError = require('stripe').Stripe.errors;
|
|
const uuidv4 = require('uuid').v4;
|
|
const moment = require('moment');
|
|
const { Container } = require('typedi');
|
|
|
|
const chance = new Chance();
|
|
let mockRedis;
|
|
const proxyquire = require('proxyquire').noPreserveCache();
|
|
const dbStub = {
|
|
getUidAndEmailByStripeCustomerId: sinon.stub(),
|
|
};
|
|
const {
|
|
MozillaSubscriptionTypes,
|
|
PAYPAL_PAYMENT_ERROR_FUNDING_SOURCE,
|
|
PAYPAL_PAYMENT_ERROR_MISSING_AGREEMENT,
|
|
} = require('../../../../fxa-shared/subscriptions/types');
|
|
const {
|
|
StripeHelper,
|
|
STRIPE_INVOICE_METADATA,
|
|
SUBSCRIPTION_UPDATE_TYPES,
|
|
MOZILLA_TAX_ID,
|
|
CUSTOMER_RESOURCE,
|
|
SUBSCRIPTIONS_RESOURCE,
|
|
} = proxyquire('../../../lib/payments/stripe', {
|
|
'../redis': (config, log) => mockRedis.init(config, log),
|
|
'fxa-shared/db/models/auth': dbStub,
|
|
});
|
|
const { CurrencyHelper } = require('../../../lib/payments/currencies');
|
|
const {
|
|
generateIdempotencyKey,
|
|
roundTime,
|
|
} = require('../../../lib/payments/utils');
|
|
const {
|
|
stripeInvoiceToLatestInvoiceItemsDTO,
|
|
} = require('../../../lib/payments/stripe-formatter');
|
|
const {
|
|
ProductConfigurationManager,
|
|
PurchaseWithDetailsOfferingContentTransformedFactory,
|
|
} = require('@fxa/shared/cms');
|
|
|
|
const customer1 = require('./fixtures/stripe/customer1.json');
|
|
const newCustomer = require('./fixtures/stripe/customer_new.json');
|
|
const newCustomerPM = require('./fixtures/stripe/customer_new_pmi.json');
|
|
const deletedCustomer = require('./fixtures/stripe/customer_deleted.json');
|
|
const taxRateDe = require('./fixtures/stripe/taxRateDe.json');
|
|
const taxRateFr = require('./fixtures/stripe/taxRateFr.json');
|
|
const plan1 = require('./fixtures/stripe/plan1.json');
|
|
const plan2 = require('./fixtures/stripe/plan2.json');
|
|
const plan3 = require('./fixtures/stripe/plan3.json');
|
|
const product1 = require('./fixtures/stripe/product1.json');
|
|
const product2 = require('./fixtures/stripe/product2.json');
|
|
const product3 = require('./fixtures/stripe/product3.json');
|
|
const subscription1 = require('./fixtures/stripe/subscription1.json');
|
|
const subscription2 = require('./fixtures/stripe/subscription2.json');
|
|
const multiPlanSubscription = require('./fixtures/stripe/subscription_multiplan.json');
|
|
const subscriptionPMIExpanded = require('./fixtures/stripe/subscription_pmi_expanded.json');
|
|
const subscriptionPMIExpandedIncompleteCVCFail = require('./fixtures/stripe/subscription_pmi_expanded_incomplete_cvc_fail.json');
|
|
const cancelledSubscription = require('./fixtures/stripe/subscription_cancelled.json');
|
|
const pastDueSubscription = require('./fixtures/stripe/subscription_past_due.json');
|
|
const subscriptionCouponOnce = require('./fixtures/stripe/subscription_coupon_once.json');
|
|
const subscriptionCouponForever = require('./fixtures/stripe/subscription_coupon_forever.json');
|
|
const subscriptionCouponRepeating = require('./fixtures/stripe/subscription_coupon_repeating.json');
|
|
const paidInvoice = require('./fixtures/stripe/invoice_paid.json');
|
|
const unpaidInvoice = require('./fixtures/stripe/invoice_open.json');
|
|
const invoiceRetry = require('./fixtures/stripe/invoice_retry.json');
|
|
const successfulPaymentIntent = require('./fixtures/stripe/paymentIntent_succeeded.json');
|
|
const unsuccessfulPaymentIntent = require('./fixtures/stripe/paymentIntent_requires_payment_method.json');
|
|
const paymentMethodAttach = require('./fixtures/stripe/payment_method_attach.json');
|
|
const failedCharge = require('./fixtures/stripe/charge_failed.json');
|
|
const invoicePaidSubscriptionCreate = require('./fixtures/stripe/invoice_paid_subscription_create.json');
|
|
const invoicePaidSubscriptionCreateDiscount = require('./fixtures/stripe/invoice_paid_subscription_create_discount.json');
|
|
const invoicePaidSubscriptionCreateTaxDiscount = require('./fixtures/stripe/invoice_paid_subscription_create_tax_discount.json');
|
|
const invoiceDraftProrationRefund = require('./fixtures/stripe/invoice_draft_proration_refund.json');
|
|
const invoicePaidSubscriptionCreateTax = require('./fixtures/stripe/invoice_paid_subscription_create_tax.json');
|
|
const eventCustomerSourceExpiring = require('./fixtures/stripe/event_customer_source_expiring.json');
|
|
const eventCustomerSubscriptionUpdated = require('./fixtures/stripe/event_customer_subscription_updated.json');
|
|
const subscriptionCreatedInvoice = require('./fixtures/stripe/invoice_paid_subscription_create.json');
|
|
const eventInvoiceCreated = require('./fixtures/stripe/event_invoice_created.json');
|
|
const eventSubscriptionUpdated = require('./fixtures/stripe/event_customer_subscription_updated.json');
|
|
const eventCustomerUpdated = require('./fixtures/stripe/event_customer_updated.json');
|
|
const eventPaymentMethodAttached = require('./fixtures/stripe/event_payment_method_attached.json');
|
|
const eventPaymentMethodDetached = require('./fixtures/stripe/event_payment_method_detached.json');
|
|
const closedPaymementIntent = require('./fixtures/stripe/paymentIntent_succeeded.json');
|
|
const newSetupIntent = require('./fixtures/stripe/setup_intent_new.json');
|
|
|
|
// App Store Server API response fixtures
|
|
const appStoreApiResponse = require('./fixtures/apple-app-store/api_response_subscription_status.json');
|
|
const renewalInfo = require('./fixtures/apple-app-store/decoded_renewal_info.json');
|
|
const transactionInfo = require('./fixtures/apple-app-store/decoded_transaction_info.json');
|
|
|
|
const {
|
|
createAccountCustomer,
|
|
getAccountCustomerByUid,
|
|
} = require('fxa-shared/db/models/auth');
|
|
const {
|
|
AppStoreSubscriptionPurchase,
|
|
} = require('../../../lib/payments/iap/apple-app-store/subscription-purchase');
|
|
const {
|
|
PlayStoreSubscriptionPurchase,
|
|
} = require('../../../lib/payments/iap/google-play/subscription-purchase');
|
|
const { AuthFirestore, AuthLogger, AppConfig } = require('../../../lib/types');
|
|
const {
|
|
INVOICES_RESOURCE,
|
|
PAYMENT_METHOD_RESOURCE,
|
|
STRIPE_PRICE_METADATA,
|
|
} = require('../../../lib/payments/stripe');
|
|
const { GoogleMapsService } = require('../../../lib/google-maps-services');
|
|
const {
|
|
FirestoreStripeError,
|
|
newFirestoreStripeError,
|
|
StripeFirestoreMultiError,
|
|
} = require('../../../lib/payments/stripe-firestore');
|
|
|
|
const mockConfig = {
|
|
authFirestore: {
|
|
prefix: 'fxa-auth-',
|
|
},
|
|
publicUrl: 'https://accounts.example.com',
|
|
subscriptions: {
|
|
cacheTtlSeconds: 10,
|
|
productConfigsFirestore: { enabled: true },
|
|
stripeApiKey: 'blah',
|
|
},
|
|
subhub: {
|
|
enabled: true,
|
|
url: 'https://foo.bar',
|
|
key: 'foo',
|
|
customerCacheTtlSeconds: 90,
|
|
plansCacheTtlSeconds: 60,
|
|
stripeTaxRatesCacheTtlSeconds: 60,
|
|
},
|
|
currenciesToCountries: { ZAR: ['AS', 'CA'] },
|
|
cms: {
|
|
enabled: false,
|
|
legacyMapper: {
|
|
mapperCacheTTL: 60,
|
|
},
|
|
},
|
|
};
|
|
|
|
const mockRedisConfig = {
|
|
host: process.env.REDIS_HOST || 'localhost',
|
|
port: process.env.REDIS_PORT || 6379,
|
|
password: process.env.REDIS_PASSWORD || '',
|
|
maxPending: 1000,
|
|
retryCount: 5,
|
|
initialBackoff: '100 milliseconds',
|
|
subhub: {
|
|
enabled: true,
|
|
prefix: 'subhub:',
|
|
minConnections: 1,
|
|
},
|
|
};
|
|
|
|
function createMockRedis() {
|
|
let _data = {};
|
|
const mock = {
|
|
reset() {
|
|
_data = {};
|
|
},
|
|
_data() {
|
|
return _data;
|
|
},
|
|
init(config, log) {
|
|
this.reset();
|
|
this.redis = this;
|
|
return this;
|
|
},
|
|
info() {
|
|
return 'mock\nredis';
|
|
},
|
|
async set(key, value, opt, ttl) {
|
|
_data[key] = value;
|
|
},
|
|
async del(key) {
|
|
delete _data[key];
|
|
},
|
|
async get(key) {
|
|
return _data[key];
|
|
},
|
|
};
|
|
Object.keys(mock).forEach((key) => sinon.spy(mock, key));
|
|
mock.options = {};
|
|
return mock;
|
|
}
|
|
|
|
mockConfig.redis = mockRedisConfig;
|
|
|
|
const testKnexConfig = {
|
|
client: 'mysql',
|
|
connection: {
|
|
charset: 'UTF8MB4_BIN',
|
|
host: process.env.MYSQL_HOST || 'localhost',
|
|
password: process.env.MYSQL_PASSWORD || '',
|
|
port: process.env.MYSQL_PORT || 3306,
|
|
user: process.env.MYSQL_USERNAME || 'root',
|
|
},
|
|
};
|
|
|
|
mockConfig.database = {
|
|
mysql: {
|
|
auth: {
|
|
database: 'testStripeHelper',
|
|
host: process.env.MYSQL_HOST || 'localhost',
|
|
password: process.env.MYSQL_PASSWORD || '',
|
|
port: process.env.MYSQL_PORT || 3306,
|
|
user: process.env.MYSQL_USERNAME || 'root',
|
|
},
|
|
},
|
|
};
|
|
|
|
async function createTestDatabase() {
|
|
const knex = Knex(testKnexConfig);
|
|
|
|
await knex.raw('DROP DATABASE IF EXISTS testStripeHelper');
|
|
await knex.raw('CREATE DATABASE testStripeHelper');
|
|
await knex.raw(
|
|
'CREATE TABLE testStripeHelper.`accountCustomers` (`uid` BINARY(16) PRIMARY KEY,`stripeCustomerId` VARCHAR(32),`createdAt` BIGINT UNSIGNED NOT NULL,`updatedAt` BIGINT UNSIGNED NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;'
|
|
);
|
|
await knex.destroy();
|
|
setupAuthDatabase(mockConfig.database.mysql.auth);
|
|
}
|
|
|
|
async function destroyTestDatabase() {
|
|
const knex = Knex(testKnexConfig);
|
|
await knex.raw('DROP DATABASE IF EXISTS testStripeHelper');
|
|
await knex.destroy();
|
|
}
|
|
|
|
/**
|
|
* To prevent the modification of the test objects loaded, which can impact other tests referencing the object,
|
|
* a deep copy of the object can be created which uses the test object as a template
|
|
*
|
|
* @param {Object} object
|
|
*/
|
|
function deepCopy(object) {
|
|
return JSON.parse(JSON.stringify(object));
|
|
}
|
|
|
|
const mockConfigCollection = (configDocs) => ({
|
|
get: () => ({ docs: configDocs.map((c) => ({ id: c.id, data: () => c })) }),
|
|
onSnapshot: () => {},
|
|
});
|
|
|
|
describe('#integration - StripeHelper', () => {
|
|
/** @type StripeHelper */
|
|
let stripeHelper;
|
|
/** @type sinon.SinonSandbox */
|
|
let sandbox;
|
|
let listStripePlans;
|
|
let log;
|
|
/** @type AccountCustomers */
|
|
let existingCustomer;
|
|
let mockStatsd;
|
|
const existingUid = '40cc397def2d487b9b8ba0369079a267';
|
|
let stripeFirestore;
|
|
let mockGoogleMapsService;
|
|
|
|
before(async () => {
|
|
await createTestDatabase();
|
|
existingCustomer = await createAccountCustomer(existingUid, customer1.id);
|
|
});
|
|
|
|
after(async () => {
|
|
await destroyTestDatabase();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
sandbox = sinon.createSandbox();
|
|
mockRedis = createMockRedis();
|
|
log = mockLog();
|
|
mockStatsd = {
|
|
increment: sandbox.fake.returns({}),
|
|
timing: sandbox.fake.returns({}),
|
|
close: sandbox.fake.returns({}),
|
|
};
|
|
// Make currencyHelper
|
|
const currencyHelper = new CurrencyHelper(mockConfig);
|
|
Container.set(CurrencyHelper, currencyHelper);
|
|
Container.set(AuthFirestore, {
|
|
collection: sandbox.stub().callsFake((arg) => {
|
|
if (arg.endsWith('products')) {
|
|
return mockConfigCollection([
|
|
{ id: 'doc1', stripeProductId: product1.id },
|
|
{ id: 'doc2', stripeProductId: product2.id },
|
|
{ id: 'doc3', stripeProductId: product3.id },
|
|
]);
|
|
}
|
|
if (arg.endsWith('plans')) {
|
|
return mockConfigCollection([
|
|
{
|
|
id: 'doc1',
|
|
productConfigId: 'doc1',
|
|
stripePriceId: plan1.id,
|
|
},
|
|
{
|
|
id: 'doc2',
|
|
productConfigId: 'doc2',
|
|
stripePriceId: plan2.id,
|
|
},
|
|
]);
|
|
}
|
|
|
|
return {};
|
|
}),
|
|
});
|
|
Container.set(AuthLogger, log);
|
|
Container.set(AppConfig, mockConfig);
|
|
mockGoogleMapsService = {
|
|
getStateFromZip: sandbox.stub().resolves('ABD'),
|
|
};
|
|
Container.set(GoogleMapsService, mockGoogleMapsService);
|
|
|
|
stripeHelper = new StripeHelper(log, mockConfig, mockStatsd);
|
|
stripeHelper.redis = mockRedis;
|
|
stripeHelper.stripeFirestore = stripeFirestore = {};
|
|
listStripePlans = sandbox
|
|
.stub(stripeHelper.stripe.plans, 'list')
|
|
.returns(asyncIterable([plan1, plan2, plan3]));
|
|
sandbox
|
|
.stub(stripeHelper.stripe.taxRates, 'list')
|
|
.returns(asyncIterable([taxRateDe, taxRateFr]));
|
|
sandbox
|
|
.stub(stripeHelper.stripe.products, 'list')
|
|
.returns(asyncIterable([product1, product2, product3]));
|
|
});
|
|
|
|
afterEach(() => {
|
|
Container.reset();
|
|
sandbox.restore();
|
|
});
|
|
|
|
describe('constructor', () => {
|
|
it('sets currencyHelper', () => {
|
|
const expectedCurrencyHelper = new CurrencyHelper(mockConfig);
|
|
assert.deepEqual(stripeHelper.currencyHelper, expectedCurrencyHelper);
|
|
});
|
|
});
|
|
|
|
describe('createPlainCustomer', () => {
|
|
it('creates a customer using stripe api', async () => {
|
|
const expected = deepCopy(newCustomerPM);
|
|
sandbox.stub(stripeHelper.stripe.customers, 'create').resolves(expected);
|
|
stripeFirestore.insertCustomerRecord = sandbox.stub().resolves({});
|
|
const uid = chance.guid({ version: 4 }).replace(/-/g, '');
|
|
const actual = await stripeHelper.createPlainCustomer({
|
|
uid,
|
|
email: 'joe@example.com',
|
|
displayName: 'Joe Cool',
|
|
idempotencyKey: uuidv4(),
|
|
});
|
|
assert.deepEqual(actual, expected);
|
|
sinon.assert.calledWithExactly(
|
|
stripeHelper.stripeFirestore.insertCustomerRecord,
|
|
uid,
|
|
expected
|
|
);
|
|
});
|
|
|
|
it('creates a customer using the stripe api with a shipping address', async () => {
|
|
const expected = deepCopy(newCustomerPM);
|
|
sandbox.stub(stripeHelper.stripe.customers, 'create').resolves(expected);
|
|
stripeFirestore.insertCustomerRecord = sandbox.stub().resolves({});
|
|
const uid = chance.guid({ version: 4 }).replace(/-/g, '');
|
|
const idempotencyKey = uuidv4();
|
|
const actual = await stripeHelper.createPlainCustomer({
|
|
uid,
|
|
email: 'joe@example.com',
|
|
displayName: 'Joe Cool',
|
|
idempotencyKey,
|
|
taxAddress: {
|
|
countryCode: 'US',
|
|
postalCode: '92841',
|
|
},
|
|
});
|
|
assert.deepEqual(actual, expected);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.customers.create,
|
|
{
|
|
email: 'joe@example.com',
|
|
name: 'Joe Cool',
|
|
description: uid,
|
|
metadata: {
|
|
userid: uid,
|
|
geoip_date: sinon.match.any,
|
|
},
|
|
shipping: {
|
|
name: sinon.match.any,
|
|
address: {
|
|
country: 'US',
|
|
postal_code: '92841',
|
|
},
|
|
},
|
|
},
|
|
{ idempotencyKey }
|
|
);
|
|
sinon.assert.calledWithExactly(
|
|
stripeHelper.stripeFirestore.insertCustomerRecord,
|
|
uid,
|
|
expected
|
|
);
|
|
});
|
|
|
|
it('surfaces stripe errors', async () => {
|
|
const apiError = new stripeError.StripeAPIError();
|
|
sandbox.stub(stripeHelper.stripe.customers, 'create').rejects(apiError);
|
|
|
|
return stripeHelper
|
|
.createPlainCustomer({
|
|
uid: 'uid',
|
|
email: 'joe@example.com',
|
|
displayName: 'Joe Cool',
|
|
idempotencyKey: uuidv4(),
|
|
})
|
|
.then(
|
|
() => Promise.reject(new Error('Method expected to reject')),
|
|
(err) => {
|
|
assert.equal(err, apiError);
|
|
}
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('createLocalCustomer', () => {
|
|
it('inserts a local customer record', async () => {
|
|
const uid = '993499bcb0cf4da2bf1b37f1a37f3b88';
|
|
|
|
// customer doesn't exist
|
|
const existingCustomer = await getAccountCustomerByUid(uid);
|
|
assert.isUndefined(existingCustomer);
|
|
|
|
await stripeHelper.createLocalCustomer(uid, newCustomer);
|
|
|
|
// customer does exist
|
|
const insertedCustomer = await getAccountCustomerByUid(uid);
|
|
assert.isObject(insertedCustomer);
|
|
|
|
// inserting again
|
|
await stripeHelper.createLocalCustomer(uid, {
|
|
...newCustomer,
|
|
id: 'cus_nope',
|
|
});
|
|
const sameCustomer = await getAccountCustomerByUid(uid);
|
|
assert.notEqual(sameCustomer.stripeCustomerId, 'cus_nope');
|
|
});
|
|
});
|
|
|
|
describe('createSetupIntent', () => {
|
|
it('creates a setup intent', async () => {
|
|
const expected = deepCopy(newSetupIntent);
|
|
sandbox
|
|
.stub(stripeHelper.stripe.setupIntents, 'create')
|
|
.resolves(expected);
|
|
|
|
const actual = await stripeHelper.createSetupIntent('cust_new');
|
|
|
|
assert.deepEqual(actual, expected);
|
|
assert.hasAnyKeys(actual, 'client_secret');
|
|
});
|
|
|
|
it('surfaces stripe errors', async () => {
|
|
const apiError = new stripeError.StripeAPIError();
|
|
sandbox
|
|
.stub(stripeHelper.stripe.setupIntents, 'create')
|
|
.rejects(apiError);
|
|
|
|
return stripeHelper.createSetupIntent('cust_new').then(
|
|
() => Promise.reject(new Error('Method expected to reject')),
|
|
(err) => {
|
|
assert.equal(err, apiError);
|
|
}
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('updateDefaultPaymentMethod', () => {
|
|
it('updates the default payment method', async () => {
|
|
const expected = deepCopy(newCustomerPM);
|
|
sandbox.stub(stripeHelper.stripe.customers, 'update').resolves(expected);
|
|
stripeFirestore.insertCustomerRecordWithBackfill = sandbox
|
|
.stub()
|
|
.resolves({});
|
|
const actual = await stripeHelper.updateDefaultPaymentMethod(
|
|
'cust_new',
|
|
'pm_1H0FRp2eZvKYlo2CeIZoc0wj'
|
|
);
|
|
assert.deepEqual(actual, expected);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeFirestore.insertCustomerRecordWithBackfill,
|
|
expected.metadata.userid,
|
|
expected
|
|
);
|
|
});
|
|
|
|
it('surfaces stripe errors', async () => {
|
|
const apiError = new stripeError.StripeAPIError();
|
|
sandbox.stub(stripeHelper.stripe.customers, 'update').rejects(apiError);
|
|
|
|
return stripeHelper
|
|
.updateDefaultPaymentMethod('cust_new', 'pm_1H0FRp2eZvKYlo2CeIZoc0wj')
|
|
.then(
|
|
() => Promise.reject(new Error('Method expected to reject')),
|
|
(err) => {
|
|
assert.equal(err, apiError);
|
|
}
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getPaymentMethod', () => {
|
|
it('calls the Stripe api', async () => {
|
|
const paymentMethodId = 'pm_9001';
|
|
sandbox.stub(stripeHelper, 'expandResource');
|
|
await stripeHelper.getPaymentMethod(paymentMethodId);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.expandResource,
|
|
paymentMethodId,
|
|
PAYMENT_METHOD_RESOURCE
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getPaymentProvider', () => {
|
|
let customerExpanded;
|
|
beforeEach(() => {
|
|
customerExpanded = deepCopy(customer1);
|
|
});
|
|
|
|
describe('returns correct value based on collection_method', () => {
|
|
describe('when collection_method is "send_invoice"', () => {
|
|
it('payment_provider is "paypal"', async () => {
|
|
subscription2.collection_method = 'send_invoice';
|
|
customerExpanded.subscriptions.data[0] = subscription2;
|
|
assert.strictEqual(
|
|
await stripeHelper.getPaymentProvider(customerExpanded),
|
|
'paypal'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('when the customer has a canceled subscription', () => {
|
|
it('payment_provider is "not_chosen"', async () => {
|
|
customerExpanded.subscriptions.data[0] = cancelledSubscription;
|
|
assert.strictEqual(
|
|
await stripeHelper.getPaymentProvider(customerExpanded),
|
|
'not_chosen'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('when the customer has no subscriptions', () => {
|
|
it('payment_provider is "not_chosen"', async () => {
|
|
customerExpanded.subscriptions.data = [];
|
|
assert.strictEqual(
|
|
await stripeHelper.getPaymentProvider(customerExpanded),
|
|
'not_chosen'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('when collection_method is "instant"', () => {
|
|
it('payment_provider is "stripe"', async () => {
|
|
subscription2.collection_method = 'instant';
|
|
customerExpanded.subscriptions.data[0] = subscription2;
|
|
|
|
stripeHelper.stripe = {
|
|
invoices: {
|
|
retrieve: sinon.stub().resolves({ payment_intent: 'pi_mock' }),
|
|
},
|
|
paymentIntents: {
|
|
retrieve: sinon.stub().resolves({ payment_method: null }),
|
|
},
|
|
};
|
|
|
|
sandbox.stub(stripeHelper, 'getPaymentMethod').resolves(null);
|
|
|
|
const result =
|
|
await stripeHelper.getPaymentProvider(customerExpanded);
|
|
assert.strictEqual(result, 'stripe');
|
|
});
|
|
|
|
it('payment_provider is "card"', async () => {
|
|
subscription2.collection_method = 'instant';
|
|
customerExpanded.subscriptions.data[0] = subscription2;
|
|
|
|
stripeHelper.stripe = {
|
|
paymentIntents: {
|
|
retrieve: sinon.stub().resolves({ payment_method: 'pm_mock' }),
|
|
},
|
|
invoices: {
|
|
retrieve: sinon.stub().resolves({ payment_intent: 'pi_mock' }),
|
|
},
|
|
};
|
|
sandbox
|
|
.stub(stripeHelper, 'getPaymentMethod')
|
|
.resolves({ type: 'card', card: {} });
|
|
|
|
assert.strictEqual(
|
|
await stripeHelper.getPaymentProvider(customerExpanded),
|
|
'card'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('when payment method is "link"', () => {
|
|
it('returns "link" as the payment_provider', async () => {
|
|
customerExpanded.subscriptions.data[0] = subscription2;
|
|
|
|
stripeHelper.stripe = {
|
|
invoices: {
|
|
retrieve: sinon.stub().resolves({ payment_intent: 'pi_mock' }),
|
|
},
|
|
paymentIntents: {
|
|
retrieve: sinon.stub().resolves({ payment_method: 'pm_mock' }),
|
|
},
|
|
};
|
|
|
|
sandbox.stub(stripeHelper, 'getPaymentMethod').resolves({
|
|
type: 'link',
|
|
});
|
|
|
|
const result =
|
|
await stripeHelper.getPaymentProvider(customerExpanded);
|
|
assert.strictEqual(result, 'link');
|
|
});
|
|
});
|
|
|
|
describe('when payment method is Apple Pay', () => {
|
|
it('returns "apple_pay" as the payment_provider', async () => {
|
|
customerExpanded.subscriptions.data[0] = subscription2;
|
|
|
|
stripeHelper.stripe = {
|
|
invoices: {
|
|
retrieve: sinon.stub().resolves({ payment_intent: 'pi_mock' }),
|
|
},
|
|
paymentIntents: {
|
|
retrieve: sinon.stub().resolves({ payment_method: 'pm_mock' }),
|
|
},
|
|
};
|
|
|
|
sandbox.stub(stripeHelper, 'getPaymentMethod').resolves({
|
|
type: 'card',
|
|
card: {
|
|
wallet: {
|
|
type: 'apple_pay',
|
|
},
|
|
},
|
|
});
|
|
|
|
const result =
|
|
await stripeHelper.getPaymentProvider(customerExpanded);
|
|
assert.strictEqual(result, 'apple_pay');
|
|
});
|
|
});
|
|
|
|
describe('when payment method is Google Pay', () => {
|
|
it('returns "google_pay" as the payment_provider', async () => {
|
|
customerExpanded.subscriptions.data[0] = subscription2;
|
|
|
|
stripeHelper.stripe = {
|
|
invoices: {
|
|
retrieve: sinon.stub().resolves({ payment_intent: 'pi_mock' }),
|
|
},
|
|
paymentIntents: {
|
|
retrieve: sinon.stub().resolves({ payment_method: 'pm_mock' }),
|
|
},
|
|
};
|
|
|
|
sandbox.stub(stripeHelper, 'getPaymentMethod').resolves({
|
|
type: 'card',
|
|
card: {
|
|
wallet: {
|
|
type: 'google_pay',
|
|
},
|
|
},
|
|
});
|
|
|
|
const result =
|
|
await stripeHelper.getPaymentProvider(customerExpanded);
|
|
assert.strictEqual(result, 'google_pay');
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('hasSubscriptionRequiringPaymentMethod', () => {
|
|
let customerExpanded;
|
|
beforeEach(() => {
|
|
customerExpanded = deepCopy(customer1);
|
|
});
|
|
|
|
it('returns true for a non-cancelled active subscription', () => {
|
|
const subscription3 = deepCopy(subscription2);
|
|
subscription3.status = 'active';
|
|
subscription3.cancel_at_period_end = false;
|
|
customerExpanded.subscriptions.data[0] = subscription3;
|
|
assert.isTrue(
|
|
stripeHelper.hasSubscriptionRequiringPaymentMethod(customerExpanded)
|
|
);
|
|
});
|
|
|
|
it('returns false for a cancelled active subscription', () => {
|
|
const subscription3 = deepCopy(subscription2);
|
|
subscription3.status = 'active';
|
|
subscription3.cancel_at_period_end = true;
|
|
customerExpanded.subscriptions.data[0] = subscription3;
|
|
assert.isFalse(
|
|
stripeHelper.hasSubscriptionRequiringPaymentMethod(customerExpanded)
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('hasActiveSubscription', () => {
|
|
let customerExpanded, subscription;
|
|
beforeEach(() => {
|
|
customerExpanded = deepCopy(customer1);
|
|
subscription = deepCopy(subscription2);
|
|
});
|
|
|
|
it('returns true for an active subscription', async () => {
|
|
subscription.status = 'active';
|
|
customerExpanded.subscriptions.data[0] = subscription;
|
|
sandbox.stub(stripeHelper, 'expandResource').resolves(customerExpanded);
|
|
assert.isTrue(
|
|
await stripeHelper.hasActiveSubscription(
|
|
customerExpanded.metadata.userid
|
|
)
|
|
);
|
|
});
|
|
|
|
it('returns false when there is no Stripe customer', async () => {
|
|
const uid = uuidv4().replace(/-/g, '');
|
|
customerExpanded = undefined;
|
|
sandbox.stub(stripeHelper, 'expandResource').resolves(customerExpanded);
|
|
assert.isFalse(await stripeHelper.hasActiveSubscription(uid));
|
|
});
|
|
|
|
it('returns false when there is no active subscription', async () => {
|
|
subscription.status = 'canceled';
|
|
customerExpanded.subscriptions.data[0] = subscription;
|
|
sandbox.stub(stripeHelper, 'expandResource').resolves(customerExpanded);
|
|
assert.isFalse(
|
|
await stripeHelper.hasActiveSubscription(
|
|
customerExpanded.metadata.userid
|
|
)
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getLatestInvoicesForActiveSubscriptions', () => {
|
|
let customerExpanded;
|
|
let invoice;
|
|
let subscription;
|
|
|
|
beforeEach(() => {
|
|
customerExpanded = deepCopy(customer1);
|
|
invoice = deepCopy(paidInvoice);
|
|
subscription = deepCopy(subscription2);
|
|
customerExpanded.subscriptions.data[0] = subscription;
|
|
sandbox.stub(stripeHelper, 'expandResource').resolves(invoice);
|
|
});
|
|
|
|
it('returns latest invoices for any active subscriptions', async () => {
|
|
const expected = [invoice];
|
|
const actual =
|
|
await stripeHelper.getLatestInvoicesForActiveSubscriptions(
|
|
customerExpanded
|
|
);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('returns [] if there are no active subscriptions', async () => {
|
|
subscription.status = 'incomplete';
|
|
const expected = [];
|
|
const actual =
|
|
await stripeHelper.getLatestInvoicesForActiveSubscriptions(
|
|
customerExpanded
|
|
);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('returns [] if no invoices are found', async () => {
|
|
subscription.latest_invoice = null;
|
|
const expected = [];
|
|
const actual =
|
|
await stripeHelper.getLatestInvoicesForActiveSubscriptions(
|
|
customerExpanded
|
|
);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
});
|
|
|
|
describe('hasOpenInvoiceWithPaymentAttempts', async () => {
|
|
let customerExpanded;
|
|
let invoice;
|
|
|
|
beforeEach(() => {
|
|
invoice = deepCopy(paidInvoice);
|
|
customerExpanded = deepCopy(customer1);
|
|
});
|
|
|
|
it('returns true if any open invoices are found with payment attempts', async () => {
|
|
const openInvoice = deepCopy(invoice);
|
|
openInvoice.status = 'open';
|
|
openInvoice.metadata.paymentAttempts = 1;
|
|
sandbox
|
|
.stub(stripeHelper, 'getLatestInvoicesForActiveSubscriptions')
|
|
.resolves([invoice, openInvoice]);
|
|
assert.isTrue(
|
|
await stripeHelper.hasOpenInvoiceWithPaymentAttempts(customerExpanded)
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.getLatestInvoicesForActiveSubscriptions,
|
|
customerExpanded
|
|
);
|
|
});
|
|
|
|
it('returns false for open invoices with no payment attempts', async () => {
|
|
const openInvoice = deepCopy(invoice);
|
|
openInvoice.status = 'open';
|
|
openInvoice.metadata.paymentAttempts = 0;
|
|
sandbox
|
|
.stub(stripeHelper, 'getLatestInvoicesForActiveSubscriptions')
|
|
.resolves([invoice]);
|
|
assert.isFalse(
|
|
await stripeHelper.hasOpenInvoiceWithPaymentAttempts(customerExpanded)
|
|
);
|
|
});
|
|
|
|
it('returns false for open invoices with no payment attempts and paid invoices with payment attempts', async () => {
|
|
const openInvoice = deepCopy(invoice);
|
|
openInvoice.status = 'open';
|
|
openInvoice.metadata.paymentAttempts = 0;
|
|
invoice.metadata.paymentAttempts = 1;
|
|
sandbox
|
|
.stub(stripeHelper, 'getLatestInvoicesForActiveSubscriptions')
|
|
.resolves([invoice, openInvoice]);
|
|
assert.isFalse(
|
|
await stripeHelper.hasOpenInvoiceWithPaymentAttempts(customerExpanded)
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('detachPaymentMethod', () => {
|
|
it('calls the Stripe api', async () => {
|
|
const paymentMethodId = 'pm_9001';
|
|
const expected = { id: paymentMethodId };
|
|
sandbox
|
|
.stub(stripeHelper.stripe.paymentMethods, 'detach')
|
|
.resolves(expected);
|
|
stripeFirestore.removePaymentMethodRecord = sandbox.stub().resolves({});
|
|
const actual = await stripeHelper.detachPaymentMethod(paymentMethodId);
|
|
assert.deepEqual(actual, expected);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.paymentMethods.detach,
|
|
paymentMethodId
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('removeSources', () => {
|
|
it('removes all the sources', async () => {
|
|
const ids = {
|
|
data: [{ id: uuidv4() }, { id: uuidv4() }, { id: uuidv4() }],
|
|
};
|
|
sandbox.stub(stripeHelper.stripe.customers, 'listSources').resolves(ids);
|
|
sandbox.stub(stripeHelper.stripe.customers, 'deleteSource').resolves({});
|
|
const result = await stripeHelper.removeSources('cust_new');
|
|
assert.deepEqual(result, [{}, {}, {}]);
|
|
sinon.assert.calledThrice(stripeHelper.stripe.customers.deleteSource);
|
|
for (const obj of ids.data) {
|
|
sinon.assert.calledWith(
|
|
stripeHelper.stripe.customers.deleteSource,
|
|
'cust_new',
|
|
obj.id
|
|
);
|
|
}
|
|
});
|
|
|
|
it('returns if no sources', async () => {
|
|
sandbox
|
|
.stub(stripeHelper.stripe.customers, 'listSources')
|
|
.resolves({ data: [] });
|
|
sandbox.stub(stripeHelper.stripe.customers, 'deleteSource').resolves({});
|
|
const result = await stripeHelper.removeSources('cust_new');
|
|
assert.deepEqual(result, []);
|
|
sinon.assert.notCalled(stripeHelper.stripe.customers.deleteSource);
|
|
});
|
|
|
|
it('surfaces stripe errors', async () => {
|
|
const apiError = new stripeError.StripeAPIError();
|
|
sandbox
|
|
.stub(stripeHelper.stripe.customers, 'listSources')
|
|
.rejects(apiError);
|
|
return stripeHelper.removeSources('cust_new').then(
|
|
() => Promise.reject(new Error('Method expected to reject')),
|
|
(err) => {
|
|
assert.equal(err, apiError);
|
|
}
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('retryInvoiceWithPaymentId', () => {
|
|
it('retries with an invoice successfully', async () => {
|
|
const attachExpected = deepCopy(paymentMethodAttach);
|
|
const customerExpected = deepCopy(newCustomerPM);
|
|
const invoiceRetryExpected = deepCopy(invoiceRetry);
|
|
sandbox
|
|
.stub(stripeHelper.stripe.paymentMethods, 'attach')
|
|
.resolves(attachExpected);
|
|
sandbox
|
|
.stub(stripeHelper.stripe.customers, 'update')
|
|
.resolves(customerExpected);
|
|
sandbox
|
|
.stub(stripeHelper.stripe.invoices, 'pay')
|
|
.resolves(invoiceRetryExpected);
|
|
sandbox
|
|
.stub(stripeHelper.stripe.invoices, 'retrieve')
|
|
.resolves(invoiceRetryExpected);
|
|
stripeFirestore.insertCustomerRecordWithBackfill = sandbox
|
|
.stub()
|
|
.resolves({});
|
|
stripeFirestore.insertPaymentMethodRecord = sandbox.stub().resolves({});
|
|
stripeFirestore.insertInvoiceRecord = sandbox.stub().resolves({});
|
|
const actual = await stripeHelper.retryInvoiceWithPaymentId(
|
|
'customerId',
|
|
'invoiceId',
|
|
'pm_1H0FRp2eZvKYlo2CeIZoc0wj',
|
|
uuidv4()
|
|
);
|
|
|
|
assert.deepEqual(actual, invoiceRetryExpected);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeFirestore.insertCustomerRecordWithBackfill,
|
|
customerExpected.metadata.userid,
|
|
customerExpected
|
|
);
|
|
});
|
|
|
|
it('surfaces payment issues', async () => {
|
|
const apiError = new stripeError.StripeCardError();
|
|
sandbox
|
|
.stub(stripeHelper.stripe.paymentMethods, 'attach')
|
|
.rejects(apiError);
|
|
|
|
return stripeHelper
|
|
.retryInvoiceWithPaymentId(
|
|
'customerId',
|
|
'invoiceId',
|
|
'pm_1H0FRp2eZvKYlo2CeIZoc0wj',
|
|
uuidv4()
|
|
)
|
|
.then(
|
|
() => Promise.reject(new Error('Method expected to reject')),
|
|
(err) => {
|
|
assert.equal(
|
|
err.errno,
|
|
error.ERRNO.REJECTED_SUBSCRIPTION_PAYMENT_TOKEN
|
|
);
|
|
}
|
|
);
|
|
});
|
|
|
|
it('surfaces stripe errors', async () => {
|
|
const apiError = new stripeError.StripeAPIError();
|
|
sandbox
|
|
.stub(stripeHelper.stripe.paymentMethods, 'attach')
|
|
.rejects(apiError);
|
|
|
|
return stripeHelper
|
|
.retryInvoiceWithPaymentId(
|
|
'customerId',
|
|
'invoiceId',
|
|
'pm_1H0FRp2eZvKYlo2CeIZoc0wj',
|
|
uuidv4()
|
|
)
|
|
.then(
|
|
() => Promise.reject(new Error('Method expected to reject')),
|
|
(err) => {
|
|
assert.equal(err, apiError);
|
|
}
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('createSubscriptionWithPMI', () => {
|
|
it('checks that roundTime() returns time rounded to the nearest minute', async () => {
|
|
const mockDate = new Date('2023-01-03T17:44:44.400Z');
|
|
const res = roundTime(mockDate);
|
|
const actualTime = '27879464.74';
|
|
const roundedTime = '27879465';
|
|
|
|
assert.deepEqual(res, roundedTime);
|
|
assert.notEqual(res, actualTime);
|
|
});
|
|
|
|
it('creates a subscription successfully', async () => {
|
|
const attachExpected = deepCopy(paymentMethodAttach);
|
|
const customerExpected = deepCopy(newCustomerPM);
|
|
sandbox
|
|
.stub(stripeHelper.stripe.paymentMethods, 'attach')
|
|
.resolves(attachExpected);
|
|
sandbox
|
|
.stub(stripeHelper.stripe.customers, 'update')
|
|
.resolves(customerExpected);
|
|
sandbox
|
|
.stub(stripeHelper.stripe.subscriptions, 'create')
|
|
.resolves(subscriptionPMIExpanded);
|
|
stripeFirestore.insertCustomerRecordWithBackfill = sandbox
|
|
.stub()
|
|
.resolves({});
|
|
stripeFirestore.insertSubscriptionRecordWithBackfill = sandbox
|
|
.stub()
|
|
.resolves({});
|
|
stripeFirestore.insertPaymentMethodRecord = sandbox.stub().resolves({});
|
|
const expectedIdempotencyKey = generateIdempotencyKey([
|
|
'customerId',
|
|
'priceId',
|
|
attachExpected.card.fingerprint,
|
|
roundTime(),
|
|
]);
|
|
|
|
const actual = await stripeHelper.createSubscriptionWithPMI({
|
|
customerId: 'customerId',
|
|
priceId: 'priceId',
|
|
paymentMethodId: 'pm_1H0FRp2eZvKYlo2CeIZoc0wj',
|
|
automaticTax: true,
|
|
});
|
|
|
|
assert.deepEqual(actual, subscriptionPMIExpanded);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.subscriptions.create,
|
|
{
|
|
customer: 'customerId',
|
|
items: [{ price: 'priceId' }],
|
|
expand: ['latest_invoice.payment_intent.latest_charge'],
|
|
promotion_code: undefined,
|
|
automatic_tax: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
{ idempotencyKey: `ssc-${expectedIdempotencyKey}` }
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeFirestore.insertSubscriptionRecordWithBackfill,
|
|
{
|
|
...subscriptionPMIExpanded,
|
|
latest_invoice: subscriptionPMIExpanded.latest_invoice
|
|
? subscriptionPMIExpanded.latest_invoice.id
|
|
: null,
|
|
}
|
|
);
|
|
sinon.assert.callCount(mockStatsd.increment, 1);
|
|
});
|
|
|
|
it('uses the given promotion code', async () => {
|
|
const promotionCode = { id: 'redpanda', code: 'firefox' };
|
|
const attachExpected = deepCopy(paymentMethodAttach);
|
|
const customerExpected = deepCopy(newCustomerPM);
|
|
const newSubscription = deepCopy(subscriptionPMIExpanded);
|
|
newSubscription.latest_invoice.discount = {};
|
|
sandbox
|
|
.stub(stripeHelper.stripe.paymentMethods, 'attach')
|
|
.resolves(attachExpected);
|
|
sandbox
|
|
.stub(stripeHelper.stripe.customers, 'update')
|
|
.resolves(customerExpected);
|
|
sandbox
|
|
.stub(stripeHelper.stripe.subscriptions, 'create')
|
|
.resolves(newSubscription);
|
|
sandbox.stub(stripeHelper.stripe.subscriptions, 'update').resolves({});
|
|
stripeFirestore.insertCustomerRecordWithBackfill = sandbox
|
|
.stub()
|
|
.resolves({});
|
|
stripeFirestore.insertSubscriptionRecordWithBackfill = sandbox
|
|
.stub()
|
|
.resolves({});
|
|
stripeFirestore.insertPaymentMethodRecord = sandbox.stub().resolves({});
|
|
const expectedIdempotencyKey = generateIdempotencyKey([
|
|
'customerId',
|
|
'priceId',
|
|
attachExpected.card.fingerprint,
|
|
roundTime(),
|
|
]);
|
|
|
|
const actual = await stripeHelper.createSubscriptionWithPMI({
|
|
customerId: 'customerId',
|
|
priceId: 'priceId',
|
|
paymentMethodId: 'pm_1H0FRp2eZvKYlo2CeIZoc0wj',
|
|
promotionCode,
|
|
automaticTax: false,
|
|
});
|
|
|
|
const subWithPromotionCodeMetadata = {
|
|
...newSubscription,
|
|
metadata: {
|
|
...newSubscription.metadata,
|
|
appliedPromotionCode: promotionCode.code,
|
|
},
|
|
};
|
|
assert.deepEqual(actual, subWithPromotionCodeMetadata);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.subscriptions.create,
|
|
{
|
|
customer: 'customerId',
|
|
items: [{ price: 'priceId' }],
|
|
expand: ['latest_invoice.payment_intent.latest_charge'],
|
|
promotion_code: promotionCode.id,
|
|
automatic_tax: {
|
|
enabled: false,
|
|
},
|
|
},
|
|
{ idempotencyKey: `ssc-${expectedIdempotencyKey}` }
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.subscriptions.update,
|
|
newSubscription.id,
|
|
{
|
|
metadata: {
|
|
...newSubscription.metadata,
|
|
appliedPromotionCode: promotionCode.code,
|
|
},
|
|
}
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeFirestore.insertSubscriptionRecordWithBackfill,
|
|
{
|
|
...subWithPromotionCodeMetadata,
|
|
latest_invoice: subscriptionPMIExpanded.latest_invoice
|
|
? subscriptionPMIExpanded.latest_invoice.id
|
|
: null,
|
|
}
|
|
);
|
|
});
|
|
|
|
it('errors and deletes subscription when a cvc check fails on subscription creation', async () => {
|
|
const attachExpected = deepCopy(paymentMethodAttach);
|
|
const customerExpected = deepCopy(newCustomerPM);
|
|
sandbox
|
|
.stub(stripeHelper.stripe.paymentMethods, 'attach')
|
|
.resolves(attachExpected);
|
|
sandbox
|
|
.stub(stripeHelper.stripe.customers, 'update')
|
|
.resolves(customerExpected);
|
|
sandbox
|
|
.stub(stripeHelper.stripe.subscriptions, 'create')
|
|
.resolves(subscriptionPMIExpandedIncompleteCVCFail);
|
|
sandbox.stub(stripeHelper, 'cancelSubscription').resolves({});
|
|
stripeFirestore.insertCustomerRecordWithBackfill = sandbox
|
|
.stub()
|
|
.resolves({});
|
|
stripeFirestore.insertSubscriptionRecordWithBackfill = sandbox
|
|
.stub()
|
|
.resolves({});
|
|
stripeFirestore.insertPaymentMethodRecord = sandbox.stub().resolves({});
|
|
const expectedIdempotencyKey = generateIdempotencyKey([
|
|
'customerId',
|
|
'priceId',
|
|
attachExpected.card.fingerprint,
|
|
roundTime(),
|
|
]);
|
|
|
|
try {
|
|
await stripeHelper.createSubscriptionWithPMI({
|
|
customerId: 'customerId',
|
|
priceId: 'priceId',
|
|
paymentMethodId: 'pm_1H0FRp2eZvKYlo2CeIZoc0wj',
|
|
automaticTax: true,
|
|
});
|
|
sinon.assert.fail();
|
|
} catch (err) {
|
|
assert.equal(
|
|
err.errno,
|
|
error.ERRNO.REJECTED_SUBSCRIPTION_PAYMENT_TOKEN
|
|
);
|
|
}
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.subscriptions.create,
|
|
{
|
|
customer: 'customerId',
|
|
items: [{ price: 'priceId' }],
|
|
expand: ['latest_invoice.payment_intent.latest_charge'],
|
|
promotion_code: undefined,
|
|
automatic_tax: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
{ idempotencyKey: `ssc-${expectedIdempotencyKey}` }
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.cancelSubscription,
|
|
subscriptionPMIExpandedIncompleteCVCFail.id
|
|
);
|
|
sinon.assert.notCalled(
|
|
stripeFirestore.insertSubscriptionRecordWithBackfill
|
|
);
|
|
sinon.assert.callCount(mockStatsd.increment, 1);
|
|
});
|
|
|
|
it('surfaces payment issues', async () => {
|
|
const apiError = new stripeError.StripeCardError();
|
|
sandbox
|
|
.stub(stripeHelper.stripe.paymentMethods, 'attach')
|
|
.rejects(apiError);
|
|
|
|
return stripeHelper
|
|
.createSubscriptionWithPMI({
|
|
customerId: 'customerId',
|
|
priceId: 'priceId',
|
|
paymentMethodId: 'pm_1H0FRp2eZvKYlo2CeIZoc0wj',
|
|
})
|
|
.then(
|
|
() => Promise.reject(new Error('Method expected to reject')),
|
|
(err) => {
|
|
assert.equal(
|
|
err.errno,
|
|
error.ERRNO.REJECTED_SUBSCRIPTION_PAYMENT_TOKEN
|
|
);
|
|
}
|
|
);
|
|
});
|
|
|
|
it('surfaces stripe errors', async () => {
|
|
const apiError = new stripeError.StripeAPIError();
|
|
sandbox
|
|
.stub(stripeHelper.stripe.paymentMethods, 'attach')
|
|
.rejects(apiError);
|
|
|
|
return stripeHelper
|
|
.createSubscriptionWithPMI({
|
|
customerId: 'customerId',
|
|
priceId: 'invoiceId',
|
|
paymentMethodId: 'pm_1H0FRp2eZvKYlo2CeIZoc0wj',
|
|
})
|
|
.then(
|
|
() => Promise.reject(new Error('Method expected to reject')),
|
|
(err) => {
|
|
assert.equal(err, apiError);
|
|
}
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('createSubscriptionWithPaypal', () => {
|
|
it('creates a subscription successfully', async () => {
|
|
sandbox
|
|
.stub(stripeHelper, 'findCustomerSubscriptionByPlanId')
|
|
.returns(undefined);
|
|
sandbox
|
|
.stub(stripeHelper.stripe.subscriptions, 'create')
|
|
.resolves(subscriptionPMIExpanded);
|
|
const subIdempotencyKey = uuidv4();
|
|
stripeFirestore.insertSubscriptionRecordWithBackfill = sandbox
|
|
.stub()
|
|
.resolves({});
|
|
const actual = await stripeHelper.createSubscriptionWithPaypal({
|
|
customer: customer1,
|
|
priceId: 'priceId',
|
|
subIdempotencyKey,
|
|
automaticTax: true,
|
|
});
|
|
|
|
assert.deepEqual(actual, subscriptionPMIExpanded);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeFirestore.insertSubscriptionRecordWithBackfill,
|
|
{
|
|
...subscriptionPMIExpanded,
|
|
latest_invoice: subscriptionPMIExpanded.latest_invoice
|
|
? subscriptionPMIExpanded.latest_invoice.id
|
|
: null,
|
|
}
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.subscriptions.create,
|
|
{
|
|
customer: customer1.id,
|
|
items: [{ price: 'priceId' }],
|
|
expand: ['latest_invoice'],
|
|
collection_method: 'send_invoice',
|
|
days_until_due: 1,
|
|
promotion_code: undefined,
|
|
automatic_tax: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
{ idempotencyKey: `ssc-${subIdempotencyKey}` }
|
|
);
|
|
sinon.assert.callCount(mockStatsd.increment, 1);
|
|
});
|
|
|
|
it('uses the given promotion code to create a subscription', async () => {
|
|
const promotionCode = { id: 'redpanda', code: 'firefox' };
|
|
const newSubscription = deepCopy(subscriptionPMIExpanded);
|
|
newSubscription.latest_invoice.discount = {};
|
|
sandbox
|
|
.stub(stripeHelper, 'findCustomerSubscriptionByPlanId')
|
|
.returns(undefined);
|
|
sandbox
|
|
.stub(stripeHelper.stripe.subscriptions, 'create')
|
|
.resolves(newSubscription);
|
|
sandbox.stub(stripeHelper.stripe.subscriptions, 'update').resolves({});
|
|
const subIdempotencyKey = uuidv4();
|
|
stripeFirestore.insertSubscriptionRecordWithBackfill = sandbox
|
|
.stub()
|
|
.resolves({});
|
|
const actual = await stripeHelper.createSubscriptionWithPaypal({
|
|
customer: customer1,
|
|
priceId: 'priceId',
|
|
subIdempotencyKey,
|
|
promotionCode,
|
|
automaticTax: false,
|
|
});
|
|
|
|
const subWithPromotionCodeMetadata = {
|
|
...newSubscription,
|
|
metadata: {
|
|
...newSubscription.metadata,
|
|
appliedPromotionCode: promotionCode.code,
|
|
},
|
|
};
|
|
assert.deepEqual(actual, subWithPromotionCodeMetadata);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeFirestore.insertSubscriptionRecordWithBackfill,
|
|
{
|
|
...subWithPromotionCodeMetadata,
|
|
latest_invoice: subscriptionPMIExpanded.latest_invoice
|
|
? subscriptionPMIExpanded.latest_invoice.id
|
|
: null,
|
|
}
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.subscriptions.create,
|
|
{
|
|
customer: customer1.id,
|
|
items: [{ price: 'priceId' }],
|
|
expand: ['latest_invoice'],
|
|
collection_method: 'send_invoice',
|
|
days_until_due: 1,
|
|
promotion_code: promotionCode.id,
|
|
automatic_tax: {
|
|
enabled: false,
|
|
},
|
|
},
|
|
{ idempotencyKey: `ssc-${subIdempotencyKey}` }
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.subscriptions.update,
|
|
newSubscription.id,
|
|
{
|
|
metadata: {
|
|
...newSubscription.metadata,
|
|
appliedPromotionCode: promotionCode.code,
|
|
},
|
|
}
|
|
);
|
|
sinon.assert.callCount(mockStatsd.increment, 1);
|
|
});
|
|
|
|
it('returns a usable sub if one is active/past_due', async () => {
|
|
const collectionSubscription = deepCopy(subscription1);
|
|
collectionSubscription.collection_method = 'send_invoice';
|
|
sandbox
|
|
.stub(stripeHelper, 'findCustomerSubscriptionByPlanId')
|
|
.returns(collectionSubscription);
|
|
sandbox.stub(stripeHelper, 'expandResource').returns({});
|
|
const actual = await stripeHelper.createSubscriptionWithPaypal({
|
|
customer: customer1,
|
|
priceId: 'priceId',
|
|
subIdempotencyKey: uuidv4(),
|
|
});
|
|
|
|
assert.deepEqual(actual, collectionSubscription);
|
|
});
|
|
|
|
it('throws an error for an existing charge subscription', async () => {
|
|
sandbox
|
|
.stub(stripeHelper, 'findCustomerSubscriptionByPlanId')
|
|
.returns(subscription1);
|
|
sandbox.stub(stripeHelper, 'expandResource').returns({});
|
|
try {
|
|
await stripeHelper.createSubscriptionWithPaypal({
|
|
customer: customer1,
|
|
priceId: 'priceId',
|
|
subIdempotencyKey: uuidv4(),
|
|
});
|
|
assert.fail('Error should throw with active charge subscription');
|
|
} catch (err) {
|
|
assert.deepEqual(err, error.subscriptionAlreadyExists());
|
|
}
|
|
});
|
|
|
|
it('deletes an incomplete subscription when creating', async () => {
|
|
const collectionSubscription = deepCopy(subscription1);
|
|
collectionSubscription.status = 'incomplete';
|
|
sandbox
|
|
.stub(stripeHelper, 'findCustomerSubscriptionByPlanId')
|
|
.returns(collectionSubscription);
|
|
sandbox.stub(stripeHelper.stripe.subscriptions, 'cancel').resolves({});
|
|
sandbox
|
|
.stub(stripeHelper.stripe.subscriptions, 'create')
|
|
.resolves(subscription1);
|
|
stripeFirestore.insertSubscriptionRecordWithBackfill = sandbox
|
|
.stub()
|
|
.resolves({});
|
|
const actual = await stripeHelper.createSubscriptionWithPaypal({
|
|
customer: customer1,
|
|
priceId: 'priceId',
|
|
subIdempotencyKey: uuidv4(),
|
|
});
|
|
|
|
assert.deepEqual(actual, subscription1);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.subscriptions.cancel,
|
|
collectionSubscription.id
|
|
);
|
|
sinon.assert.calledWithExactly(
|
|
stripeFirestore.insertSubscriptionRecordWithBackfill,
|
|
{
|
|
...subscription1,
|
|
latest_invoice: subscription1.latest_invoice
|
|
? subscription1.latest_invoice.id
|
|
: null,
|
|
}
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getCoupon', () => {
|
|
it('returns a coupon', async () => {
|
|
const coupon = { id: 'couponId' };
|
|
sandbox.stub(stripeHelper.stripe.coupons, 'retrieve').resolves(coupon);
|
|
const actual = await stripeHelper.getCoupon('couponId');
|
|
assert.deepEqual(actual, coupon);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.coupons.retrieve,
|
|
coupon.id,
|
|
{ expand: ['applies_to'] }
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getInvoiceWithDiscount', () => {
|
|
it('returns an invoice with discounts expanded', async () => {
|
|
const invoice = { id: 'invoiceId' };
|
|
sandbox.stub(stripeHelper.stripe.invoices, 'retrieve').resolves(invoice);
|
|
const actual = await stripeHelper.getInvoiceWithDiscount('invoiceId');
|
|
assert.deepEqual(actual, invoice);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.invoices.retrieve,
|
|
invoice.id,
|
|
{ expand: ['discounts'] }
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('findValidPromoCode', () => {
|
|
it('finds a valid promotionCode with plan metadata', async () => {
|
|
const promotionCode = { code: 'promo1', coupon: { valid: true } };
|
|
sandbox
|
|
.stub(stripeHelper.stripe.promotionCodes, 'list')
|
|
.resolves({ data: [promotionCode] });
|
|
sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({
|
|
plan_metadata: {
|
|
[STRIPE_PRICE_METADATA.PROMOTION_CODES]: 'promo1',
|
|
},
|
|
});
|
|
const actual = await stripeHelper.findValidPromoCode('promo1', 'planId');
|
|
assert.deepEqual(actual, promotionCode);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.promotionCodes.list,
|
|
{
|
|
active: true,
|
|
code: 'promo1',
|
|
}
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.findAbbrevPlanById,
|
|
'planId'
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.promotionCodes.list,
|
|
{
|
|
active: true,
|
|
code: 'promo1',
|
|
}
|
|
);
|
|
});
|
|
|
|
it('does not find an expired promotionCode', async () => {
|
|
const expiredTime = Date.now() / 1000 - 50;
|
|
const promotionCode = {
|
|
code: 'promo1',
|
|
coupon: { valid: true },
|
|
expires_at: expiredTime,
|
|
};
|
|
sandbox
|
|
.stub(stripeHelper.stripe.promotionCodes, 'list')
|
|
.resolves({ data: [promotionCode] });
|
|
sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({
|
|
plan_metadata: {
|
|
[STRIPE_PRICE_METADATA.PROMOTION_CODES]: 'promo1',
|
|
},
|
|
});
|
|
const actual = await stripeHelper.findValidPromoCode('promo1', 'planId');
|
|
assert.isUndefined(actual);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.promotionCodes.list,
|
|
{
|
|
active: true,
|
|
code: 'promo1',
|
|
}
|
|
);
|
|
sinon.assert.notCalled(stripeHelper.findAbbrevPlanById);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.promotionCodes.list,
|
|
{
|
|
active: true,
|
|
code: 'promo1',
|
|
}
|
|
);
|
|
});
|
|
|
|
it('does not find a promotionCode with a different plan', async () => {
|
|
const promotionCode = { code: 'promo1', coupon: { valid: true } };
|
|
sandbox
|
|
.stub(stripeHelper.stripe.promotionCodes, 'list')
|
|
.resolves({ data: [promotionCode] });
|
|
sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({
|
|
plan_metadata: {},
|
|
});
|
|
const actual = await stripeHelper.findValidPromoCode('promo1', 'planId');
|
|
assert.isUndefined(actual);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.promotionCodes.list,
|
|
{
|
|
active: true,
|
|
code: 'promo1',
|
|
}
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.findAbbrevPlanById,
|
|
'planId'
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.promotionCodes.list,
|
|
{
|
|
active: true,
|
|
code: 'promo1',
|
|
}
|
|
);
|
|
});
|
|
|
|
it('does not find an invalid promotionCode', async () => {
|
|
const promotionCode = {
|
|
code: 'promo1',
|
|
coupon: { valid: false },
|
|
};
|
|
sandbox
|
|
.stub(stripeHelper.stripe.promotionCodes, 'list')
|
|
.resolves({ data: [promotionCode] });
|
|
sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({
|
|
plan_metadata: {
|
|
[STRIPE_PRICE_METADATA.PROMOTION_CODES]: 'promo1',
|
|
},
|
|
});
|
|
const actual = await stripeHelper.findValidPromoCode('promo1', 'planId');
|
|
assert.isUndefined(actual);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.promotionCodes.list,
|
|
{
|
|
active: true,
|
|
code: 'promo1',
|
|
}
|
|
);
|
|
sinon.assert.notCalled(stripeHelper.findAbbrevPlanById);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.promotionCodes.list,
|
|
{
|
|
active: true,
|
|
code: 'promo1',
|
|
}
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('validateCouponDurationForPlan', () => {
|
|
const priceId = 'priceId';
|
|
const promotionCode = 'promotionCode';
|
|
const couponTemplate = {
|
|
duration: 'repeating',
|
|
duration_in_months: 3,
|
|
};
|
|
const planTemplate = {
|
|
interval: 'month',
|
|
interval_count: 1,
|
|
};
|
|
const couponDuration = 'repeating';
|
|
const couponDurationInMonths = 3;
|
|
const priceInterval = 'month';
|
|
const priceIntervalCount = 1;
|
|
let sentryScope;
|
|
|
|
const setDefaultFindPlanById = () =>
|
|
sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves(planTemplate);
|
|
|
|
beforeEach(() => {
|
|
sentryScope = { setContext: sandbox.stub(), setExtra: sandbox.stub() };
|
|
sandbox.stub(Sentry, 'withScope').callsFake((cb) => cb(sentryScope));
|
|
sandbox.stub(Sentry, 'setExtra');
|
|
sandbox.stub(Sentry, 'captureException');
|
|
});
|
|
|
|
afterEach(() => {
|
|
sandbox.restore();
|
|
});
|
|
|
|
it('coupon duration other than repeating', async () => {
|
|
const expected = true;
|
|
const coupon = {
|
|
...couponTemplate,
|
|
duration: 'once',
|
|
};
|
|
setDefaultFindPlanById();
|
|
const actual = await stripeHelper.validateCouponDurationForPlan(
|
|
priceId,
|
|
promotionCode,
|
|
coupon
|
|
);
|
|
assert.equal(actual, expected);
|
|
});
|
|
|
|
it('valid yearly plan interval', async () => {
|
|
const expected = true;
|
|
const coupon = {
|
|
...couponTemplate,
|
|
duration_in_months: 12,
|
|
};
|
|
sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({
|
|
...planTemplate,
|
|
interval: 'year',
|
|
interval_count: 1,
|
|
});
|
|
|
|
const actual = await stripeHelper.validateCouponDurationForPlan(
|
|
priceId,
|
|
promotionCode,
|
|
coupon
|
|
);
|
|
|
|
assert.equal(actual, expected);
|
|
});
|
|
|
|
it('invalid yearly plan interval', async () => {
|
|
const expected = false;
|
|
const coupon = couponTemplate;
|
|
const priceIntervalOverride = 'year';
|
|
|
|
sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({
|
|
...planTemplate,
|
|
interval: priceIntervalOverride,
|
|
});
|
|
|
|
const actual = await stripeHelper.validateCouponDurationForPlan(
|
|
priceId,
|
|
promotionCode,
|
|
coupon
|
|
);
|
|
|
|
assert.equal(actual, expected);
|
|
sinon.assert.calledTwice(Sentry.withScope);
|
|
sinon.assert.calledOnceWithExactly(
|
|
sentryScope.setContext,
|
|
'validateCouponDurationForPlan',
|
|
{
|
|
promotionCode,
|
|
priceId,
|
|
couponDuration,
|
|
couponDurationInMonths,
|
|
priceInterval: priceIntervalOverride,
|
|
priceIntervalCount,
|
|
}
|
|
);
|
|
});
|
|
|
|
it('valid monthly plan interval', async () => {
|
|
const expected = true;
|
|
const coupon = couponTemplate;
|
|
setDefaultFindPlanById();
|
|
|
|
const actual = await stripeHelper.validateCouponDurationForPlan(
|
|
priceId,
|
|
promotionCode,
|
|
coupon
|
|
);
|
|
|
|
assert.equal(actual, expected);
|
|
});
|
|
|
|
it('invalid monthly plan interval', async () => {
|
|
const expected = false;
|
|
const coupon = couponTemplate;
|
|
const priceIntervalCountOverride = 6;
|
|
sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({
|
|
...planTemplate,
|
|
interval_count: priceIntervalCountOverride,
|
|
});
|
|
|
|
const actual = await stripeHelper.validateCouponDurationForPlan(
|
|
priceId,
|
|
promotionCode,
|
|
coupon
|
|
);
|
|
|
|
assert.equal(actual, expected);
|
|
sinon.assert.calledTwice(Sentry.withScope);
|
|
sinon.assert.calledOnceWithExactly(
|
|
sentryScope.setContext,
|
|
'validateCouponDurationForPlan',
|
|
{
|
|
promotionCode,
|
|
priceId,
|
|
couponDuration,
|
|
couponDurationInMonths,
|
|
priceInterval,
|
|
priceIntervalCount: priceIntervalCountOverride,
|
|
}
|
|
);
|
|
});
|
|
|
|
it('invalid plan interval', async () => {
|
|
const expected = false;
|
|
const coupon = couponTemplate;
|
|
sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({
|
|
...planTemplate,
|
|
interval: 'week',
|
|
});
|
|
|
|
const actual = await stripeHelper.validateCouponDurationForPlan(
|
|
priceId,
|
|
promotionCode,
|
|
coupon
|
|
);
|
|
|
|
assert.equal(actual, expected);
|
|
sinon.assert.notCalled(Sentry.withScope);
|
|
});
|
|
|
|
it('missing coupon duration in months', async () => {
|
|
const expected = false;
|
|
const coupon = {
|
|
...couponTemplate,
|
|
duration_in_months: null,
|
|
};
|
|
setDefaultFindPlanById();
|
|
|
|
const actual = await stripeHelper.validateCouponDurationForPlan(
|
|
priceId,
|
|
promotionCode,
|
|
coupon
|
|
);
|
|
|
|
assert.equal(actual, expected);
|
|
sinon.assert.notCalled(Sentry.withScope);
|
|
});
|
|
});
|
|
|
|
describe('findPromoCodeByCode', () => {
|
|
it('finds a promo code', async () => {
|
|
const promotionCode = { code: 'code1' };
|
|
sandbox
|
|
.stub(stripeHelper.stripe.promotionCodes, 'list')
|
|
.resolves({ data: [promotionCode] });
|
|
const actual = await stripeHelper.findPromoCodeByCode('code1');
|
|
assert.deepEqual(actual, promotionCode);
|
|
});
|
|
|
|
it('finds no promo code', async () => {
|
|
const promotionCode = { code: 'code2' };
|
|
sandbox
|
|
.stub(stripeHelper.stripe.promotionCodes, 'list')
|
|
.resolves({ data: [promotionCode] });
|
|
const actual = await stripeHelper.findPromoCodeByCode('code1');
|
|
assert.isUndefined(actual);
|
|
});
|
|
});
|
|
|
|
describe('retrieveCouponDetails', () => {
|
|
const validInvoicePreview = {
|
|
total: 1000,
|
|
currency: 'usd',
|
|
discount: {},
|
|
total_discount_amounts: [{ amount: 200 }],
|
|
};
|
|
|
|
const expectedTemplate = {
|
|
promotionCode: 'promo',
|
|
type: 'forever',
|
|
valid: true,
|
|
durationInMonths: null,
|
|
expired: false,
|
|
maximallyRedeemed: false,
|
|
};
|
|
|
|
let sentryScope;
|
|
|
|
beforeEach(() => {
|
|
sentryScope = { setContext: sandbox.stub(), setExtra: sandbox.stub() };
|
|
sandbox.stub(Sentry, 'withScope').callsFake((cb) => cb(sentryScope));
|
|
sandbox.stub(Sentry, 'setExtra');
|
|
sandbox.stub(Sentry, 'captureException');
|
|
});
|
|
|
|
it('retrieves coupon details', async () => {
|
|
const expected = { ...expectedTemplate, discountAmount: 200 };
|
|
sandbox
|
|
.stub(stripeHelper, 'previewInvoice')
|
|
.resolves([validInvoicePreview, undefined]);
|
|
|
|
sandbox.stub(stripeHelper, 'retrievePromotionCodeForPlan').resolves({
|
|
active: true,
|
|
coupon: {
|
|
id: 'promo',
|
|
duration: 'forever',
|
|
valid: true,
|
|
duration_in_months: null,
|
|
},
|
|
});
|
|
|
|
const actual = await stripeHelper.retrieveCouponDetails({
|
|
automaticTax: false,
|
|
country: 'US',
|
|
priceId: 'planId',
|
|
promotionCode: 'promo',
|
|
taxAddress: {
|
|
countryCode: 'US',
|
|
postalCode: '92841',
|
|
},
|
|
});
|
|
|
|
sinon.assert.calledOnceWithExactly(stripeHelper.previewInvoice, {
|
|
priceId: 'planId',
|
|
promotionCode: 'promo',
|
|
taxAddress: {
|
|
countryCode: 'US',
|
|
postalCode: '92841',
|
|
},
|
|
});
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.retrievePromotionCodeForPlan,
|
|
'promo',
|
|
'planId'
|
|
);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('retrieves coupon details for 100% discount', async () => {
|
|
const expected = { ...expectedTemplate, discountAmount: 200 };
|
|
sandbox
|
|
.stub(stripeHelper, 'previewInvoice')
|
|
.resolves([{ ...validInvoicePreview, total: 0 }, undefined]);
|
|
|
|
sandbox.stub(stripeHelper, 'retrievePromotionCodeForPlan').resolves({
|
|
active: true,
|
|
coupon: {
|
|
id: 'promo',
|
|
duration: 'forever',
|
|
valid: true,
|
|
duration_in_months: null,
|
|
},
|
|
});
|
|
|
|
const actual = await stripeHelper.retrieveCouponDetails({
|
|
priceId: 'planId',
|
|
promotionCode: 'promo',
|
|
taxAddress: {
|
|
countryCode: 'US',
|
|
postalCode: '92841',
|
|
},
|
|
});
|
|
|
|
sinon.assert.calledOnceWithExactly(stripeHelper.previewInvoice, {
|
|
priceId: 'planId',
|
|
promotionCode: 'promo',
|
|
taxAddress: {
|
|
countryCode: 'US',
|
|
postalCode: '92841',
|
|
},
|
|
});
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.retrievePromotionCodeForPlan,
|
|
'promo',
|
|
'planId'
|
|
);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('retrieves details on an expired coupon', async () => {
|
|
const expected = {
|
|
...expectedTemplate,
|
|
valid: false,
|
|
expired: true,
|
|
};
|
|
sandbox
|
|
.stub(stripeHelper, 'previewInvoice')
|
|
.resolves({ ...validInvoicePreview, total_discount_amounts: null });
|
|
|
|
sandbox.stub(stripeHelper, 'retrievePromotionCodeForPlan').resolves({
|
|
active: true,
|
|
coupon: {
|
|
id: 'promo',
|
|
duration: 'forever',
|
|
valid: false,
|
|
redeem_by: 1000,
|
|
duration_in_months: null,
|
|
},
|
|
});
|
|
|
|
const actual = await stripeHelper.retrieveCouponDetails({
|
|
country: 'US',
|
|
priceId: 'planId',
|
|
promotionCode: 'promo',
|
|
});
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('retrieves details on a maximally redeemed coupon', async () => {
|
|
const expected = {
|
|
...expectedTemplate,
|
|
valid: false,
|
|
maximallyRedeemed: true,
|
|
};
|
|
sandbox
|
|
.stub(stripeHelper, 'previewInvoice')
|
|
.resolves({ ...validInvoicePreview, total_discount_amounts: null });
|
|
|
|
sandbox.stub(stripeHelper, 'retrievePromotionCodeForPlan').resolves({
|
|
active: true,
|
|
coupon: {
|
|
id: 'promo',
|
|
duration: 'forever',
|
|
valid: false,
|
|
max_redemptions: 1,
|
|
times_redeemed: 1,
|
|
duration_in_months: null,
|
|
},
|
|
});
|
|
|
|
const actual = await stripeHelper.retrieveCouponDetails({
|
|
country: 'US',
|
|
priceId: 'planId',
|
|
promotionCode: 'promo',
|
|
});
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('retrieves details on an expired promotion code', async () => {
|
|
const expected = {
|
|
...expectedTemplate,
|
|
valid: false,
|
|
expired: true,
|
|
};
|
|
sandbox
|
|
.stub(stripeHelper, 'previewInvoice')
|
|
.resolves({ ...validInvoicePreview, total_discount_amounts: null });
|
|
|
|
sandbox.stub(stripeHelper, 'retrievePromotionCodeForPlan').resolves({
|
|
active: false,
|
|
expires_at: 1000,
|
|
coupon: {
|
|
id: 'promo',
|
|
duration: 'forever',
|
|
valid: true,
|
|
duration_in_months: null,
|
|
},
|
|
});
|
|
|
|
const actual = await stripeHelper.retrieveCouponDetails({
|
|
country: 'US',
|
|
priceId: 'planId',
|
|
promotionCode: 'promo',
|
|
});
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('retrieves details on a maximally redeemed promotion code', async () => {
|
|
const expected = {
|
|
...expectedTemplate,
|
|
valid: false,
|
|
maximallyRedeemed: true,
|
|
};
|
|
sandbox
|
|
.stub(stripeHelper, 'previewInvoice')
|
|
.resolves({ ...validInvoicePreview, total_discount_amounts: null });
|
|
|
|
sandbox.stub(stripeHelper, 'retrievePromotionCodeForPlan').resolves({
|
|
active: false,
|
|
max_redemptions: 1,
|
|
times_redeemed: 1,
|
|
coupon: {
|
|
id: 'promo',
|
|
duration: 'forever',
|
|
valid: true,
|
|
duration_in_months: null,
|
|
},
|
|
});
|
|
|
|
const actual = await stripeHelper.retrieveCouponDetails({
|
|
country: 'US',
|
|
priceId: 'planId',
|
|
promotionCode: 'promo',
|
|
});
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('return coupon details even when previewInvoice rejects', async () => {
|
|
const expected = {
|
|
...expectedTemplate,
|
|
valid: false,
|
|
};
|
|
const err = new error('previewInvoiceFailed');
|
|
sandbox.stub(stripeHelper, 'previewInvoice').rejects(err);
|
|
|
|
sandbox.stub(stripeHelper, 'retrievePromotionCodeForPlan').resolves({
|
|
active: true,
|
|
coupon: {
|
|
id: 'promo',
|
|
duration: 'forever',
|
|
valid: true,
|
|
duration_in_months: null,
|
|
},
|
|
});
|
|
|
|
const actual = await stripeHelper.retrieveCouponDetails({
|
|
country: 'US',
|
|
priceId: 'planId',
|
|
promotionCode: 'promo',
|
|
});
|
|
assert.deepEqual(actual, expected);
|
|
sinon.assert.calledTwice(Sentry.withScope);
|
|
sinon.assert.calledWithExactly(
|
|
sentryScope.setContext.getCall(0),
|
|
'retrieveCouponDetails',
|
|
{
|
|
priceId: 'planId',
|
|
promotionCode: 'promo',
|
|
}
|
|
);
|
|
sinon.assert.calledOnceWithExactly(Sentry.captureException, err);
|
|
});
|
|
|
|
it('return coupon details even when getMinAmount rejects', async () => {
|
|
const expected = {
|
|
...expectedTemplate,
|
|
valid: false,
|
|
};
|
|
sandbox
|
|
.stub(stripeHelper, 'previewInvoice')
|
|
.resolves({ ...validInvoicePreview, currency: 'fake' });
|
|
|
|
sandbox.stub(stripeHelper, 'retrievePromotionCodeForPlan').resolves({
|
|
active: true,
|
|
coupon: {
|
|
id: 'promo',
|
|
duration: 'forever',
|
|
valid: true,
|
|
duration_in_months: null,
|
|
},
|
|
});
|
|
|
|
const actual = await stripeHelper.retrieveCouponDetails({
|
|
country: 'US',
|
|
priceId: 'planId',
|
|
promotionCode: 'promo',
|
|
});
|
|
assert.deepEqual(actual, expected);
|
|
sinon.assert.calledTwice(Sentry.withScope);
|
|
sinon.assert.calledOnceWithExactly(
|
|
sentryScope.setContext,
|
|
'retrieveCouponDetails',
|
|
{
|
|
priceId: 'planId',
|
|
promotionCode: 'promo',
|
|
}
|
|
);
|
|
});
|
|
|
|
it('throw an error when previewInvoice returns total less than stripe minimums', async () => {
|
|
sandbox
|
|
.stub(stripeHelper, 'previewInvoice')
|
|
.resolves({ ...validInvoicePreview, total: 20 });
|
|
|
|
sandbox.stub(stripeHelper, 'retrievePromotionCodeForPlan').resolves({
|
|
active: true,
|
|
coupon: {
|
|
id: 'promo',
|
|
duration: 'forever',
|
|
valid: true,
|
|
duration_in_months: null,
|
|
},
|
|
});
|
|
try {
|
|
await stripeHelper.retrieveCouponDetails({
|
|
country: 'US',
|
|
priceId: 'planId',
|
|
promotionCode: 'promo',
|
|
});
|
|
} catch (e) {
|
|
assert.equal(e.errno, error.ERRNO.INVALID_PROMOTION_CODE);
|
|
}
|
|
});
|
|
|
|
it('throw an error when retrievePromotionCodeForPlan returns no coupon', async () => {
|
|
sandbox.stub(stripeHelper, 'retrievePromotionCodeForPlan').resolves();
|
|
try {
|
|
await stripeHelper.retrieveCouponDetails({
|
|
country: 'US',
|
|
priceId: 'planId',
|
|
promotionCode: 'promo',
|
|
});
|
|
} catch (e) {
|
|
assert.equal(e.errno, error.ERRNO.INVALID_PROMOTION_CODE);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('previewInvoice', () => {
|
|
it('uses shipping address when present and no customer is provided', async () => {
|
|
const stripeStub = sandbox
|
|
.stub(stripeHelper.stripe.invoices, 'retrieveUpcoming')
|
|
.resolves();
|
|
|
|
sandbox
|
|
.stub(stripeHelper.currencyHelper, 'isCurrencyCompatibleWithCountry')
|
|
.returns(true);
|
|
|
|
const findAbbrevPlanByIdStub = sandbox
|
|
.stub(stripeHelper, 'findAbbrevPlanById')
|
|
.resolves({
|
|
currency: 'USD',
|
|
});
|
|
|
|
await stripeHelper.previewInvoice({
|
|
priceId: 'priceId',
|
|
taxAddress: {
|
|
countryCode: 'US',
|
|
postalCode: '92841',
|
|
},
|
|
});
|
|
|
|
sinon.assert.calledOnceWithExactly(stripeStub, {
|
|
customer: undefined,
|
|
automatic_tax: {
|
|
enabled: true,
|
|
},
|
|
customer_details: {
|
|
tax_exempt: 'none',
|
|
shipping: {
|
|
name: sinon.match.any,
|
|
address: {
|
|
country: 'US',
|
|
postal_code: '92841',
|
|
},
|
|
},
|
|
},
|
|
subscription_items: [
|
|
{
|
|
price: 'priceId',
|
|
},
|
|
],
|
|
expand: ['total_tax_amounts.tax_rate'],
|
|
});
|
|
|
|
sinon.assert.calledOnceWithExactly(findAbbrevPlanByIdStub, 'priceId');
|
|
});
|
|
|
|
it('disables stripe tax when currency is incompatible with country', async () => {
|
|
const stripeStub = sandbox
|
|
.stub(stripeHelper.stripe.invoices, 'retrieveUpcoming')
|
|
.resolves();
|
|
|
|
const findAbbrevPlanByIdStub = sandbox
|
|
.stub(stripeHelper, 'findAbbrevPlanById')
|
|
.resolves({
|
|
currency: 'USD',
|
|
});
|
|
|
|
sandbox
|
|
.stub(stripeHelper.currencyHelper, 'isCurrencyCompatibleWithCountry')
|
|
.returns(false);
|
|
|
|
await stripeHelper.previewInvoice({
|
|
priceId: 'priceId',
|
|
taxAddress: {
|
|
countryCode: 'US',
|
|
postalCode: '92841',
|
|
},
|
|
});
|
|
|
|
sinon.assert.calledOnceWithExactly(stripeStub, {
|
|
customer: undefined,
|
|
automatic_tax: {
|
|
enabled: false,
|
|
},
|
|
customer_details: {
|
|
tax_exempt: 'none',
|
|
shipping: {
|
|
name: sinon.match.any,
|
|
address: {
|
|
country: 'US',
|
|
postal_code: '92841',
|
|
},
|
|
},
|
|
},
|
|
subscription_items: [
|
|
{
|
|
price: 'priceId',
|
|
},
|
|
],
|
|
expand: ['total_tax_amounts.tax_rate'],
|
|
});
|
|
|
|
sinon.assert.calledOnceWithExactly(findAbbrevPlanByIdStub, 'priceId');
|
|
});
|
|
|
|
it('excludes shipping address when shipping address not passed', async () => {
|
|
const stripeStub = sandbox
|
|
.stub(stripeHelper.stripe.invoices, 'retrieveUpcoming')
|
|
.resolves();
|
|
|
|
sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({
|
|
currency: 'USD',
|
|
});
|
|
|
|
await stripeHelper.previewInvoice({
|
|
priceId: 'priceId',
|
|
taxAddress: undefined,
|
|
});
|
|
|
|
sinon.assert.calledOnceWithExactly(stripeStub, {
|
|
customer: undefined,
|
|
automatic_tax: {
|
|
enabled: false,
|
|
},
|
|
customer_details: {
|
|
tax_exempt: 'none',
|
|
shipping: undefined,
|
|
},
|
|
subscription_items: [
|
|
{
|
|
price: 'priceId',
|
|
},
|
|
],
|
|
expand: ['total_tax_amounts.tax_rate'],
|
|
});
|
|
});
|
|
|
|
it('logs when there is an error', async () => {
|
|
sandbox
|
|
.stub(stripeHelper.stripe.invoices, 'retrieveUpcoming')
|
|
.throws(new Error());
|
|
|
|
sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({
|
|
currency: 'USD',
|
|
});
|
|
|
|
try {
|
|
await stripeHelper.previewInvoice({
|
|
priceId: 'priceId',
|
|
taxAddress: {
|
|
countryCode: 'US',
|
|
postalCode: '92841',
|
|
},
|
|
});
|
|
} catch (e) {
|
|
sinon.assert.calledOnce(stripeHelper.log.warn);
|
|
}
|
|
});
|
|
|
|
it('retrieves both upcoming invoices with and without proration info', async () => {
|
|
const stripeStub = sandbox
|
|
.stub(stripeHelper.stripe.invoices, 'retrieveUpcoming')
|
|
.resolves();
|
|
sandbox.stub(Math, 'floor').returns(1);
|
|
|
|
sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({
|
|
currency: 'USD',
|
|
});
|
|
|
|
await stripeHelper.previewInvoice({
|
|
customer: customer1,
|
|
priceId: 'priceId',
|
|
taxAddress: {
|
|
countryCode: 'US',
|
|
postalCode: '92841',
|
|
},
|
|
isUpgrade: true,
|
|
sourcePlan: {
|
|
plan_id: 'plan_test1',
|
|
},
|
|
});
|
|
|
|
sinon.assert.callCount(stripeStub, 2);
|
|
|
|
sinon.assert.calledWith(stripeStub, {
|
|
customer: 'cus_test1',
|
|
automatic_tax: {
|
|
enabled: false,
|
|
},
|
|
customer_details: {
|
|
tax_exempt: 'none',
|
|
shipping: undefined,
|
|
},
|
|
subscription_proration_behavior: 'always_invoice',
|
|
subscription: customer1.subscriptions?.data[0].id,
|
|
subscription_proration_date: 1,
|
|
subscription_items: [
|
|
{
|
|
price: 'priceId',
|
|
id: customer1.subscriptions?.data[0].items.data[0].id,
|
|
},
|
|
],
|
|
expand: ['total_tax_amounts.tax_rate'],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('previewInvoiceBySubscriptionId', () => {
|
|
it('fetches invoice preview', async () => {
|
|
const stripeStub = sandbox
|
|
.stub(stripeHelper.stripe.invoices, 'retrieveUpcoming')
|
|
.resolves();
|
|
|
|
await stripeHelper.previewInvoiceBySubscriptionId({
|
|
subscriptionId: 'sub123',
|
|
});
|
|
|
|
sinon.assert.calledOnceWithExactly(stripeStub, {
|
|
subscription: 'sub123',
|
|
});
|
|
});
|
|
|
|
it('fetches invoice preview for cancelled subscription', async () => {
|
|
const stripeStub = sandbox
|
|
.stub(stripeHelper.stripe.invoices, 'retrieveUpcoming')
|
|
.resolves();
|
|
|
|
await stripeHelper.previewInvoiceBySubscriptionId({
|
|
subscriptionId: 'sub123',
|
|
includeCanceled: true,
|
|
});
|
|
|
|
sinon.assert.calledOnceWithExactly(stripeStub, {
|
|
subscription: 'sub123',
|
|
subscription_cancel_at_period_end: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('retrievePromotionCodeForPlan', () => {
|
|
it('finds a stripe promotionCode object when a valid code is used', async () => {
|
|
const promotionCode = { code: 'promo1', coupon: { valid: true } };
|
|
sandbox
|
|
.stub(stripeHelper.stripe.promotionCodes, 'list')
|
|
.resolves({ data: [promotionCode] });
|
|
sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({
|
|
plan_metadata: {
|
|
[STRIPE_PRICE_METADATA.PROMOTION_CODES]: 'promo1',
|
|
},
|
|
});
|
|
|
|
const actual = await stripeHelper.retrievePromotionCodeForPlan(
|
|
'promo1',
|
|
'planId'
|
|
);
|
|
assert.deepEqual(actual, promotionCode);
|
|
});
|
|
|
|
it('returns undefined when an invalid promo code is used', async () => {
|
|
const promotionCode = { code: 'promo1', coupon: { valid: true } };
|
|
sandbox
|
|
.stub(stripeHelper.stripe.promotionCodes, 'list')
|
|
.resolves({ data: [promotionCode] });
|
|
sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({
|
|
plan_metadata: {
|
|
[STRIPE_PRICE_METADATA.PROMOTION_CODES]: 'promo2',
|
|
},
|
|
});
|
|
|
|
const actual = await stripeHelper.retrievePromotionCodeForPlan(
|
|
'promo1',
|
|
'planId'
|
|
);
|
|
assert.deepEqual(actual, undefined);
|
|
});
|
|
});
|
|
|
|
describe('verifyPromotionAndCoupon', () => {
|
|
const priceId = 'priceId';
|
|
const promotionCodeTemplate = {
|
|
active: true,
|
|
expires_at: null,
|
|
max_redemptions: null,
|
|
times_redeemed: 0,
|
|
coupon: {
|
|
valid: true,
|
|
max_redemptions: null,
|
|
times_redeemed: 0,
|
|
redeem_by: null,
|
|
},
|
|
};
|
|
|
|
const expectedTemplate = {
|
|
valid: false,
|
|
expired: false,
|
|
maximallyRedeemed: false,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
sandbox
|
|
.stub(stripeHelper, 'validateCouponDurationForPlan')
|
|
.resolves(true);
|
|
});
|
|
|
|
afterEach(() => {
|
|
sandbox.restore();
|
|
});
|
|
|
|
it('return valid for valid coupon and promotion code', async () => {
|
|
const expected = { ...expectedTemplate, valid: true };
|
|
const actual = await stripeHelper.verifyPromotionAndCoupon(
|
|
priceId,
|
|
promotionCodeTemplate
|
|
);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('return invalid with maximallyRedeemed for max redeemed coupon', async () => {
|
|
const promotionCode = {
|
|
...promotionCodeTemplate,
|
|
coupon: {
|
|
...promotionCodeTemplate.coupon,
|
|
valid: false,
|
|
max_redemptions: 1,
|
|
times_redeemed: 1,
|
|
},
|
|
};
|
|
const expected = { ...expectedTemplate, maximallyRedeemed: true };
|
|
const actual = await stripeHelper.verifyPromotionAndCoupon(
|
|
priceId,
|
|
promotionCode
|
|
);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('return invalid with expired for expired coupon', async () => {
|
|
const promotionCode = {
|
|
...promotionCodeTemplate,
|
|
coupon: {
|
|
valid: false,
|
|
redeem_by: 1000,
|
|
},
|
|
};
|
|
const expected = { ...expectedTemplate, expired: true };
|
|
const actual = await stripeHelper.verifyPromotionAndCoupon(
|
|
priceId,
|
|
promotionCode
|
|
);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('return invalid with maximallyRedeemed for max redeemed promotion code', async () => {
|
|
const promotionCode = {
|
|
...promotionCodeTemplate,
|
|
active: false,
|
|
max_redemptions: 1,
|
|
times_redeemed: 1,
|
|
};
|
|
const expected = { ...expectedTemplate, maximallyRedeemed: true };
|
|
const actual = await stripeHelper.verifyPromotionAndCoupon(
|
|
priceId,
|
|
promotionCode
|
|
);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('return invalid with expired for expired promotion code', async () => {
|
|
const promotionCode = {
|
|
...promotionCodeTemplate,
|
|
active: false,
|
|
expires_at: 1000,
|
|
};
|
|
const expected = { ...expectedTemplate, expired: true };
|
|
const actual = await stripeHelper.verifyPromotionAndCoupon(
|
|
priceId,
|
|
promotionCode
|
|
);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('return invalid for invalid coupon duration for plan', async () => {
|
|
const promotionCode = promotionCodeTemplate;
|
|
sandbox.restore();
|
|
sandbox
|
|
.stub(stripeHelper, 'validateCouponDurationForPlan')
|
|
.resolves(false);
|
|
|
|
const expected = expectedTemplate;
|
|
const actual = await stripeHelper.verifyPromotionAndCoupon(
|
|
priceId,
|
|
promotionCode
|
|
);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
});
|
|
|
|
describe('checkPromotionAndCouponProperties', () => {
|
|
const propertiesTemplate = {
|
|
valid: false,
|
|
redeem_by: null,
|
|
max_redemptions: null,
|
|
times_redeemed: 0,
|
|
};
|
|
it('return valid', () => {
|
|
const properties = {
|
|
...propertiesTemplate,
|
|
valid: true,
|
|
};
|
|
const expected = {
|
|
valid: true,
|
|
expired: false,
|
|
maximallyRedeemed: false,
|
|
};
|
|
const actual = stripeHelper.checkPromotionAndCouponProperties(properties);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('return invalid and maximally redeemed', () => {
|
|
const properties = {
|
|
...propertiesTemplate,
|
|
max_redemptions: 1,
|
|
times_redeemed: 1,
|
|
};
|
|
const expected = {
|
|
valid: false,
|
|
expired: false,
|
|
maximallyRedeemed: true,
|
|
};
|
|
const actual = stripeHelper.checkPromotionAndCouponProperties(properties);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('return invalid and expired', () => {
|
|
const properties = {
|
|
...propertiesTemplate,
|
|
redeem_by: 1000,
|
|
};
|
|
const expected = {
|
|
valid: false,
|
|
expired: true,
|
|
maximallyRedeemed: false,
|
|
};
|
|
const actual = stripeHelper.checkPromotionAndCouponProperties(properties);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('return invalid only if neither expired or maximally redeemed', () => {
|
|
const properties = propertiesTemplate;
|
|
const expected = {
|
|
valid: false,
|
|
expired: false,
|
|
maximallyRedeemed: false,
|
|
};
|
|
const actual = stripeHelper.checkPromotionAndCouponProperties(properties);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
});
|
|
|
|
describe('checkPromotionCodeForPlan', () => {
|
|
const couponTemplate = {
|
|
duration: 'once',
|
|
duration_in_months: null,
|
|
};
|
|
it('finds a promo code for a given plan', async () => {
|
|
const promotionCode = 'promo1';
|
|
sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({
|
|
plan_metadata: {
|
|
[STRIPE_PRICE_METADATA.PROMOTION_CODES]: 'promo1',
|
|
},
|
|
});
|
|
|
|
const actual = await stripeHelper.checkPromotionCodeForPlan(
|
|
promotionCode,
|
|
'planId',
|
|
couponTemplate
|
|
);
|
|
assert.deepEqual(actual, true);
|
|
});
|
|
|
|
it('finds a promo code in a Firestore config', async () => {
|
|
const promotionCode = 'promo1';
|
|
sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({
|
|
plan_metadata: {
|
|
[STRIPE_PRICE_METADATA.PROMOTION_CODES]: '',
|
|
},
|
|
});
|
|
sandbox.stub(stripeHelper, 'maybeGetPlanConfig').resolves({
|
|
promotionCodes: ['promo1'],
|
|
});
|
|
|
|
const actual = await stripeHelper.checkPromotionCodeForPlan(
|
|
promotionCode,
|
|
'planId',
|
|
couponTemplate
|
|
);
|
|
assert.deepEqual(actual, true);
|
|
});
|
|
|
|
it('does not find a promo code for a given plan', async () => {
|
|
const promotionCode = 'promo1';
|
|
sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({
|
|
plan_metadata: {
|
|
[STRIPE_PRICE_METADATA.PROMOTION_CODES]: 'promo2',
|
|
},
|
|
});
|
|
|
|
const actual = await stripeHelper.checkPromotionCodeForPlan(
|
|
promotionCode,
|
|
'planId',
|
|
couponTemplate
|
|
);
|
|
assert.deepEqual(actual, false);
|
|
});
|
|
});
|
|
|
|
describe('invoicePayableWithPaypal', () => {
|
|
it('returns true if its payable via paypal', async () => {
|
|
const mockInvoice = {
|
|
billing_reason: 'subscription_cycle',
|
|
subscription: 'sub-1234',
|
|
};
|
|
const mockSub = {
|
|
collection_method: 'send_invoice',
|
|
};
|
|
sandbox.stub(stripeHelper, 'expandResource').resolves(mockSub);
|
|
const actual = await stripeHelper.invoicePayableWithPaypal(mockInvoice);
|
|
assert.isTrue(actual);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.expandResource,
|
|
'sub-1234',
|
|
'subscriptions'
|
|
);
|
|
});
|
|
|
|
it('returns false if invoice is sub create', async () => {
|
|
const mockInvoice = {
|
|
billing_reason: 'subscription_create',
|
|
};
|
|
const mockSub = {
|
|
collection_method: 'send_invoice',
|
|
};
|
|
sandbox.stub(stripeHelper, 'expandResource').resolves(mockSub);
|
|
const actual = await stripeHelper.invoicePayableWithPaypal(mockInvoice);
|
|
assert.isFalse(actual);
|
|
sinon.assert.notCalled(stripeHelper.expandResource);
|
|
});
|
|
|
|
it('returns false if subscription collection_method isnt invoice', async () => {
|
|
const mockInvoice = {
|
|
billing_reason: 'subscription_cycle',
|
|
subscription: 'sub-1234',
|
|
};
|
|
const mockSub = {
|
|
collection_method: 'charge_automatically',
|
|
};
|
|
sandbox.stub(stripeHelper, 'expandResource').resolves(mockSub);
|
|
const actual = await stripeHelper.invoicePayableWithPaypal(mockInvoice);
|
|
assert.isFalse(actual);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.expandResource,
|
|
'sub-1234',
|
|
'subscriptions'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getInvoice', () => {
|
|
it('works successfully', async () => {
|
|
sandbox.stub(stripeHelper, 'expandResource').resolves(unpaidInvoice);
|
|
const actual = await stripeHelper.getInvoice(unpaidInvoice.id);
|
|
assert.deepEqual(actual, unpaidInvoice);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.expandResource,
|
|
unpaidInvoice.id,
|
|
INVOICES_RESOURCE
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('finalizeInvoice', () => {
|
|
it('works successfully', async () => {
|
|
sandbox
|
|
.stub(stripeHelper.stripe.invoices, 'finalizeInvoice')
|
|
.resolves({});
|
|
const actual = await stripeHelper.finalizeInvoice(unpaidInvoice);
|
|
assert.deepEqual(actual, {});
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.invoices.finalizeInvoice,
|
|
unpaidInvoice.id,
|
|
{
|
|
auto_advance: false,
|
|
}
|
|
);
|
|
});
|
|
});
|
|
|
|
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({});
|
|
const actual = await stripeHelper.updateInvoiceWithPaypalTransactionId(
|
|
unpaidInvoice,
|
|
'tid'
|
|
);
|
|
assert.deepEqual(actual, {});
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.invoices.update,
|
|
unpaidInvoice.id,
|
|
{
|
|
metadata: { paypalTransactionId: 'tid' },
|
|
}
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('updateInvoiceWithPaypalRefundTransactionId', () => {
|
|
it('works successfully', async () => {
|
|
sandbox.stub(stripeHelper.stripe.invoices, 'update').resolves({});
|
|
const actual =
|
|
await stripeHelper.updateInvoiceWithPaypalRefundTransactionId(
|
|
unpaidInvoice,
|
|
'tid'
|
|
);
|
|
assert.deepEqual(actual, {});
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.invoices.update,
|
|
unpaidInvoice.id,
|
|
{
|
|
metadata: { paypalRefundTransactionId: 'tid' },
|
|
}
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('updateInvoiceWithPaypalRefundReason', () => {
|
|
it('works successfully', async () => {
|
|
sandbox.stub(stripeHelper.stripe.invoices, 'update').resolves({});
|
|
const actual = await stripeHelper.updateInvoiceWithPaypalRefundReason(
|
|
unpaidInvoice,
|
|
'reason'
|
|
);
|
|
assert.deepEqual(actual, {});
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.invoices.update,
|
|
unpaidInvoice.id,
|
|
{
|
|
metadata: { paypalRefundRefused: 'reason' },
|
|
}
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getPaymentAttempts', () => {
|
|
it('returns 0 with no attempts', () => {
|
|
const actual = stripeHelper.getPaymentAttempts(unpaidInvoice);
|
|
assert.equal(actual, 0);
|
|
});
|
|
|
|
it('returns 1 when the attempt is 1', () => {
|
|
const attemptedInvoice = deepCopy(unpaidInvoice);
|
|
attemptedInvoice.metadata['paymentAttempts'] = '1';
|
|
const actual = stripeHelper.getPaymentAttempts(attemptedInvoice);
|
|
assert.equal(actual, 1);
|
|
});
|
|
});
|
|
|
|
describe('updatePaymentAttempts', () => {
|
|
it('returns 1 updating from 0', async () => {
|
|
const attemptedInvoice = deepCopy(unpaidInvoice);
|
|
const actual = stripeHelper.getPaymentAttempts(attemptedInvoice);
|
|
assert.equal(actual, 0);
|
|
sandbox.stub(stripeHelper.stripe.invoices, 'update').resolves({});
|
|
await stripeHelper.updatePaymentAttempts(attemptedInvoice);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.invoices.update,
|
|
attemptedInvoice.id,
|
|
{
|
|
metadata: { paymentAttempts: '1' },
|
|
}
|
|
);
|
|
});
|
|
|
|
it('returns 2 updating from 1', async () => {
|
|
const attemptedInvoice = deepCopy(unpaidInvoice);
|
|
attemptedInvoice.metadata.paymentAttempts = '1';
|
|
const actual = stripeHelper.getPaymentAttempts(attemptedInvoice);
|
|
assert.equal(actual, 1);
|
|
sandbox.stub(stripeHelper.stripe.invoices, 'update').resolves({});
|
|
await stripeHelper.updatePaymentAttempts(attemptedInvoice);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.invoices.update,
|
|
attemptedInvoice.id,
|
|
{
|
|
metadata: { paymentAttempts: '2' },
|
|
}
|
|
);
|
|
});
|
|
|
|
it('returns 3 updating from 1', async () => {
|
|
const attemptedInvoice = deepCopy(unpaidInvoice);
|
|
attemptedInvoice.metadata.paymentAttempts = '1';
|
|
const actual = stripeHelper.getPaymentAttempts(attemptedInvoice);
|
|
assert.equal(actual, 1);
|
|
sandbox.stub(stripeHelper.stripe.invoices, 'update').resolves({});
|
|
await stripeHelper.updatePaymentAttempts(attemptedInvoice, 3);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.invoices.update,
|
|
attemptedInvoice.id,
|
|
{
|
|
metadata: { paymentAttempts: '3' },
|
|
}
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getEmailTypes', () => {
|
|
it('returns empty array when no email was sent', () => {
|
|
const actual = stripeHelper.getEmailTypes(unpaidInvoice);
|
|
assert.deepEqual(actual, []);
|
|
});
|
|
|
|
it('returns the only email sent', () => {
|
|
const emailSentInvoice = {
|
|
...unpaidInvoice,
|
|
metadata: { [STRIPE_INVOICE_METADATA.EMAIL_SENT]: 'paymentFailed' },
|
|
};
|
|
const actual = stripeHelper.getEmailTypes(emailSentInvoice);
|
|
assert.deepEqual(actual, ['paymentFailed']);
|
|
});
|
|
|
|
it('returns all types of emails sent', () => {
|
|
const emailSentInvoice = {
|
|
...unpaidInvoice,
|
|
metadata: { [STRIPE_INVOICE_METADATA.EMAIL_SENT]: 'paymentFailed:foo' },
|
|
};
|
|
const actual = stripeHelper.getEmailTypes(emailSentInvoice);
|
|
assert.deepEqual(actual, ['paymentFailed', 'foo']);
|
|
});
|
|
});
|
|
|
|
describe('updateEmailSent', () => {
|
|
const emailSentInvoice = {
|
|
...unpaidInvoice,
|
|
metadata: { [STRIPE_INVOICE_METADATA.EMAIL_SENT]: 'paymentFailed' },
|
|
};
|
|
|
|
it('returns undefined if email type already sent', async () => {
|
|
const actual = await stripeHelper.updateEmailSent(
|
|
emailSentInvoice,
|
|
'paymentFailed'
|
|
);
|
|
assert.equal(actual, undefined);
|
|
});
|
|
|
|
it('returns invoice updated with new email type', async () => {
|
|
const emailSendInvoice = deepCopy(unpaidInvoice);
|
|
sandbox.stub(stripeHelper.stripe.invoices, 'update').resolves({});
|
|
const actual = await stripeHelper.updateEmailSent(
|
|
emailSendInvoice,
|
|
'paymentFailed'
|
|
);
|
|
assert.deepEqual(actual, {});
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.invoices.update,
|
|
emailSendInvoice.id,
|
|
{
|
|
metadata: emailSentInvoice.metadata,
|
|
}
|
|
);
|
|
});
|
|
|
|
it('returns invoice updated with another email type', async () => {
|
|
const emailSendInvoice = deepCopy(emailSentInvoice);
|
|
sandbox.stub(stripeHelper.stripe.invoices, 'update').resolves({});
|
|
const actual = await stripeHelper.updateEmailSent(
|
|
emailSendInvoice,
|
|
'foo'
|
|
);
|
|
assert.deepEqual(actual, {});
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.invoices.update,
|
|
emailSendInvoice.id,
|
|
{
|
|
metadata: {
|
|
[STRIPE_INVOICE_METADATA.EMAIL_SENT]: 'paymentFailed:foo',
|
|
},
|
|
}
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('payInvoiceOutOfBand', () => {
|
|
it('pays the invoice', async () => {
|
|
sandbox.stub(stripeHelper.stripe.invoices, 'pay').resolves({});
|
|
await stripeHelper.payInvoiceOutOfBand(unpaidInvoice);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.invoices.pay,
|
|
unpaidInvoice.id,
|
|
{ paid_out_of_band: true }
|
|
);
|
|
});
|
|
|
|
it('ignores error if the invoice was already paid', async () => {
|
|
const paidInvoice = { ...deepCopy(unpaidInvoice), paid: true };
|
|
sandbox
|
|
.stub(stripeHelper.stripe.invoices, 'pay')
|
|
.rejects(new Error('Invoice is already paid'));
|
|
await stripeHelper.payInvoiceOutOfBand(paidInvoice);
|
|
sinon.assert.calledOnce(stripeHelper.stripe.invoices.pay);
|
|
});
|
|
});
|
|
|
|
describe('updateCustomerBillingAddress', () => {
|
|
it('updates Customer with empty PayPal billing address', async () => {
|
|
sandbox
|
|
.stub(stripeHelper.stripe.customers, 'update')
|
|
.resolves({ metadata: {}, tax: {} });
|
|
stripeFirestore.insertCustomerRecordWithBackfill = sandbox
|
|
.stub()
|
|
.resolves({});
|
|
const result = await stripeHelper.updateCustomerBillingAddress({
|
|
customerId: customer1.id,
|
|
options: {
|
|
city: 'city',
|
|
country: 'US',
|
|
line1: 'street address',
|
|
line2: undefined,
|
|
postalCode: '12345',
|
|
state: 'CA',
|
|
},
|
|
});
|
|
assert.deepEqual(result, { metadata: {}, tax: {} });
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.customers.update,
|
|
customer1.id,
|
|
{
|
|
address: {
|
|
city: 'city',
|
|
country: 'US',
|
|
line1: 'street address',
|
|
line2: undefined,
|
|
postal_code: '12345',
|
|
state: 'CA',
|
|
},
|
|
expand: ['tax'],
|
|
}
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeFirestore.insertCustomerRecordWithBackfill,
|
|
undefined,
|
|
{ metadata: {} }
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('updateCustomerPaypalAgreement', () => {
|
|
it('skips if the agreement id is already set', async () => {
|
|
const paypalCustomer = deepCopy(customer1);
|
|
paypalCustomer.metadata.paypalAgreementId = 'test-1234';
|
|
sandbox.stub(stripeHelper.stripe.customers, 'update').resolves({});
|
|
await stripeHelper.updateCustomerPaypalAgreement(
|
|
paypalCustomer,
|
|
'test-1234'
|
|
);
|
|
sinon.assert.callCount(stripeHelper.stripe.customers.update, 0);
|
|
});
|
|
|
|
it('updates for a billing agreement id', async () => {
|
|
const paypalCustomer = deepCopy(customer1);
|
|
sandbox.stub(stripeHelper.stripe.customers, 'update').resolves({});
|
|
stripeFirestore.insertCustomerRecordWithBackfill = sandbox
|
|
.stub()
|
|
.resolves({});
|
|
await stripeHelper.updateCustomerPaypalAgreement(
|
|
paypalCustomer,
|
|
'test-1234'
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.customers.update,
|
|
paypalCustomer.id,
|
|
{ metadata: { paypalAgreementId: 'test-1234' } }
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeFirestore.insertCustomerRecordWithBackfill,
|
|
paypalCustomer.metadata.userid,
|
|
{}
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('removeCustomerPaypalAgreement', () => {
|
|
it('removes billing agreement id', async () => {
|
|
const paypalCustomer = deepCopy(customer1);
|
|
sandbox.stub(stripeHelper.stripe.customers, 'update').resolves({});
|
|
const now = new Date();
|
|
const clock = sinon.useFakeTimers(now.getTime());
|
|
|
|
sandbox.stub(dbStub, 'updatePayPalBA').returns(0);
|
|
stripeFirestore.insertCustomerRecordWithBackfill = sandbox
|
|
.stub()
|
|
.resolves({});
|
|
await stripeHelper.removeCustomerPaypalAgreement(
|
|
'uid',
|
|
paypalCustomer.id,
|
|
'billingAgreementId'
|
|
);
|
|
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.customers.update,
|
|
paypalCustomer.id,
|
|
{ metadata: { paypalAgreementId: null } }
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
dbStub.updatePayPalBA,
|
|
'uid',
|
|
'billingAgreementId',
|
|
'Cancelled',
|
|
clock.now
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeFirestore.insertCustomerRecordWithBackfill,
|
|
'uid',
|
|
{}
|
|
);
|
|
clock.restore();
|
|
});
|
|
});
|
|
|
|
describe('getCustomerPaypalAgreement', () => {
|
|
it('returns undefined with no paypal agreement', () => {
|
|
const actual = stripeHelper.getCustomerPaypalAgreement(customer1);
|
|
assert.isUndefined(actual);
|
|
});
|
|
|
|
it('returns an agreement when set', () => {
|
|
const paypalCustomer = deepCopy(customer1);
|
|
paypalCustomer.metadata.paypalAgreementId = 'test-1234';
|
|
const actual = stripeHelper.getCustomerPaypalAgreement(paypalCustomer);
|
|
assert.equal(actual, 'test-1234');
|
|
});
|
|
});
|
|
|
|
describe('fetchOpenInvoices', () => {
|
|
it('returns customer paypal agreement id', async () => {
|
|
const invoice = deepCopy(invoicePaidSubscriptionCreate);
|
|
invoice.subscription = { status: 'active' };
|
|
const invoice2 = deepCopy(invoicePaidSubscriptionCreate);
|
|
invoice2.subscription = { status: 'cancelled' };
|
|
async function* genInvoice() {
|
|
yield invoice;
|
|
yield invoice2;
|
|
}
|
|
sandbox.stub(stripeHelper.stripe.invoices, 'list').returns(genInvoice());
|
|
const actual = [];
|
|
for await (const item of stripeHelper.fetchOpenInvoices(0)) {
|
|
actual.push(item);
|
|
}
|
|
assert.deepEqual(actual, [invoice]);
|
|
sinon.assert.calledOnceWithExactly(stripeHelper.stripe.invoices.list, {
|
|
customer: undefined,
|
|
limit: 100,
|
|
collection_method: 'send_invoice',
|
|
status: 'open',
|
|
created: 0,
|
|
expand: ['data.customer', 'data.subscription'],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('markUncollectible', () => {
|
|
it('returns an invoice marked uncollectible', async () => {
|
|
sandbox
|
|
.stub(stripeHelper.stripe.invoices, 'markUncollectible')
|
|
.resolves({});
|
|
sandbox.stub(stripeHelper.stripe.invoices, 'list').resolves({});
|
|
const actual = await stripeHelper.markUncollectible(unpaidInvoice);
|
|
assert.deepEqual(actual, {});
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.invoices.markUncollectible,
|
|
unpaidInvoice.id
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('cancelSubscription', () => {
|
|
it('sets subscription to cancelled', async () => {
|
|
sandbox.stub(stripeHelper.stripe.subscriptions, 'cancel').resolves({});
|
|
await stripeHelper.cancelSubscription('subscriptionId');
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.subscriptions.cancel,
|
|
'subscriptionId'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('findCustomerSubscriptionByPlanId', () => {
|
|
describe('Customer has Single One-Plan Subscription', () => {
|
|
const customer = deepCopy(customer1);
|
|
customer.subscriptions.data = [subscription2];
|
|
it('returns the Subscription when the plan id is found', () => {
|
|
const expected = customer.subscriptions.data[0];
|
|
const actual = stripeHelper.findCustomerSubscriptionByPlanId(
|
|
customer,
|
|
customer.subscriptions.data[0].items.data[0].plan.id
|
|
);
|
|
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('returns `undefined` when the plan id is not found', () => {
|
|
assert.isUndefined(
|
|
stripeHelper.findCustomerSubscriptionByPlanId(customer, 'plan_test2')
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Customer has Single Multi-Plan Subscription', () => {
|
|
const customer = deepCopy(customer1);
|
|
customer.subscriptions.data = [multiPlanSubscription];
|
|
|
|
it('returns the Subscription when the plan id is found - first in array', () => {
|
|
const expected = customer.subscriptions.data[0];
|
|
const actual = stripeHelper.findCustomerSubscriptionByPlanId(
|
|
customer,
|
|
'plan_1'
|
|
);
|
|
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('returns the Subscription when the plan id is found - not first in array', () => {
|
|
const expected = customer.subscriptions.data[0];
|
|
const actual = stripeHelper.findCustomerSubscriptionByPlanId(
|
|
customer,
|
|
'plan_2'
|
|
);
|
|
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('returns `undefined` when the plan id is not found', () => {
|
|
assert.isUndefined(
|
|
stripeHelper.findCustomerSubscriptionByPlanId(customer, 'plan_3')
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Customer has Multiple Subscriptions', () => {
|
|
const customer = deepCopy(customer1);
|
|
customer.subscriptions.data = [multiPlanSubscription, subscription2];
|
|
|
|
it('returns the Subscription when the plan id is found in the first subscription', () => {
|
|
const expected = customer.subscriptions.data[0];
|
|
const actual = stripeHelper.findCustomerSubscriptionByPlanId(
|
|
customer,
|
|
'plan_2'
|
|
);
|
|
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('returns the Subscription when the plan id is found in not the first subscription', () => {
|
|
const expected = customer.subscriptions.data[1];
|
|
const actual = stripeHelper.findCustomerSubscriptionByPlanId(
|
|
customer,
|
|
'plan_G93mMKnIFCjZek'
|
|
);
|
|
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('returns `undefined` when the plan id is not found', () => {
|
|
assert.isUndefined(
|
|
stripeHelper.findCustomerSubscriptionByPlanId(customer, 'plan_test2')
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('extractSourceCountryFromSubscription', () => {
|
|
it('extracts the country if its present', () => {
|
|
const latest_invoice = {
|
|
...subscriptionCreatedInvoice,
|
|
payment_intent: { ...closedPaymementIntent },
|
|
};
|
|
const subscription = { ...subscription2, latest_invoice };
|
|
const result =
|
|
stripeHelper.extractSourceCountryFromSubscription(subscription);
|
|
assert.equal(result, 'US');
|
|
});
|
|
|
|
it('returns null with no invoice', () => {
|
|
const result =
|
|
stripeHelper.extractSourceCountryFromSubscription(subscription2);
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
it('returns null and sends sentry error with no charges', () => {
|
|
const scopeContextSpy = sinon.fake();
|
|
const scopeSpy = {
|
|
setContext: scopeContextSpy,
|
|
};
|
|
sandbox.replace(Sentry, 'withScope', (fn) => fn(scopeSpy));
|
|
sandbox.replace(sentryModule, 'reportSentryMessage', sinon.stub());
|
|
|
|
const latest_invoice = {
|
|
...subscriptionCreatedInvoice,
|
|
payment_intent: { latest_charge: null },
|
|
};
|
|
const subscription = { ...subscription2, latest_invoice };
|
|
const result =
|
|
stripeHelper.extractSourceCountryFromSubscription(subscription);
|
|
assert.equal(result, null);
|
|
|
|
assert.isTrue(
|
|
scopeContextSpy.calledOnce,
|
|
'Set a message scope when "latest_charge" is missing'
|
|
);
|
|
assert.isTrue(
|
|
sentryModule.reportSentryMessage.calledOnce,
|
|
'Capture a message with Sentry when "latest_charge" is missing'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('allTaxRates', () => {
|
|
it('pulls a list of tax rates and caches it', async () => {
|
|
assert.lengthOf(await stripeHelper.allTaxRates(), 2);
|
|
assert(mockRedis.get.calledOnce);
|
|
|
|
assert.lengthOf(await stripeHelper.allTaxRates(), 2);
|
|
assert(mockRedis.get.calledTwice);
|
|
assert(mockRedis.set.calledOnce);
|
|
|
|
// Assert that a TTL was set for this cache entry
|
|
assert.deepEqual(mockRedis.set.args[0][2], [
|
|
'EX',
|
|
mockConfig.subhub.stripeTaxRatesCacheTtlSeconds,
|
|
]);
|
|
|
|
assert(stripeHelper.stripe.taxRates.list.calledOnce);
|
|
|
|
assert.deepEqual(
|
|
await stripeHelper.allTaxRates(),
|
|
JSON.parse(await mockRedis.get('listStripeTaxRates'))
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('updateAllTaxRates', () => {
|
|
it('updates the tax rates in the cache', async () => {
|
|
const newList = ['xyz'];
|
|
await stripeHelper.updateAllTaxRates(newList);
|
|
assert.deepEqual(mockRedis.set.args[0][2], [
|
|
'EX',
|
|
mockConfig.subhub.stripeTaxRatesCacheTtlSeconds,
|
|
]);
|
|
assert.deepEqual(
|
|
newList,
|
|
JSON.parse(await mockRedis.get('listStripeTaxRates'))
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('taxRateByCountryCode', () => {
|
|
it('locates an existing tax rate', async () => {
|
|
const result = await stripeHelper.taxRateByCountryCode('FR');
|
|
assert.isDefined(result);
|
|
assert.deepEqual(result, taxRateFr);
|
|
});
|
|
|
|
it('returns undefined for unknown tax rates', async () => {
|
|
const result = await stripeHelper.taxRateByCountryCode('GA');
|
|
assert.isUndefined(result);
|
|
});
|
|
|
|
it('ignores case on comparison', async () => {
|
|
const result = await stripeHelper.taxRateByCountryCode('fr');
|
|
assert.isDefined(result);
|
|
assert.deepEqual(result, taxRateFr);
|
|
});
|
|
});
|
|
|
|
describe('allConfiguredPlans', () => {
|
|
it('gets a list of configured plans', async () => {
|
|
const thePlans = await stripeHelper.allPlans();
|
|
sandbox.spy(stripeHelper, 'allPlans');
|
|
sandbox.spy(stripeHelper.paymentConfigManager, 'getMergedConfig');
|
|
const actual = await stripeHelper.allConfiguredPlans();
|
|
actual.forEach((p, idx) => {
|
|
assert.equal(p.id, thePlans[idx].id);
|
|
assert.isTrue('configuration' in p);
|
|
if (p.id === plan3.id) {
|
|
assert.isNull(p.configuration);
|
|
} else {
|
|
assert.isNotNull(p.configuration);
|
|
}
|
|
});
|
|
assert.isTrue(stripeHelper.allPlans.calledOnce);
|
|
assert.isTrue(
|
|
// one of the plans does not have a matching ProductConfig
|
|
stripeHelper.paymentConfigManager.getMergedConfig.calledTwice
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('allPlans', () => {
|
|
it('pulls a list of plans and caches it', async () => {
|
|
assert.lengthOf(await stripeHelper.allPlans(), 3);
|
|
assert(mockRedis.get.calledOnce);
|
|
|
|
assert.lengthOf(await stripeHelper.allPlans(), 3);
|
|
assert(mockRedis.get.calledTwice);
|
|
assert(mockRedis.set.calledOnce);
|
|
|
|
// Assert that a TTL was set for this cache entry
|
|
assert.deepEqual(mockRedis.set.args[0][2], [
|
|
'EX',
|
|
mockConfig.subhub.plansCacheTtlSeconds,
|
|
]);
|
|
|
|
assert(stripeHelper.stripe.plans.list.calledOnce);
|
|
|
|
assert.deepEqual(
|
|
await stripeHelper.allPlans(),
|
|
JSON.parse(await mockRedis.get('listStripePlans'))
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('updateAllPlans', () => {
|
|
it('updates the plans in the cache', async () => {
|
|
const newList = ['xyz'];
|
|
await stripeHelper.updateAllPlans(newList);
|
|
assert.deepEqual(mockRedis.set.args[0][2], [
|
|
'EX',
|
|
mockConfig.subhub.plansCacheTtlSeconds,
|
|
]);
|
|
assert.deepEqual(
|
|
newList,
|
|
JSON.parse(await mockRedis.get('listStripePlans'))
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('allAbbrevPlans', () => {
|
|
it('returns a AbbrevPlan list based on allPlans', async () => {
|
|
sandbox.spy(stripeHelper, 'allPlans');
|
|
sandbox.spy(stripeHelper, 'allConfiguredPlans');
|
|
const actual = await stripeHelper.allAbbrevPlans();
|
|
assert(stripeHelper.allConfiguredPlans.calledOnce);
|
|
assert(stripeHelper.allPlans.calledOnce);
|
|
assert(stripeHelper.stripe.plans.list.calledOnce);
|
|
|
|
assert.deepEqual(
|
|
actual,
|
|
[plan1, plan2]
|
|
.map((p) => ({
|
|
amount: p.amount,
|
|
currency: p.currency,
|
|
interval_count: p.interval_count,
|
|
interval: p.interval,
|
|
plan_id: p.id,
|
|
plan_metadata: p.metadata,
|
|
plan_name: p.nickname || '',
|
|
product_id: p.product.id,
|
|
product_metadata: p.product.metadata,
|
|
product_name: p.product.name,
|
|
active: true,
|
|
configuration: {
|
|
locales: {},
|
|
productSet: undefined,
|
|
stripePriceId: p.id,
|
|
styles: {},
|
|
support: {},
|
|
uiContent: {},
|
|
urls: {},
|
|
},
|
|
}))
|
|
.concat(
|
|
[plan3].map((p) => ({
|
|
amount: p.amount,
|
|
currency: p.currency,
|
|
interval_count: p.interval_count,
|
|
interval: p.interval,
|
|
plan_id: p.id,
|
|
plan_metadata: p.metadata,
|
|
plan_name: p.nickname || '',
|
|
product_id: p.product.id,
|
|
product_metadata: p.product.metadata,
|
|
product_name: p.product.name,
|
|
active: true,
|
|
configuration: null,
|
|
}))
|
|
)
|
|
);
|
|
});
|
|
|
|
it('filters out invalid plans', async () => {
|
|
const first = { ...plan1, product: { ...plan1.product, metadata: {} } };
|
|
const second = {
|
|
...plan2,
|
|
id: 'veryfake',
|
|
product: { ...plan2.product, id: 'veryfake' },
|
|
};
|
|
const third = {
|
|
...plan3,
|
|
id: 'missing',
|
|
metadata: {},
|
|
product: { ...plan3.product, metadata: {} },
|
|
};
|
|
listStripePlans.restore();
|
|
sandbox
|
|
.stub(stripeHelper.stripe.plans, 'list')
|
|
.returns([first, second, third]);
|
|
sandbox.spy(stripeHelper, 'allPlans');
|
|
sandbox.spy(stripeHelper, 'allConfiguredPlans');
|
|
const actual = await stripeHelper.allAbbrevPlans();
|
|
assert(stripeHelper.allConfiguredPlans.calledOnce);
|
|
assert(stripeHelper.allPlans.calledOnce);
|
|
assert(stripeHelper.stripe.plans.list.calledOnce);
|
|
|
|
assert.deepEqual(
|
|
actual,
|
|
[first]
|
|
.map((p) => ({
|
|
amount: p.amount,
|
|
currency: p.currency,
|
|
interval_count: p.interval_count,
|
|
interval: p.interval,
|
|
plan_id: p.id,
|
|
plan_metadata: p.metadata,
|
|
plan_name: p.nickname || '',
|
|
product_id: p.product.id,
|
|
product_metadata: p.product.metadata,
|
|
product_name: p.product.name,
|
|
active: true,
|
|
configuration: {
|
|
locales: {},
|
|
productSet: undefined,
|
|
stripePriceId: p.id,
|
|
styles: {},
|
|
support: {},
|
|
uiContent: {},
|
|
urls: {},
|
|
},
|
|
}))
|
|
.concat(
|
|
[second].map((p) => ({
|
|
amount: p.amount,
|
|
currency: p.currency,
|
|
interval_count: p.interval_count,
|
|
interval: p.interval,
|
|
plan_id: p.id,
|
|
plan_metadata: p.metadata,
|
|
plan_name: p.nickname || '',
|
|
product_id: p.product.id,
|
|
product_metadata: p.product.metadata,
|
|
product_name: p.product.name,
|
|
active: true,
|
|
configuration: null,
|
|
}))
|
|
)
|
|
);
|
|
});
|
|
|
|
it('rejects and returns stripe values', async () => {
|
|
const err = new Error('It is bad');
|
|
const mockProductConfigurationManager = {
|
|
getPurchaseWithDetailsOfferingContentByPlanIds: sinon
|
|
.stub()
|
|
.rejects(err),
|
|
getSupportedLocale: sinon.fake.resolves('en'),
|
|
};
|
|
Container.set(
|
|
ProductConfigurationManager,
|
|
mockProductConfigurationManager
|
|
);
|
|
const stripeHelper = new StripeHelper(log, mockConfig, mockStatsd);
|
|
listStripePlans = sandbox
|
|
.stub(stripeHelper.stripe.plans, 'list')
|
|
.returns(asyncIterable([plan1, plan2, plan3]));
|
|
sandbox.spy(stripeHelper, 'allPlans');
|
|
sandbox.spy(stripeHelper, 'allConfiguredPlans');
|
|
sandbox.stub(Sentry, 'captureException');
|
|
const actual = await stripeHelper.allAbbrevPlans();
|
|
assert(stripeHelper.allConfiguredPlans.calledOnce);
|
|
assert(stripeHelper.allPlans.calledOnce);
|
|
assert(stripeHelper.stripe.plans.list.calledOnce);
|
|
sinon.assert.calledOnceWithExactly(Sentry.captureException, err);
|
|
|
|
assert.deepEqual(
|
|
actual,
|
|
[plan1, plan2]
|
|
.map((p) => ({
|
|
amount: p.amount,
|
|
currency: p.currency,
|
|
interval_count: p.interval_count,
|
|
interval: p.interval,
|
|
plan_id: p.id,
|
|
plan_metadata: p.metadata,
|
|
plan_name: p.nickname || '',
|
|
product_id: p.product.id,
|
|
product_metadata: p.product.metadata,
|
|
product_name: p.product.name,
|
|
active: true,
|
|
configuration: {
|
|
locales: {},
|
|
productSet: undefined,
|
|
stripePriceId: p.id,
|
|
styles: {},
|
|
support: {},
|
|
uiContent: {},
|
|
urls: {},
|
|
},
|
|
}))
|
|
.concat(
|
|
[plan3].map((p) => ({
|
|
amount: p.amount,
|
|
currency: p.currency,
|
|
interval_count: p.interval_count,
|
|
interval: p.interval,
|
|
plan_id: p.id,
|
|
plan_metadata: p.metadata,
|
|
plan_name: p.nickname || '',
|
|
product_id: p.product.id,
|
|
product_metadata: p.product.metadata,
|
|
product_name: p.product.name,
|
|
active: true,
|
|
configuration: null,
|
|
}))
|
|
)
|
|
);
|
|
});
|
|
|
|
it('returns CMS values', async () => {
|
|
const newWebIconURL = 'http://strapi.example/webicon';
|
|
const mockCMSConfigUtil = {
|
|
transformedPurchaseWithCommonContentForPlanId: (planId) => {
|
|
const mockValue =
|
|
PurchaseWithDetailsOfferingContentTransformedFactory();
|
|
mockValue.purchaseDetails.webIcon = newWebIconURL;
|
|
mockValue.purchaseDetails.localizations = [];
|
|
return mockValue;
|
|
},
|
|
};
|
|
const mockProductConfigurationManager = {
|
|
getPurchaseWithDetailsOfferingContentByPlanIds:
|
|
sinon.fake.resolves(mockCMSConfigUtil),
|
|
getSupportedLocale: sinon.fake.resolves('en'),
|
|
};
|
|
Container.set(
|
|
ProductConfigurationManager,
|
|
mockProductConfigurationManager
|
|
);
|
|
const stripeHelper = new StripeHelper(log, mockConfig, mockStatsd);
|
|
const newPlan1 = deepCopy(plan1);
|
|
delete newPlan1.product.metadata['webIconURL'];
|
|
listStripePlans = sandbox
|
|
.stub(stripeHelper.stripe.plans, 'list')
|
|
.returns(asyncIterable([newPlan1, plan2, plan3]));
|
|
sandbox.spy(stripeHelper, 'allPlans');
|
|
sandbox.spy(stripeHelper, 'allConfiguredPlans');
|
|
const sentryScope = {
|
|
setContext: sandbox.stub(),
|
|
setExtra: sandbox.stub(),
|
|
};
|
|
sandbox.stub(Sentry, 'withScope').callsFake((cb) => cb(sentryScope));
|
|
sandbox.stub(sentryModule, 'reportSentryMessage');
|
|
const actual = await stripeHelper.allAbbrevPlans();
|
|
assert(stripeHelper.allConfiguredPlans.calledOnce);
|
|
assert(stripeHelper.allPlans.calledOnce);
|
|
assert(stripeHelper.stripe.plans.list.calledOnce);
|
|
assert.equal(actual[0].plan_metadata['webIconURL'], newWebIconURL);
|
|
assert.equal(actual[0].product_metadata['webIconURL'], newWebIconURL);
|
|
sinon.assert.calledOnce(Sentry.withScope);
|
|
sinon.assert.calledOnce(sentryScope.setContext);
|
|
});
|
|
|
|
it('returns CMS values when flag is enabled', async () => {
|
|
// enable flag
|
|
mockConfig.cms.enabled = true;
|
|
|
|
// set container
|
|
const mockProductConfigurationManager = {
|
|
getPurchaseWithDetailsOfferingContentByPlanIds: sinon.fake.resolves(),
|
|
getSupportedLocale: sinon.fake.resolves('en'),
|
|
};
|
|
Container.set(
|
|
ProductConfigurationManager,
|
|
mockProductConfigurationManager
|
|
);
|
|
|
|
// set stripeHelper
|
|
const stripeHelper = new StripeHelper(log, mockConfig, mockStatsd);
|
|
|
|
// set sandbox and spies
|
|
sandbox
|
|
.stub(stripeHelper.stripe.plans, 'list')
|
|
.returns(asyncIterable([plan1, plan2, plan3]));
|
|
sandbox.spy(stripeHelper, 'allPlans');
|
|
sandbox.spy(stripeHelper, 'allConfiguredPlans');
|
|
|
|
// test method
|
|
await stripeHelper.allAbbrevPlans();
|
|
|
|
// test that flag is enabled and all spies called
|
|
assert.isTrue(mockConfig.cms.enabled);
|
|
assert(stripeHelper.allConfiguredPlans.calledOnce);
|
|
assert(stripeHelper.allPlans.calledOnce);
|
|
assert(stripeHelper.stripe.plans.list.calledOnce);
|
|
});
|
|
});
|
|
|
|
describe('fetchProductById', () => {
|
|
const productId = 'prod_00000000000000';
|
|
const productName = 'Example Product';
|
|
const mockProduct = {
|
|
id: productId,
|
|
name: productName,
|
|
metadata: {
|
|
'product:termsOfServiceURL':
|
|
'https://www.mozilla.org/about/legal/terms/firefox-private-network',
|
|
'product:privacyNoticeURL':
|
|
'https://www.mozilla.org/privacy/firefox-private-network',
|
|
},
|
|
};
|
|
beforeEach(() => {
|
|
sandbox.stub(stripeHelper, 'allProducts').resolves([mockProduct]);
|
|
});
|
|
|
|
it('returns undefined if the product is not in allProducts', async () => {
|
|
const actual = await stripeHelper.fetchProductById('invalidId');
|
|
assert.isUndefined(actual);
|
|
});
|
|
|
|
it('returns a product of the correct id', async () => {
|
|
const actual = await stripeHelper.fetchProductById(productId);
|
|
assert.deepEqual(mockProduct, actual);
|
|
});
|
|
});
|
|
|
|
describe('fetchAllPlans', () => {
|
|
describe('without Firestore configs', () => {
|
|
beforeEach(() => {
|
|
stripeHelper.config.subscriptions.productConfigsFirestore.enabled = false;
|
|
});
|
|
|
|
it('only returns valid plans', async () => {
|
|
const validProductMetadata = plan1.product.metadata;
|
|
|
|
const planMissingProduct = {
|
|
id: 'plan_noprod',
|
|
object: 'plan',
|
|
product: null,
|
|
};
|
|
|
|
const planUnloadedProduct = {
|
|
id: 'plan_stringprod',
|
|
object: 'plan',
|
|
product: 'prod_123',
|
|
};
|
|
|
|
const planDeletedProduct = {
|
|
id: 'plan_deletedprod',
|
|
object: 'plan',
|
|
product: { deleted: true },
|
|
};
|
|
|
|
const planInvalidProductMetadata = {
|
|
id: 'plan_invalidproductmetadata',
|
|
object: 'plan',
|
|
product: {
|
|
metadata: Object.assign({}, validProductMetadata, {
|
|
// Include some invalid whitespace that will be trimmed.
|
|
'product:privacyNoticeDownloadURL': 'https://example.com',
|
|
}),
|
|
},
|
|
};
|
|
|
|
const goodPlan = deepCopy(plan1);
|
|
goodPlan.product = deepCopy(product1);
|
|
goodPlan.product.metadata['product:privacyNoticeURL'] =
|
|
'https://cdn.accounts.firefox.com/legal/privacy\n\n';
|
|
goodPlan.metadata['product:privacyNoticeURL'] =
|
|
'https://cdn.accounts.firefox.com/legal/privacy\n\n';
|
|
const dupeGoodPlan = deepCopy(goodPlan);
|
|
|
|
const planList = [
|
|
planMissingProduct,
|
|
planUnloadedProduct,
|
|
planDeletedProduct,
|
|
planInvalidProductMetadata,
|
|
goodPlan,
|
|
];
|
|
|
|
listStripePlans.restore();
|
|
sandbox.stub(stripeHelper.stripe.plans, 'list').returns(planList);
|
|
|
|
const actual = await stripeHelper.fetchAllPlans();
|
|
|
|
/** Assert that only the "good" plan was returned */
|
|
assert.deepEqual(actual, [goodPlan]);
|
|
|
|
// Assert that the product metadata was trimmed
|
|
assert.equal(
|
|
actual[0].product.metadata['product:privacyNoticeURL'],
|
|
dupeGoodPlan.product.metadata['product:privacyNoticeURL'].trim()
|
|
);
|
|
// Assert that the plan metadata was trimmed
|
|
assert.equal(
|
|
actual[0].metadata['product:privacyNoticeURL'],
|
|
dupeGoodPlan.metadata['product:privacyNoticeURL'].trim()
|
|
);
|
|
|
|
/** Verify the error cases were handled properly */
|
|
assert.equal(stripeHelper.log.error.callCount, 4);
|
|
|
|
/** Plan.product is null */
|
|
assert.equal(
|
|
`fetchAllPlans - Plan "${planMissingProduct.id}" missing Product`,
|
|
stripeHelper.log.error.getCall(0).args[0]
|
|
);
|
|
|
|
/** Plan.product is string */
|
|
assert.equal(
|
|
`fetchAllPlans - Plan "${planUnloadedProduct.id}" failed to load Product`,
|
|
stripeHelper.log.error.getCall(1).args[0]
|
|
);
|
|
|
|
/** Plan.product is DeletedProduct */
|
|
assert.equal(
|
|
`fetchAllPlans - Plan "${planDeletedProduct.id}" associated with Deleted Product`,
|
|
stripeHelper.log.error.getCall(2).args[0]
|
|
);
|
|
|
|
/** Plan.product has invalid metadata */
|
|
assert.isTrue(
|
|
stripeHelper.log.error
|
|
.getCall(3)
|
|
.args[0].includes(
|
|
`fetchAllPlans: ${planInvalidProductMetadata.id} metadata invalid:`
|
|
)
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('allProducts', () => {
|
|
it('pulls a list of products and caches it', async () => {
|
|
assert.lengthOf(await stripeHelper.allProducts(), 3);
|
|
assert(mockRedis.get.calledOnce);
|
|
|
|
assert.lengthOf(await stripeHelper.allProducts(), 3);
|
|
assert(mockRedis.get.calledTwice);
|
|
assert(mockRedis.set.calledOnce);
|
|
|
|
// Assert that a TTL was set for this cache entry
|
|
assert.deepEqual(mockRedis.set.args[0][2], [
|
|
'EX',
|
|
mockConfig.subhub.plansCacheTtlSeconds,
|
|
]);
|
|
|
|
assert(stripeHelper.stripe.products.list.calledOnce);
|
|
|
|
assert.deepEqual(
|
|
await stripeHelper.allProducts(),
|
|
JSON.parse(await mockRedis.get('listStripeProducts'))
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('updateAllProducts', () => {
|
|
it('updates the products in the cache', async () => {
|
|
const newList = ['x'];
|
|
await stripeHelper.updateAllProducts(newList);
|
|
assert.deepEqual(mockRedis.set.args[0][2], [
|
|
'EX',
|
|
mockConfig.subhub.plansCacheTtlSeconds,
|
|
]);
|
|
assert.deepEqual(
|
|
newList,
|
|
JSON.parse(await mockRedis.get('listStripeProducts'))
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('allAbbrevProducts', () => {
|
|
it('returns a AbbrevProduct list based on allProducts', async () => {
|
|
sandbox.spy(stripeHelper, 'allProducts');
|
|
const actual = await stripeHelper.allAbbrevProducts();
|
|
assert(stripeHelper.stripe.products.list.calledOnce);
|
|
assert(stripeHelper.allProducts.calledOnce);
|
|
assert.deepEqual(
|
|
actual,
|
|
[product1, product2, product3].map((p) => ({
|
|
product_id: p.id,
|
|
product_name: p.name,
|
|
product_metadata: p.metadata,
|
|
}))
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('updateSubscriptionAndBackfill', () => {
|
|
it('updates and backfills', async () => {
|
|
const subscription = deepCopy(subscription1);
|
|
const updatedSubscription = deepCopy(subscription1);
|
|
updatedSubscription.cancel_at_period_end = false;
|
|
const newProps = {
|
|
cancel_at_period_end: false,
|
|
};
|
|
sandbox
|
|
.stub(stripeHelper.stripe.subscriptions, 'update')
|
|
.resolves(updatedSubscription);
|
|
|
|
stripeFirestore.insertSubscriptionRecordWithBackfill = sandbox
|
|
.stub()
|
|
.resolves();
|
|
const actual = await stripeHelper.updateSubscriptionAndBackfill(
|
|
subscription,
|
|
newProps
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.subscriptions.update,
|
|
subscription.id,
|
|
newProps
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeFirestore.insertSubscriptionRecordWithBackfill,
|
|
updatedSubscription
|
|
);
|
|
assert.deepEqual(actual, updatedSubscription);
|
|
});
|
|
});
|
|
|
|
describe('changeSubscriptionPlan', () => {
|
|
it('accepts valid upgrade and adds the appropriate metadata', async () => {
|
|
const unixTimestamp = moment().unix();
|
|
const subscription = deepCopy(subscription1);
|
|
subscription.metadata = {
|
|
key: 'value',
|
|
amount: 1000,
|
|
currency: 'usd',
|
|
previous_plan_id: 'plan_123',
|
|
plan_change_date: 12345678,
|
|
};
|
|
|
|
sandbox.stub(moment, 'unix').returns(unixTimestamp);
|
|
sandbox
|
|
.stub(stripeHelper, 'updateSubscriptionAndBackfill')
|
|
.resolves(subscription2);
|
|
|
|
const actual = await stripeHelper.changeSubscriptionPlan(
|
|
subscription,
|
|
'plan_G93mMKnIFCjZek',
|
|
1000,
|
|
'usd'
|
|
);
|
|
|
|
assert.deepEqual(actual, subscription2);
|
|
sinon.assert.calledWithExactly(
|
|
stripeHelper.updateSubscriptionAndBackfill,
|
|
subscription,
|
|
{
|
|
cancel_at_period_end: false,
|
|
items: [
|
|
{
|
|
id: subscription1.items.data[0].id,
|
|
plan: 'plan_G93mMKnIFCjZek',
|
|
},
|
|
],
|
|
proration_behavior: 'always_invoice',
|
|
metadata: {
|
|
key: 'value',
|
|
amount: 1000,
|
|
currency: 'usd',
|
|
previous_plan_id: subscription1.items.data[0].plan.id,
|
|
plan_change_date: unixTimestamp,
|
|
},
|
|
}
|
|
);
|
|
});
|
|
|
|
it('throws an error if the user already upgraded', async () => {
|
|
sandbox
|
|
.stub(stripeHelper, 'updateSubscriptionAndBackfill')
|
|
.resolves(subscription2);
|
|
let thrown;
|
|
try {
|
|
await stripeHelper.changeSubscriptionPlan(
|
|
subscription2,
|
|
'plan_G93mMKnIFCjZek'
|
|
);
|
|
} catch (err) {
|
|
thrown = err;
|
|
}
|
|
assert.equal(thrown.errno, error.ERRNO.SUBSCRIPTION_ALREADY_CHANGED);
|
|
sinon.assert.notCalled(stripeHelper.updateSubscriptionAndBackfill);
|
|
});
|
|
});
|
|
|
|
describe('cancelSubscriptionForCustomer', () => {
|
|
beforeEach(() => {
|
|
sandbox.stub(stripeHelper, 'updateSubscriptionAndBackfill').resolves({});
|
|
});
|
|
|
|
describe('customer owns subscription', () => {
|
|
it('calls subscription update', async () => {
|
|
const existingMetadata = { foo: 'bar' };
|
|
const unixTimestamp = moment().unix();
|
|
const subscription = { ...subscription2, metadata: existingMetadata };
|
|
sandbox.stub(moment, 'unix').returns(unixTimestamp);
|
|
sandbox
|
|
.stub(stripeHelper, 'subscriptionForCustomer')
|
|
.resolves(subscription);
|
|
|
|
await stripeHelper.cancelSubscriptionForCustomer(
|
|
'123',
|
|
'test@example.com',
|
|
subscription2.id
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.updateSubscriptionAndBackfill,
|
|
subscription,
|
|
{
|
|
cancel_at_period_end: true,
|
|
metadata: {
|
|
...existingMetadata,
|
|
cancelled_for_customer_at: unixTimestamp,
|
|
},
|
|
}
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('customer does not own the subscription', () => {
|
|
it('throws an error', async () => {
|
|
sandbox.stub(stripeHelper, 'subscriptionForCustomer').resolves();
|
|
return stripeHelper
|
|
.cancelSubscriptionForCustomer(
|
|
'123',
|
|
'test@example.com',
|
|
subscription2.id
|
|
)
|
|
.then(
|
|
() => Promise.reject(new Error('Method expected to reject')),
|
|
(err) => {
|
|
assert.equal(err.errno, error.ERRNO.UNKNOWN_SUBSCRIPTION);
|
|
sinon.assert.notCalled(
|
|
stripeHelper.updateSubscriptionAndBackfill
|
|
);
|
|
}
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('reactivateSubscriptionForCustomer', () => {
|
|
describe('customer owns subscription', () => {
|
|
describe('the intial subscription has a active status', () => {
|
|
it('returns the updated subscription', async () => {
|
|
const existingMetadata = { foo: 'bar' };
|
|
const expected = {
|
|
...deepCopy(subscription2),
|
|
metadata: existingMetadata,
|
|
};
|
|
sandbox
|
|
.stub(stripeHelper, 'updateSubscriptionAndBackfill')
|
|
.resolves(expected);
|
|
sandbox
|
|
.stub(stripeHelper, 'subscriptionForCustomer')
|
|
.resolves(expected);
|
|
|
|
const actual = await stripeHelper.reactivateSubscriptionForCustomer(
|
|
'123',
|
|
'test@example.com',
|
|
expected.id
|
|
);
|
|
|
|
assert.deepEqual(actual, expected);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.updateSubscriptionAndBackfill,
|
|
expected,
|
|
{
|
|
cancel_at_period_end: false,
|
|
metadata: {
|
|
...existingMetadata,
|
|
cancelled_for_customer_at: '',
|
|
},
|
|
}
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('the initial subscription has a trialing status', () => {
|
|
it('returns the updated subscription', async () => {
|
|
const expected = deepCopy(subscription2);
|
|
expected.status = 'trialing';
|
|
|
|
sandbox
|
|
.stub(stripeHelper, 'subscriptionForCustomer')
|
|
.resolves(expected);
|
|
sandbox
|
|
.stub(stripeHelper, 'updateSubscriptionAndBackfill')
|
|
.resolves(expected);
|
|
|
|
const actual = await stripeHelper.reactivateSubscriptionForCustomer(
|
|
'123',
|
|
'test@example.com',
|
|
expected.id
|
|
);
|
|
|
|
assert.deepEqual(actual, expected);
|
|
sinon.assert.calledWithExactly(
|
|
stripeHelper.updateSubscriptionAndBackfill,
|
|
expected,
|
|
{
|
|
cancel_at_period_end: false,
|
|
metadata: {
|
|
cancelled_for_customer_at: '',
|
|
},
|
|
}
|
|
);
|
|
});
|
|
});
|
|
describe('the updated subscription is not in a active||trialing state', () => {
|
|
it('throws an error', () => {
|
|
const expected = deepCopy(subscription2);
|
|
expected.status = 'unpaid';
|
|
|
|
sandbox
|
|
.stub(stripeHelper, 'subscriptionForCustomer')
|
|
.resolves(expected);
|
|
sandbox
|
|
.stub(stripeHelper, 'updateSubscriptionAndBackfill')
|
|
.resolves(expected);
|
|
|
|
return stripeHelper
|
|
.reactivateSubscriptionForCustomer(
|
|
'123',
|
|
'test@example.com',
|
|
expected.id
|
|
)
|
|
.then(
|
|
() => Promise.reject(new Error('Method expected to reject')),
|
|
(err) => {
|
|
assert.equal(err.errno, error.ERRNO.BACKEND_SERVICE_FAILURE);
|
|
sinon.assert.notCalled(
|
|
stripeHelper.updateSubscriptionAndBackfill
|
|
);
|
|
}
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('customer does not own the subscription', () => {
|
|
it('throws an error', async () => {
|
|
sandbox.stub(stripeHelper, 'subscriptionForCustomer').resolves();
|
|
sandbox.stub(stripeHelper, 'updateSubscriptionAndBackfill').resolves();
|
|
return stripeHelper
|
|
.reactivateSubscriptionForCustomer(
|
|
'123',
|
|
'test@example.com',
|
|
subscription2.id
|
|
)
|
|
.then(
|
|
() => Promise.reject(new Error('Method expected to reject')),
|
|
(err) => {
|
|
assert.equal(err.errno, error.ERRNO.UNKNOWN_SUBSCRIPTION);
|
|
sinon.assert.notCalled(
|
|
stripeHelper.updateSubscriptionAndBackfill
|
|
);
|
|
}
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('addTaxIdToCustomer', () => {
|
|
it('updates stripe if theres a tax id for the currency', async () => {
|
|
const customer = deepCopy(customer1);
|
|
stripeHelper.taxIds = { EUR: 'EU1234' };
|
|
sandbox.stub(stripeHelper.stripe.customers, 'update').resolves(customer);
|
|
stripeFirestore.insertCustomerRecordWithBackfill = sandbox
|
|
.stub()
|
|
.resolves({});
|
|
await stripeHelper.addTaxIdToCustomer(customer, 'eur');
|
|
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.customers.update,
|
|
customer.id,
|
|
{
|
|
invoice_settings: {
|
|
custom_fields: [{ name: MOZILLA_TAX_ID, value: 'EU1234' }],
|
|
},
|
|
}
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeFirestore.insertCustomerRecordWithBackfill,
|
|
customer.metadata.userid,
|
|
customer
|
|
);
|
|
});
|
|
|
|
it('updates stripe if theres a tax id on the customer', async () => {
|
|
const customer = deepCopy(customer1);
|
|
stripeHelper.taxIds = { EUR: 'EU1234' };
|
|
customer.currency = 'eur';
|
|
sandbox.stub(stripeHelper.stripe.customers, 'update').resolves(customer);
|
|
stripeFirestore.insertCustomerRecordWithBackfill = sandbox
|
|
.stub()
|
|
.resolves({});
|
|
await stripeHelper.addTaxIdToCustomer(customer);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.customers.update,
|
|
customer.id,
|
|
{
|
|
invoice_settings: {
|
|
custom_fields: [{ name: MOZILLA_TAX_ID, value: 'EU1234' }],
|
|
},
|
|
}
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeFirestore.insertCustomerRecordWithBackfill,
|
|
customer.metadata.userid,
|
|
customer
|
|
);
|
|
});
|
|
|
|
it('does not update stripe with no tax id found', async () => {
|
|
const customer = deepCopy(customer1);
|
|
stripeHelper.taxIds = { EUR: 'EU1234' };
|
|
sandbox.stub(stripeHelper.stripe.customers, 'update').resolves({});
|
|
|
|
await stripeHelper.addTaxIdToCustomer(customer, 'usd');
|
|
|
|
sinon.assert.notCalled(stripeHelper.stripe.customers.update);
|
|
});
|
|
});
|
|
|
|
describe('customerTaxId', () => {
|
|
it('returns a custom field if present with the tax id', () => {
|
|
const customer = deepCopy(customer1);
|
|
const field = { name: MOZILLA_TAX_ID, value: 'EU1234' };
|
|
customer.invoice_settings = {
|
|
custom_fields: [field],
|
|
};
|
|
const result = stripeHelper.customerTaxId(customer);
|
|
assert.equal(result, field);
|
|
});
|
|
|
|
it('returns nothing if a mozilla tax field is not present', () => {
|
|
const customer = deepCopy(customer1);
|
|
const result = stripeHelper.customerTaxId(customer);
|
|
assert.isUndefined(result);
|
|
});
|
|
});
|
|
|
|
describe('fetchCustomer', () => {
|
|
it('fetches an existing customer', async () => {
|
|
sandbox.stub(stripeHelper, 'expandResource').returns(deepCopy(customer1));
|
|
const result = await stripeHelper.fetchCustomer(existingCustomer.uid);
|
|
assert.deepEqual(result, customer1);
|
|
});
|
|
|
|
it('fetches a customer and refreshes the cache if needed', async () => {
|
|
const customer = deepCopy(customer1);
|
|
customer.currency = null;
|
|
const customerSecond = deepCopy(customer1);
|
|
const expandStub = sandbox.stub(stripeHelper, 'expandResource');
|
|
stripeHelper.stripeFirestore = {
|
|
fetchAndInsertCustomer: sandbox.stub().resolves({}),
|
|
};
|
|
expandStub.onFirstCall().resolves(customer);
|
|
expandStub.onSecondCall().resolves(customerSecond);
|
|
const result = await stripeHelper.fetchCustomer(existingCustomer.uid);
|
|
assert.deepEqual(result, customerSecond);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripeFirestore.fetchAndInsertCustomer,
|
|
customer.id
|
|
);
|
|
sinon.assert.calledTwice(expandStub);
|
|
});
|
|
|
|
it('throws if the customer record has a fxa id mismatch', async () => {
|
|
sandbox.stub(stripeHelper, 'expandResource').returns(newCustomer);
|
|
let thrown;
|
|
try {
|
|
await stripeHelper.fetchCustomer(existingCustomer.uid);
|
|
assert.fail('Error should have been thrown.');
|
|
} catch (err) {
|
|
thrown = err;
|
|
}
|
|
assert.instanceOf(thrown, Error);
|
|
assert.equal(thrown.message, 'System unavailable, try again soon');
|
|
assert.equal(
|
|
thrown.jse_cause?.message,
|
|
'Stripe Customer: cus_new has mismatched uid in metadata.'
|
|
);
|
|
});
|
|
|
|
it('returns void if no there is no record of the user-customer relationship in db', async () => {
|
|
assert.isUndefined(
|
|
await stripeHelper.fetchCustomer(
|
|
'013b3c2f6c7b41e0991e6707fdbb62b3',
|
|
'test@example.com'
|
|
)
|
|
);
|
|
});
|
|
|
|
it('returns void if the stripe customer is deleted and updates db', async () => {
|
|
sandbox.stub(stripeHelper, 'expandResource').returns(deletedCustomer);
|
|
assert.isDefined(await getAccountCustomerByUid(existingCustomer.uid));
|
|
await stripeHelper.fetchCustomer(
|
|
existingCustomer.uid,
|
|
'test@example.com'
|
|
);
|
|
|
|
assert.isTrue(stripeHelper.expandResource.calledOnce);
|
|
assert.isUndefined(await getAccountCustomerByUid(existingCustomer.uid));
|
|
|
|
// reset for tests:
|
|
existingCustomer = await createAccountCustomer(existingUid, customer1.id);
|
|
});
|
|
|
|
it('expands the tax information if present', async () => {
|
|
const customer = deepCopy(customer1);
|
|
const customerSecond = deepCopy(customer1);
|
|
customerSecond.tax = {
|
|
location: { country: 'US', state: 'CA', source: 'billing_address' },
|
|
ip_address: null,
|
|
automatic_tax: 'supported',
|
|
};
|
|
sandbox.stub(stripeHelper, 'expandResource').returns(customer);
|
|
sandbox
|
|
.stub(stripeHelper.stripe.customers, 'retrieve')
|
|
.resolves(customerSecond);
|
|
const result = await stripeHelper.fetchCustomer(existingCustomer.uid, [
|
|
'tax',
|
|
]);
|
|
const customerResult = {
|
|
...customer,
|
|
tax: customerSecond.tax,
|
|
};
|
|
assert.deepEqual(result, customerResult);
|
|
});
|
|
});
|
|
|
|
describe('fetchInvoicesForActiveSubscriptions', () => {
|
|
it('returns empty array if customer has no active subscriptions', async () => {
|
|
sandbox
|
|
.stub(stripeHelper.stripe.subscriptions, 'list')
|
|
.resolves({ data: [] });
|
|
const result = await stripeHelper.fetchInvoicesForActiveSubscriptions(
|
|
existingUid,
|
|
'paid'
|
|
);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
it('fetches invoices no older than earliestCreatedDate', async () => {
|
|
sandbox.stub(stripeHelper.stripe.subscriptions, 'list').resolves({
|
|
data: [
|
|
{
|
|
id: 'idNull',
|
|
},
|
|
],
|
|
});
|
|
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 expectedString = {
|
|
id: 'idString',
|
|
subscription: 'idSub',
|
|
};
|
|
sandbox.stub(stripeHelper.stripe.subscriptions, 'list').resolves({
|
|
data: [
|
|
{
|
|
id: 'idNull',
|
|
},
|
|
{
|
|
id: 'subIdExpanded',
|
|
},
|
|
{
|
|
id: 'idSub',
|
|
},
|
|
],
|
|
});
|
|
sandbox.stub(stripeHelper.stripe.invoices, 'list').resolves({
|
|
data: [
|
|
{
|
|
id: 'idNull',
|
|
subscription: null,
|
|
},
|
|
{
|
|
...expectedString,
|
|
},
|
|
],
|
|
});
|
|
const result = await stripeHelper.fetchInvoicesForActiveSubscriptions(
|
|
existingUid,
|
|
'paid'
|
|
);
|
|
assert.deepEqual(result, [expectedString]);
|
|
});
|
|
});
|
|
|
|
describe('removeCustomer', () => {
|
|
let stripeCustomerDel;
|
|
|
|
beforeEach(() => {
|
|
stripeCustomerDel = sandbox
|
|
.stub(stripeHelper.stripe.customers, 'del')
|
|
.resolves();
|
|
});
|
|
|
|
describe('when customer is found', () => {
|
|
it('deletes customer in Stripe, removes AccountCustomer and cached records, detach payment method', async () => {
|
|
const uid = chance.guid({ version: 4 }).replace(/-/g, '');
|
|
const customerId = 'cus_1234456sdf';
|
|
sandbox.stub(stripeHelper, 'fetchCustomer').resolves({
|
|
invoice_settings: { default_payment_method: { id: 'pm9001' } },
|
|
});
|
|
sandbox.stub(stripeHelper.stripe.paymentMethods, 'detach').resolves();
|
|
const testAccount = await createAccountCustomer(uid, customerId);
|
|
await stripeHelper.removeCustomer(testAccount.uid);
|
|
assert(stripeCustomerDel.calledOnce);
|
|
assert((await getAccountCustomerByUid(uid)) === undefined);
|
|
sinon.assert.calledOnceWithExactly(stripeHelper.fetchCustomer, uid, [
|
|
'invoice_settings.default_payment_method',
|
|
]);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.paymentMethods.detach,
|
|
'pm9001'
|
|
);
|
|
});
|
|
|
|
it('deletes everything and updates metadata', async () => {
|
|
const uid = chance.guid({ version: 4 }).replace(/-/g, '');
|
|
const customerId = 'cus_1234456sdf';
|
|
sandbox.stub(stripeHelper, 'fetchCustomer').resolves({
|
|
invoice_settings: { default_payment_method: { id: 'pm9001' } },
|
|
subscriptions: {
|
|
data: [{ id: 'sub_123', status: 'active' }],
|
|
},
|
|
});
|
|
sandbox.stub(stripeHelper.stripe.paymentMethods, 'detach').resolves();
|
|
sandbox.stub(stripeHelper.stripe.subscriptions, 'update').resolves();
|
|
const testAccount = await createAccountCustomer(uid, customerId);
|
|
await stripeHelper.removeCustomer(testAccount.uid, {
|
|
cancellation_reason: 'test',
|
|
});
|
|
assert(stripeCustomerDel.calledOnce);
|
|
assert((await getAccountCustomerByUid(uid)) === undefined);
|
|
sinon.assert.calledOnceWithExactly(stripeHelper.fetchCustomer, uid, [
|
|
'invoice_settings.default_payment_method',
|
|
]);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.subscriptions.update,
|
|
'sub_123',
|
|
{
|
|
metadata: {
|
|
cancellation_reason: 'test',
|
|
},
|
|
}
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.paymentMethods.detach,
|
|
'pm9001'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('when customer is not found', () => {
|
|
it('does not throw any errors', async () => {
|
|
const uid = chance.guid({ version: 4 }).replace(/-/g, '');
|
|
await stripeHelper.removeCustomer(uid);
|
|
assert(stripeCustomerDel.notCalled);
|
|
});
|
|
});
|
|
|
|
describe('when accountCustomer record is not deleted', () => {
|
|
it('logs an error', async () => {
|
|
const uid = chance.guid({ version: 4 }).replace(/-/g, '');
|
|
const customerId = 'cus_1234456sdf';
|
|
const testAccount = await createAccountCustomer(uid, customerId);
|
|
|
|
sandbox.stub(stripeHelper, 'fetchCustomer').resolves({
|
|
invoice_settings: { default_payment_method: { id: 'pm9001' } },
|
|
});
|
|
sandbox.stub(stripeHelper.stripe.paymentMethods, 'detach').resolves();
|
|
const deleteCustomer = sandbox
|
|
.stub(dbStub, 'deleteAccountCustomer')
|
|
.returns(0);
|
|
|
|
await stripeHelper.removeCustomer(testAccount.uid);
|
|
|
|
assert(deleteCustomer.calledOnce);
|
|
assert(stripeHelper.log.error.calledOnce);
|
|
assert.equal(
|
|
`StripeHelper.removeCustomer failed to remove AccountCustomer record for uid ${uid}`,
|
|
stripeHelper.log.error.getCall(0).args[0]
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('findActiveSubscriptionsByPlanId', () => {
|
|
const argsHelper = [
|
|
'plan_123',
|
|
{
|
|
gte: 123,
|
|
lt: 456,
|
|
},
|
|
25,
|
|
];
|
|
const argsStripe = {
|
|
price: 'plan_123',
|
|
current_period_end: {
|
|
gte: 123,
|
|
lt: 456,
|
|
},
|
|
limit: 25,
|
|
expand: ['data.customer'],
|
|
};
|
|
it('calls Stripe with the correct arguments and iteratively returns active subscriptions', async () => {
|
|
const subscription3 = deepCopy(subscription2);
|
|
subscription3.status = 'cancelled';
|
|
async function* genSubscription() {
|
|
yield subscription1;
|
|
yield subscription2;
|
|
yield subscription3;
|
|
}
|
|
sandbox
|
|
.stub(stripeHelper.stripe.subscriptions, 'list')
|
|
.returns(genSubscription());
|
|
const actual = [];
|
|
for await (const item of stripeHelper.findActiveSubscriptionsByPlanId(
|
|
...argsHelper
|
|
)) {
|
|
actual.push(item);
|
|
}
|
|
assert.deepEqual(actual, [subscription1, subscription2]);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.subscriptions.list,
|
|
argsStripe
|
|
);
|
|
});
|
|
it('does not return an active subscription marked as cancel_at_period_end', async () => {
|
|
const subscription3 = deepCopy(subscription2);
|
|
subscription3.cancel_at_period_end = 456;
|
|
async function* genSubscription() {
|
|
yield subscription1;
|
|
yield subscription2;
|
|
yield subscription3;
|
|
}
|
|
sandbox
|
|
.stub(stripeHelper.stripe.subscriptions, 'list')
|
|
.returns(genSubscription());
|
|
const actual = [];
|
|
for await (const item of stripeHelper.findActiveSubscriptionsByPlanId(
|
|
...argsHelper
|
|
)) {
|
|
actual.push(item);
|
|
}
|
|
assert.deepEqual(actual, [subscription1, subscription2]);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.subscriptions.list,
|
|
argsStripe
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('findAbbrevPlanById', () => {
|
|
it('finds a valid plan', async () => {
|
|
const planId = 'plan_G93lTs8hfK7NNG';
|
|
const result = await stripeHelper.findAbbrevPlanById(planId);
|
|
assert(stripeHelper.stripe.plans.list.calledOnce);
|
|
assert(result.plan_id, planId);
|
|
});
|
|
|
|
it('throws on invalid plan id', async () => {
|
|
const planId = 'plan_9';
|
|
let thrown;
|
|
try {
|
|
await stripeHelper.findAbbrevPlanById(planId);
|
|
} catch (err) {
|
|
thrown = err;
|
|
}
|
|
assert(stripeHelper.stripe.plans.list.calledOnce);
|
|
assert.instanceOf(thrown, Error);
|
|
assert.equal(thrown.errno, error.ERRNO.UNKNOWN_SUBSCRIPTION_PLAN);
|
|
});
|
|
});
|
|
|
|
describe('paidInvoice', () => {
|
|
describe("when Invoice status is 'paid'", () => {
|
|
describe("Payment Intent Status is 'succeeded'", () => {
|
|
const invoice = deepCopy(paidInvoice);
|
|
invoice.payment_intent = successfulPaymentIntent;
|
|
it('should return true', () => {
|
|
assert.isTrue(stripeHelper.paidInvoice(invoice));
|
|
});
|
|
});
|
|
|
|
describe("Payment Intent Status is NOT 'succeeded'", () => {
|
|
const invoice = deepCopy(paidInvoice);
|
|
invoice.payment_intent = unsuccessfulPaymentIntent;
|
|
it('should return false', () => {
|
|
assert.isFalse(stripeHelper.paidInvoice(invoice));
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("when Invoice status is NOT 'paid'", () => {
|
|
describe("Payment Intent Status is 'succeeded'", () => {
|
|
const invoice = deepCopy(unpaidInvoice);
|
|
invoice.payment_intent = successfulPaymentIntent;
|
|
it('should return false', () => {
|
|
assert.isFalse(stripeHelper.paidInvoice(invoice));
|
|
});
|
|
});
|
|
|
|
describe("Payment Intent Status is NOT 'succeeded'", () => {
|
|
const invoice = deepCopy(unpaidInvoice);
|
|
invoice.payment_intent = unsuccessfulPaymentIntent;
|
|
it('should return false', () => {
|
|
assert.isFalse(stripeHelper.paidInvoice(invoice));
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('payInvoice', () => {
|
|
describe('invoice is created', () => {
|
|
it('returns the invoice if marked as paid', async () => {
|
|
const expected = deepCopy(paidInvoice);
|
|
expected.payment_intent = successfulPaymentIntent;
|
|
sandbox.stub(stripeHelper.stripe.invoices, 'pay').resolves(expected);
|
|
|
|
const actual = await stripeHelper.payInvoice(paidInvoice.id);
|
|
|
|
assert.deepEqual(expected, actual);
|
|
});
|
|
|
|
it('throws an error if invoice is not marked as paid', async () => {
|
|
const expected = deepCopy(paidInvoice);
|
|
expected.payment_intent = unsuccessfulPaymentIntent;
|
|
sandbox.stub(stripeHelper.stripe.invoices, 'pay').resolves(expected);
|
|
|
|
return stripeHelper.payInvoice(paidInvoice.id).then(
|
|
() => Promise.reject(new Error('Method expected to reject')),
|
|
(err) => {
|
|
assert.equal(err.errno, error.ERRNO.PAYMENT_FAILED);
|
|
assert.equal(err.message, 'Payment method failed');
|
|
}
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('invoice is not created', () => {
|
|
it('returns payment failed error if card_declined is reason', () => {
|
|
const cardDeclinedError = new stripeError.StripeCardError();
|
|
cardDeclinedError.code = 'card_declined';
|
|
sandbox
|
|
.stub(stripeHelper.stripe.invoices, 'pay')
|
|
.rejects(cardDeclinedError);
|
|
|
|
return stripeHelper.payInvoice(paidInvoice.id).then(
|
|
() => Promise.reject(new Error('Method expected to reject')),
|
|
(err) => {
|
|
assert.equal(err.errno, error.ERRNO.PAYMENT_FAILED);
|
|
assert.equal(err.message, 'Payment method failed');
|
|
}
|
|
);
|
|
});
|
|
|
|
it('throws caught Stripe error if not card_declined', () => {
|
|
const apiError = new stripeError.StripeAPIError();
|
|
apiError.code = 'api_error';
|
|
sandbox.stub(stripeHelper.stripe.invoices, 'pay').rejects(apiError);
|
|
|
|
return stripeHelper.payInvoice(paidInvoice.id).then(
|
|
() => Promise.reject(new Error('Method expected to reject')),
|
|
(err) => {
|
|
assert.equal(err, apiError);
|
|
}
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('fetchPaymentIntentFromInvoice', () => {
|
|
beforeEach(() => {
|
|
sandbox
|
|
.stub(stripeHelper.stripe.paymentIntents, 'retrieve')
|
|
.resolves(unsuccessfulPaymentIntent);
|
|
});
|
|
|
|
describe('when the payment_intent is loaded', () => {
|
|
it('returns the payment_intent from the Invoice object', async () => {
|
|
const invoice = deepCopy(unpaidInvoice);
|
|
invoice.payment_intent = unsuccessfulPaymentIntent;
|
|
|
|
const expected = invoice.payment_intent;
|
|
const actual =
|
|
await stripeHelper.fetchPaymentIntentFromInvoice(invoice);
|
|
|
|
assert.deepEqual(actual, expected);
|
|
assert.isTrue(stripeHelper.stripe.paymentIntents.retrieve.notCalled);
|
|
});
|
|
});
|
|
|
|
describe('when the payment_intnet is not loaded', () => {
|
|
it('fetches the payment_intent from Stripe', async () => {
|
|
const invoice = deepCopy(unpaidInvoice);
|
|
const expected = unsuccessfulPaymentIntent;
|
|
const actual =
|
|
await stripeHelper.fetchPaymentIntentFromInvoice(invoice);
|
|
|
|
assert.deepEqual(actual, expected);
|
|
assert.isTrue(stripeHelper.stripe.paymentIntents.retrieve.calledOnce);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('constructWebhookEvent', () => {
|
|
it('calls stripe.webhooks.construct event', () => {
|
|
const expected = 'the expected result';
|
|
sandbox
|
|
.stub(stripeHelper.stripe.webhooks, 'constructEvent')
|
|
.returns(expected);
|
|
|
|
const actual = stripeHelper.constructWebhookEvent([], 'signature');
|
|
assert.equal(actual, expected);
|
|
});
|
|
});
|
|
|
|
describe('subscriptionsToResponse', () => {
|
|
const productName = 'FPN Tier 1';
|
|
const productId = 'prod_123';
|
|
|
|
describe('when is one subscription', () => {
|
|
describe('when there is a subscription with an incomplete status', () => {
|
|
it('should not include the subscription', async () => {
|
|
const subscription = deepCopy(subscription1);
|
|
subscription.status = 'incomplete';
|
|
|
|
const input = {
|
|
data: [subscription],
|
|
};
|
|
|
|
const expected = [];
|
|
const actual = await stripeHelper.subscriptionsToResponse(input);
|
|
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
});
|
|
|
|
describe('when there is a subscription with an incomplete_expired status', () => {
|
|
it('should not include the subscription', async () => {
|
|
const subscription = deepCopy(subscription1);
|
|
subscription.status = 'incomplete_expired';
|
|
|
|
const input = {
|
|
data: [subscription],
|
|
};
|
|
|
|
const expected = [];
|
|
const actual = await stripeHelper.subscriptionsToResponse(input);
|
|
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
});
|
|
|
|
describe('when there is a charge-automatically payment that is past due', () => {
|
|
const failedChargeCopy = deepCopy(failedCharge);
|
|
const subscription = deepCopy(pastDueSubscription);
|
|
const invoice = deepCopy(unpaidInvoice);
|
|
const latestInvoiceItems =
|
|
stripeInvoiceToLatestInvoiceItemsDTO(invoice);
|
|
|
|
const expected = [
|
|
{
|
|
_subscription_type: MozillaSubscriptionTypes.WEB,
|
|
created: pastDueSubscription.created,
|
|
current_period_end: pastDueSubscription.current_period_end,
|
|
current_period_start: pastDueSubscription.current_period_start,
|
|
cancel_at_period_end: false,
|
|
end_at: null,
|
|
plan_id: pastDueSubscription.plan.id,
|
|
product_id: product1.id,
|
|
product_name: productName,
|
|
status: 'past_due',
|
|
subscription_id: pastDueSubscription.id,
|
|
failure_code: failedChargeCopy.failure_code,
|
|
failure_message: failedChargeCopy.failure_message,
|
|
latest_invoice: invoice.number,
|
|
latest_invoice_items: latestInvoiceItems,
|
|
promotion_amount_off: null,
|
|
promotion_code: null,
|
|
promotion_duration: null,
|
|
promotion_end: null,
|
|
promotion_name: null,
|
|
promotion_percent_off: null,
|
|
},
|
|
];
|
|
|
|
beforeEach(() => {
|
|
sandbox
|
|
.stub(stripeHelper.stripe.charges, 'retrieve')
|
|
.resolves(failedChargeCopy);
|
|
});
|
|
|
|
describe('when the charge is already expanded', () => {
|
|
it('includes charge failure information with the subscription data', async () => {
|
|
sandbox
|
|
.stub(stripeHelper, 'expandResource')
|
|
.resolves({ id: productId, name: productName });
|
|
invoice.charge = failedChargeCopy;
|
|
subscription.latest_invoice = invoice;
|
|
subscription.plan.product = product1.id;
|
|
|
|
const input = { data: [subscription] };
|
|
|
|
const actual = await stripeHelper.subscriptionsToResponse(input);
|
|
|
|
assert.deepEqual(actual, expected);
|
|
assert.isTrue(stripeHelper.stripe.charges.retrieve.notCalled);
|
|
assert.isDefined(actual[0].failure_code);
|
|
assert.isDefined(actual[0].failure_message);
|
|
});
|
|
});
|
|
|
|
describe('when the charge is not expanded', () => {
|
|
it('expands the charge and includes charge failure information with the subscription data', async () => {
|
|
sandbox
|
|
.stub(stripeHelper, 'expandResource')
|
|
.resolves({ id: productId, name: productName });
|
|
|
|
invoice.charge = 'ch_123';
|
|
subscription.latest_invoice = invoice;
|
|
|
|
const input = { data: [subscription] };
|
|
|
|
const actual = await stripeHelper.subscriptionsToResponse(input);
|
|
|
|
assert.deepEqual(actual, expected);
|
|
assert.isTrue(stripeHelper.stripe.charges.retrieve.calledOnce);
|
|
assert.isDefined(actual[0].failure_code);
|
|
assert.isDefined(actual[0].failure_message);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('when the subscription is not past_due, incomplete, or incomplete_expired', () => {
|
|
const latestInvoiceItems =
|
|
stripeInvoiceToLatestInvoiceItemsDTO(paidInvoice);
|
|
describe('when the subscription is active', () => {
|
|
it('formats the subscription', async () => {
|
|
const input = { data: [subscription1] };
|
|
sandbox
|
|
.stub(stripeHelper.stripe.invoices, 'retrieve')
|
|
.resolves(paidInvoice);
|
|
const callback = sandbox.stub(stripeHelper, 'expandResource');
|
|
callback.onCall(0).resolves(paidInvoice);
|
|
callback.onCall(1).resolves({ id: productId, name: productName });
|
|
const expected = [
|
|
{
|
|
_subscription_type: MozillaSubscriptionTypes.WEB,
|
|
created: subscription1.created,
|
|
current_period_end: subscription1.current_period_end,
|
|
current_period_start: subscription1.current_period_start,
|
|
cancel_at_period_end: false,
|
|
end_at: null,
|
|
plan_id: subscription1.plan.id,
|
|
product_id: product1.id,
|
|
product_name: productName,
|
|
status: 'active',
|
|
subscription_id: subscription1.id,
|
|
failure_code: undefined,
|
|
failure_message: undefined,
|
|
latest_invoice: paidInvoice.number,
|
|
latest_invoice_items: latestInvoiceItems,
|
|
promotion_amount_off: null,
|
|
promotion_code: null,
|
|
promotion_duration: null,
|
|
promotion_end: null,
|
|
promotion_name: null,
|
|
promotion_percent_off: null,
|
|
},
|
|
];
|
|
|
|
const actual = await stripeHelper.subscriptionsToResponse(input);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('formats the subscription, when total_excluding_tax and subtotal_excluding_tax are not set', async () => {
|
|
const missingExcludingTaxPaidInvoice = deepCopy(paidInvoice);
|
|
delete missingExcludingTaxPaidInvoice.total_excluding_tax;
|
|
delete missingExcludingTaxPaidInvoice.subtotal_excluding_tax;
|
|
const latestInvoiceItems = stripeInvoiceToLatestInvoiceItemsDTO(
|
|
missingExcludingTaxPaidInvoice
|
|
);
|
|
const input = { data: [subscription1] };
|
|
sandbox
|
|
.stub(stripeHelper.stripe.invoices, 'retrieve')
|
|
.resolves(missingExcludingTaxPaidInvoice);
|
|
const callback = sandbox.stub(stripeHelper, 'expandResource');
|
|
callback.onCall(0).resolves(missingExcludingTaxPaidInvoice);
|
|
callback.onCall(1).resolves({ id: productId, name: productName });
|
|
const expected = [
|
|
{
|
|
_subscription_type: MozillaSubscriptionTypes.WEB,
|
|
created: subscription1.created,
|
|
current_period_end: subscription1.current_period_end,
|
|
current_period_start: subscription1.current_period_start,
|
|
cancel_at_period_end: false,
|
|
end_at: null,
|
|
plan_id: subscription1.plan.id,
|
|
product_id: product1.id,
|
|
product_name: productName,
|
|
status: 'active',
|
|
subscription_id: subscription1.id,
|
|
failure_code: undefined,
|
|
failure_message: undefined,
|
|
latest_invoice: paidInvoice.number,
|
|
latest_invoice_items: latestInvoiceItems,
|
|
promotion_amount_off: null,
|
|
promotion_code: null,
|
|
promotion_duration: null,
|
|
promotion_end: null,
|
|
promotion_name: null,
|
|
promotion_percent_off: null,
|
|
},
|
|
];
|
|
|
|
const actual = await stripeHelper.subscriptionsToResponse(input);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
});
|
|
|
|
describe('when the subscription is set to cancel', () => {
|
|
it('sets cancel_at_period_end to `true` and end_at to `null`', async () => {
|
|
const subscription = deepCopy(subscription1);
|
|
subscription.cancel_at_period_end = true;
|
|
const input = { data: [subscription] };
|
|
const callback = sandbox.stub(stripeHelper, 'expandResource');
|
|
callback.onCall(0).resolves(paidInvoice);
|
|
callback.onCall(1).resolves({ id: productId, name: productName });
|
|
const expected = [
|
|
{
|
|
_subscription_type: MozillaSubscriptionTypes.WEB,
|
|
created: subscription.created,
|
|
current_period_end: subscription.current_period_end,
|
|
current_period_start: subscription.current_period_start,
|
|
cancel_at_period_end: true,
|
|
end_at: null,
|
|
plan_id: subscription.plan.id,
|
|
product_id: product1.id,
|
|
product_name: productName,
|
|
status: 'active',
|
|
subscription_id: subscription.id,
|
|
failure_code: undefined,
|
|
failure_message: undefined,
|
|
latest_invoice: paidInvoice.number,
|
|
latest_invoice_items: latestInvoiceItems,
|
|
promotion_amount_off: null,
|
|
promotion_code: null,
|
|
promotion_duration: null,
|
|
promotion_end: null,
|
|
promotion_name: null,
|
|
promotion_percent_off: null,
|
|
},
|
|
];
|
|
|
|
const actual = await stripeHelper.subscriptionsToResponse(input);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
});
|
|
|
|
describe('when the subscription has already ended', () => {
|
|
it('set end_at to the last active day of the subscription', async () => {
|
|
const sub = deepCopy(cancelledSubscription);
|
|
sub.plan.product = product1.id;
|
|
const input = { data: [sub] };
|
|
sandbox
|
|
.stub(stripeHelper.stripe.invoices, 'retrieve')
|
|
.resolves(paidInvoice);
|
|
const callback = sandbox.stub(stripeHelper, 'expandResource');
|
|
callback.onCall(0).resolves(paidInvoice);
|
|
callback.onCall(1).resolves({ id: productId, name: productName });
|
|
const expected = [
|
|
{
|
|
_subscription_type: MozillaSubscriptionTypes.WEB,
|
|
created: cancelledSubscription.created,
|
|
current_period_end: cancelledSubscription.current_period_end,
|
|
current_period_start:
|
|
cancelledSubscription.current_period_start,
|
|
cancel_at_period_end: false,
|
|
end_at: cancelledSubscription.ended_at,
|
|
plan_id: cancelledSubscription.plan.id,
|
|
product_id: product1.id,
|
|
product_name: product1.name,
|
|
status: 'canceled',
|
|
subscription_id: cancelledSubscription.id,
|
|
failure_code: undefined,
|
|
failure_message: undefined,
|
|
latest_invoice: paidInvoice.number,
|
|
latest_invoice_items: latestInvoiceItems,
|
|
promotion_amount_off: null,
|
|
promotion_code: null,
|
|
promotion_duration: null,
|
|
promotion_end: null,
|
|
promotion_name: null,
|
|
promotion_percent_off: null,
|
|
},
|
|
];
|
|
const actual = await stripeHelper.subscriptionsToResponse(input);
|
|
assert.deepEqual(actual, expected);
|
|
assert.isNotNull(actual[0].end_at);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('when there is a subscription invalid latest_invoice', () => {
|
|
it('should throw an error for a null latest_invoice', async () => {
|
|
const subscription = deepCopy(subscription1);
|
|
subscription.latest_invoice = null;
|
|
|
|
const input = {
|
|
data: [subscription],
|
|
};
|
|
|
|
try {
|
|
await stripeHelper.subscriptionsToResponse(input);
|
|
assert.fail();
|
|
} catch (err) {
|
|
assert.isNotNull(err);
|
|
assert.equal(
|
|
err.message,
|
|
'Latest invoice for subscription could not be found'
|
|
);
|
|
}
|
|
});
|
|
|
|
it('should throw an error for a latest_invoice without an invoice number', async () => {
|
|
const subscription = deepCopy(subscription1);
|
|
const input = {
|
|
data: [subscription],
|
|
};
|
|
sandbox
|
|
.stub(stripeHelper, 'expandResource')
|
|
.resolves({ ...paidInvoice, number: null });
|
|
|
|
try {
|
|
await stripeHelper.subscriptionsToResponse(input);
|
|
assert.fail();
|
|
} catch (err) {
|
|
assert.isNotNull(err);
|
|
assert.equal(
|
|
err.message,
|
|
'Invoice number for subscription is required'
|
|
);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('when there are no subscriptions', () => {
|
|
it('returns an empty array', async () => {
|
|
const expected = [];
|
|
const actual = await stripeHelper.subscriptionsToResponse({ data: [] });
|
|
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
});
|
|
|
|
describe('when there are multiple subscriptions', () => {
|
|
it('returns a formatted version of all not incomplete or incomplete_expired subscriptions', async () => {
|
|
const incompleteSubscription = deepCopy(subscription1);
|
|
incompleteSubscription.status = 'incomplete';
|
|
incompleteSubscription.id = 'sub_incomplete';
|
|
|
|
sandbox.stub(stripeHelper, 'expandResource').resolves(paidInvoice);
|
|
|
|
const input = {
|
|
data: [subscription1, incompleteSubscription, subscription2],
|
|
};
|
|
|
|
const response = await stripeHelper.subscriptionsToResponse(input);
|
|
|
|
assert.lengthOf(response, 2);
|
|
assert.isDefined(
|
|
response.find((x) => x.subscription_id === subscription1.id),
|
|
'should contain subscription1'
|
|
);
|
|
assert.isDefined(
|
|
response.find((x) => x.subscription_id === subscription2.id),
|
|
'should contain subscription2'
|
|
);
|
|
assert.isUndefined(
|
|
response.find((x) => x.subscription_id === incompleteSubscription.id),
|
|
'should not contain incompleteSubscription'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('when a subscription has a promotion code', () => {
|
|
const latestInvoiceItems =
|
|
stripeInvoiceToLatestInvoiceItemsDTO(paidInvoice);
|
|
it('"once" coupon duration do not include the promotion values in the returned value', async () => {
|
|
const subscription = deepCopy(subscriptionCouponOnce);
|
|
const input = { data: [subscription] };
|
|
sandbox
|
|
.stub(stripeHelper.stripe.invoices, 'retrieve')
|
|
.resolves(paidInvoice);
|
|
const callback = sandbox.stub(stripeHelper, 'expandResource');
|
|
callback.onCall(0).resolves(paidInvoice);
|
|
callback.onCall(1).resolves({ id: productId, name: productName });
|
|
const expected = [
|
|
{
|
|
_subscription_type: MozillaSubscriptionTypes.WEB,
|
|
created: subscriptionCouponOnce.created,
|
|
current_period_end: subscriptionCouponOnce.current_period_end,
|
|
current_period_start: subscriptionCouponOnce.current_period_start,
|
|
cancel_at_period_end: false,
|
|
end_at: null,
|
|
plan_id: subscriptionCouponOnce.plan.id,
|
|
product_id: product1.id,
|
|
product_name: productName,
|
|
status: 'active',
|
|
subscription_id: subscriptionCouponOnce.id,
|
|
failure_code: undefined,
|
|
failure_message: undefined,
|
|
latest_invoice: paidInvoice.number,
|
|
latest_invoice_items: latestInvoiceItems,
|
|
promotion_amount_off: null,
|
|
promotion_code: null,
|
|
promotion_duration: null,
|
|
promotion_end: null,
|
|
promotion_name: null,
|
|
promotion_percent_off: null,
|
|
},
|
|
];
|
|
|
|
const actual = await stripeHelper.subscriptionsToResponse(input);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('forever coupon duration includes the promotion values in the returned value', async () => {
|
|
const subscription = deepCopy(subscriptionCouponForever);
|
|
const input = { data: [subscription] };
|
|
sandbox
|
|
.stub(stripeHelper.stripe.invoices, 'retrieve')
|
|
.resolves(paidInvoice);
|
|
const callback = sandbox.stub(stripeHelper, 'expandResource');
|
|
callback.onCall(0).resolves(paidInvoice);
|
|
callback.onCall(1).resolves({ id: productId, name: productName });
|
|
const expected = [
|
|
{
|
|
_subscription_type: MozillaSubscriptionTypes.WEB,
|
|
created: subscriptionCouponForever.created,
|
|
current_period_end: subscriptionCouponForever.current_period_end,
|
|
current_period_start:
|
|
subscriptionCouponForever.current_period_start,
|
|
cancel_at_period_end: false,
|
|
end_at: null,
|
|
plan_id: subscriptionCouponForever.plan.id,
|
|
product_id: product1.id,
|
|
product_name: productName,
|
|
status: 'active',
|
|
subscription_id: subscriptionCouponForever.id,
|
|
failure_code: undefined,
|
|
failure_message: undefined,
|
|
latest_invoice: paidInvoice.number,
|
|
latest_invoice_items: latestInvoiceItems,
|
|
promotion_amount_off:
|
|
subscriptionCouponForever.discount.coupon.amount_off,
|
|
promotion_code:
|
|
subscriptionCouponForever.metadata.appliedPromotionCode,
|
|
promotion_duration: 'forever',
|
|
promotion_end: null,
|
|
promotion_name: subscriptionCouponForever.discount.coupon.name,
|
|
promotion_percent_off:
|
|
subscriptionCouponForever.discount.coupon.percent_off,
|
|
},
|
|
];
|
|
|
|
const actual = await stripeHelper.subscriptionsToResponse(input);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
|
|
it('repeating coupon includes the promotion values in the returned value', async () => {
|
|
const subscription = deepCopy(subscriptionCouponRepeating);
|
|
const input = { data: [subscription] };
|
|
sandbox
|
|
.stub(stripeHelper.stripe.invoices, 'retrieve')
|
|
.resolves(paidInvoice);
|
|
const callback = sandbox.stub(stripeHelper, 'expandResource');
|
|
callback.onCall(0).resolves(paidInvoice);
|
|
callback.onCall(1).resolves({ id: productId, name: productName });
|
|
const expected = [
|
|
{
|
|
_subscription_type: MozillaSubscriptionTypes.WEB,
|
|
created: subscriptionCouponRepeating.created,
|
|
current_period_end: subscriptionCouponRepeating.current_period_end,
|
|
current_period_start:
|
|
subscriptionCouponRepeating.current_period_start,
|
|
cancel_at_period_end: false,
|
|
end_at: null,
|
|
plan_id: subscriptionCouponRepeating.plan.id,
|
|
product_id: product1.id,
|
|
product_name: productName,
|
|
status: 'active',
|
|
subscription_id: subscriptionCouponRepeating.id,
|
|
failure_code: undefined,
|
|
failure_message: undefined,
|
|
latest_invoice: paidInvoice.number,
|
|
latest_invoice_items: latestInvoiceItems,
|
|
promotion_amount_off:
|
|
subscriptionCouponRepeating.discount.coupon.amount_off,
|
|
promotion_code:
|
|
subscriptionCouponRepeating.metadata.appliedPromotionCode,
|
|
promotion_duration: 'repeating',
|
|
promotion_end: subscriptionCouponRepeating.discount.end,
|
|
promotion_name: subscriptionCouponRepeating.discount.coupon.name,
|
|
promotion_percent_off:
|
|
subscriptionCouponRepeating.discount.coupon.percent_off,
|
|
},
|
|
];
|
|
|
|
const actual = await stripeHelper.subscriptionsToResponse(input);
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('formatSubscriptionsForSupport', () => {
|
|
const productName = 'FPN Tier 1';
|
|
const productId = 'prod_123';
|
|
|
|
beforeEach(() => {
|
|
sandbox
|
|
.stub(stripeHelper, 'expandResource')
|
|
.resolves({ id: productId, name: productName });
|
|
});
|
|
|
|
describe('when is one subscription', () => {
|
|
it('when there is a subscription with no metadata', () => {
|
|
it('should include the subscription with null values for plan changed data', async () => {
|
|
const subscription = deepCopy(subscription1);
|
|
subscription.status = 'incomplete';
|
|
|
|
const input = {
|
|
data: [subscription],
|
|
};
|
|
|
|
const expected = [
|
|
{
|
|
created: subscription.created,
|
|
current_period_end: subscription.current_period_end,
|
|
current_period_start: subscription.current_period_start,
|
|
plan_changed: null,
|
|
previous_product: null,
|
|
product_name: productName,
|
|
subscription_id: subscription.id,
|
|
},
|
|
];
|
|
const actual =
|
|
await stripeHelper.formatSubscriptionsForSupport(input);
|
|
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
});
|
|
|
|
it('when there is a subscription with plan changed information in the metadata', () => {
|
|
it('should include the subscription with values for plan changed data', async () => {
|
|
const subscription = deepCopy(subscription1);
|
|
subscription.metadata = {
|
|
previous_plan_id: 'plan_123',
|
|
plan_change_date: '1588962638',
|
|
};
|
|
|
|
const input = {
|
|
data: [subscription],
|
|
};
|
|
|
|
const expected = [
|
|
{
|
|
created: subscription.created,
|
|
current_period_end: subscription.current_period_end,
|
|
current_period_start: subscription.current_period_start,
|
|
plan_changed: 'plan_123',
|
|
previous_product: 1588962638,
|
|
product_name: productName,
|
|
subscription_id: subscription.id,
|
|
},
|
|
];
|
|
const actual =
|
|
await stripeHelper.formatSubscriptionsForSupport(input);
|
|
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('when there are no subscriptions', () => {
|
|
it('returns an empty array', async () => {
|
|
const expected = [];
|
|
const actual = await stripeHelper.formatSubscriptionsForSupport({
|
|
data: [],
|
|
});
|
|
|
|
assert.deepEqual(actual, expected);
|
|
});
|
|
});
|
|
|
|
describe('when there are multiple subscriptions', () => {
|
|
it('returns a formatted version of all subscriptions', async () => {
|
|
const input = {
|
|
data: [subscription1, subscription2, cancelledSubscription],
|
|
};
|
|
|
|
const response =
|
|
await stripeHelper.formatSubscriptionsForSupport(input);
|
|
|
|
assert.lengthOf(response, 3);
|
|
assert.isDefined(
|
|
response.find((x) => x.subscription_id === subscription1.id),
|
|
'should contain subscription1'
|
|
);
|
|
assert.isDefined(
|
|
response.find((x) => x.subscription_id === subscription2.id),
|
|
'should contain subscription2'
|
|
);
|
|
assert.isDefined(
|
|
response.find((x) => x.subscription_id === cancelledSubscription.id),
|
|
'should contain subscription2'
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('checkSubscriptionPastDue', () => {
|
|
const subscription = {
|
|
status: 'past_due',
|
|
collection_method: 'charge_automatically',
|
|
};
|
|
it('return true for a subscription past due', () => {
|
|
assert.isTrue(stripeHelper.checkSubscriptionPastDue(subscription));
|
|
});
|
|
|
|
it('return false for a subscription not past due', () => {
|
|
assert.isFalse(
|
|
stripeHelper.checkSubscriptionPastDue({
|
|
...subscription,
|
|
status: 'active',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('return false for an invalid subscription', () => {
|
|
assert.isFalse(stripeHelper.checkSubscriptionPastDue({}));
|
|
});
|
|
});
|
|
|
|
describe('extract details for billing emails', () => {
|
|
const uid = '1234abcd';
|
|
const email = 'test+20200324@example.com';
|
|
const planId = 'plan_00000000000000';
|
|
const planName = 'Example Plan';
|
|
const productId = 'prod_00000000000000';
|
|
const productName = 'Example Product';
|
|
const planEmailIconURL = 'http://example.com/icon-new';
|
|
const successActionButtonURL = 'http://example.com/download-new';
|
|
const sourceId = eventCustomerSourceExpiring.data.object.id;
|
|
const chargeId = 'ch_1GVm24BVqmGyQTMaUhRAfUmA';
|
|
const privacyNoticeURL =
|
|
'https://www.mozilla.org/privacy/firefox-private-network';
|
|
const termsOfServiceURL =
|
|
'https://www.mozilla.org/about/legal/terms/firefox-private-network';
|
|
const cancellationSurveyURL =
|
|
'https://www.mozilla.org/legal/mozilla_cancellation_survey_url';
|
|
|
|
const mockPlan = {
|
|
id: planId,
|
|
nickname: planName,
|
|
product: productId,
|
|
metadata: {
|
|
emailIconURL: planEmailIconURL,
|
|
successActionButtonURL: successActionButtonURL,
|
|
},
|
|
};
|
|
|
|
const mockProduct = {
|
|
id: productId,
|
|
name: productName,
|
|
metadata: {
|
|
'product:termsOfServiceURL': termsOfServiceURL,
|
|
'product:privacyNoticeURL': privacyNoticeURL,
|
|
},
|
|
};
|
|
|
|
const mockSource = {
|
|
id: sourceId,
|
|
};
|
|
|
|
const mockOldInvoice = {
|
|
total: 4567,
|
|
};
|
|
|
|
const mockInvoice = {
|
|
id: 'inv_0000000000',
|
|
number: '1234567',
|
|
charge: chargeId,
|
|
default_source: { id: sourceId },
|
|
total: 1234,
|
|
currency: 'usd',
|
|
period_end: 1587426018,
|
|
lines: {
|
|
data: [
|
|
{
|
|
period: { end: 1590018018 },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockInvoiceUpcoming = {
|
|
...mockInvoice,
|
|
id: 'inv_upcoming',
|
|
amount_due: 299000,
|
|
created: 1590018018,
|
|
};
|
|
|
|
const mockCharge = {
|
|
id: chargeId,
|
|
source: mockSource,
|
|
payment_method_details: {
|
|
card: {
|
|
brand: 'visa',
|
|
last4: '5309',
|
|
},
|
|
},
|
|
};
|
|
|
|
let sandbox,
|
|
mockCustomer,
|
|
mockStripe,
|
|
mockAllAbbrevProducts,
|
|
mockAllAbbrevPlans,
|
|
expandMock;
|
|
|
|
beforeEach(() => {
|
|
sandbox = sinon.createSandbox();
|
|
|
|
mockCustomer = {
|
|
id: 'cus_00000000000000',
|
|
email,
|
|
metadata: {
|
|
userid: uid,
|
|
},
|
|
subscriptions: {
|
|
data: [
|
|
{
|
|
status: 'active',
|
|
latest_invoice: 'inv_0000000000',
|
|
plan: planId,
|
|
items: {
|
|
data: [{ plan: planId }],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
mockAllAbbrevProducts = [
|
|
{
|
|
product_id: mockProduct.id,
|
|
product_name: mockProduct.name,
|
|
product_metadata: mockProduct.metadata,
|
|
},
|
|
{
|
|
product_id: 'wrongProduct',
|
|
product_name: 'Wrong Product',
|
|
product_metadata: {},
|
|
},
|
|
];
|
|
mockAllAbbrevPlans = [
|
|
{ ...mockPlan, plan_id: planId, product_id: productId },
|
|
];
|
|
sandbox
|
|
.stub(stripeHelper, 'allAbbrevProducts')
|
|
.resolves(mockAllAbbrevProducts);
|
|
sandbox.stub(stripeHelper, 'allAbbrevPlans').resolves(mockAllAbbrevPlans);
|
|
|
|
expandMock = sandbox.stub(stripeHelper, 'expandResource');
|
|
|
|
mockStripe = Object.entries({
|
|
plans: mockPlan,
|
|
products: mockProduct,
|
|
invoices: mockInvoice,
|
|
charges: mockCharge,
|
|
sources: mockSource,
|
|
}).reduce(
|
|
(acc, [resource, value]) => ({
|
|
...acc,
|
|
[resource]: { retrieve: sinon.stub().resolves(value) },
|
|
}),
|
|
{}
|
|
);
|
|
mockStripe.invoices.retrieveUpcoming = sinon
|
|
.stub()
|
|
.resolves(mockInvoiceUpcoming);
|
|
stripeHelper.stripe = mockStripe;
|
|
});
|
|
|
|
afterEach(() => {
|
|
sandbox.restore();
|
|
});
|
|
|
|
describe('extractInvoiceDetailsForEmail', () => {
|
|
const fixture = { ...invoicePaidSubscriptionCreate };
|
|
fixture.lines.data[0] = {
|
|
...fixture.lines.data[0],
|
|
plan: {
|
|
id: planId,
|
|
nickname: planName,
|
|
product: productId,
|
|
metadata: mockPlan.metadata,
|
|
},
|
|
};
|
|
|
|
const fixtureDiscount = { ...invoicePaidSubscriptionCreateDiscount };
|
|
fixtureDiscount.lines.data[0] = {
|
|
...fixtureDiscount.lines.data[0],
|
|
plan: {
|
|
id: planId,
|
|
nickname: planName,
|
|
product: productId,
|
|
metadata: mockPlan.metadata,
|
|
},
|
|
};
|
|
|
|
const fixtureTaxDiscount = {
|
|
...invoicePaidSubscriptionCreateTaxDiscount,
|
|
};
|
|
fixtureTaxDiscount.lines.data[0] = {
|
|
...fixtureTaxDiscount.lines.data[0],
|
|
plan: {
|
|
id: planId,
|
|
nickname: planName,
|
|
product: productId,
|
|
metadata: mockPlan.metadata,
|
|
},
|
|
};
|
|
|
|
const fixtureTax = { ...invoicePaidSubscriptionCreateTax };
|
|
fixtureTax.lines.data[0] = {
|
|
...fixtureTax.lines.data[0],
|
|
plan: {
|
|
id: planId,
|
|
nickname: planName,
|
|
product: productId,
|
|
metadata: mockPlan.metadata,
|
|
},
|
|
};
|
|
|
|
const fixtureProrated = deepCopy(invoicePaidSubscriptionCreate);
|
|
fixtureProrated.lines.data.unshift({
|
|
...fixtureProrated.lines.data[0],
|
|
type: 'invoiceitem',
|
|
proration: true,
|
|
amount: -100,
|
|
plan: {
|
|
id: 'mock-prorated-plan-id',
|
|
nickname: 'Prorated plan',
|
|
product: productId,
|
|
metadata: mockPlan.metadata,
|
|
},
|
|
});
|
|
|
|
const fixtureProrationRefund = { ...invoiceDraftProrationRefund };
|
|
fixtureProrationRefund.lines.data[1] = {
|
|
...fixtureProrationRefund.lines.data[1],
|
|
plan: {
|
|
id: planId,
|
|
nickname: planName,
|
|
product: productId,
|
|
metadata: mockPlan.metadata,
|
|
},
|
|
period: {
|
|
end: 1587767020,
|
|
start: 1585088620,
|
|
},
|
|
};
|
|
|
|
const planConfig = {
|
|
urls: {
|
|
emailIcon: 'http://firestore.example.gg/email.ico',
|
|
download: 'http://firestore.example.gg/download',
|
|
successActionButton: 'http://firestore.example.gg/download',
|
|
},
|
|
};
|
|
|
|
const expected = {
|
|
uid,
|
|
email,
|
|
cardType: 'visa',
|
|
creditAppliedInCents: 0,
|
|
lastFour: '5309',
|
|
invoiceAmountDueInCents: 500,
|
|
invoiceLink:
|
|
'https://pay.stripe.com/invoice/acct_1GCAr3BVqmGyQTMa/invst_GyHjTyIXBg8jj5yjt7Z0T4CCG3hfGtp',
|
|
invoiceNumber: 'AAF2CECC-0001',
|
|
invoiceStartingBalance: 0,
|
|
invoiceStatus: 'paid',
|
|
invoiceTotalCurrency: 'usd',
|
|
invoiceTotalInCents: 500,
|
|
invoiceSubtotalInCents: 500,
|
|
invoiceDiscountAmountInCents: null,
|
|
invoiceTaxAmountInCents: null,
|
|
invoiceDate: new Date('2020-03-24T22:23:40.000Z'),
|
|
nextInvoiceDate: new Date('2020-04-24T22:23:40.000Z'),
|
|
offeringPriceInCents: 500,
|
|
payment_provider: 'stripe',
|
|
productId,
|
|
productName,
|
|
planId,
|
|
planConfig: {},
|
|
planName,
|
|
planEmailIconURL,
|
|
planSuccessActionButtonURL: successActionButtonURL,
|
|
productMetadata: {
|
|
successActionButtonURL: successActionButtonURL,
|
|
emailIconURL: planEmailIconURL,
|
|
'product:privacyNoticeURL': privacyNoticeURL,
|
|
'product:termsOfServiceURL': termsOfServiceURL,
|
|
productOrder: '0',
|
|
},
|
|
remainingAmountTotalInCents: undefined,
|
|
showTaxAmount: false,
|
|
unusedAmountTotalInCents: 0,
|
|
discountType: null,
|
|
discountDuration: null,
|
|
};
|
|
|
|
const expectedDiscount_foreverCoupon = {
|
|
...expected,
|
|
invoiceAmountDueInCents: 450,
|
|
invoiceNumber: '3432720C-0001',
|
|
invoiceTotalInCents: 450,
|
|
invoiceSubtotalInCents: 500,
|
|
invoiceDiscountAmountInCents: 50,
|
|
discountType: 'forever',
|
|
discountDuration: null,
|
|
};
|
|
|
|
const mockInvoice = {
|
|
id: 'inv_0000000000',
|
|
number: '1234567',
|
|
charge: chargeId,
|
|
default_source: { id: sourceId },
|
|
total: 1234,
|
|
currency: 'usd',
|
|
period_end: 1587426018,
|
|
lines: {
|
|
data: [
|
|
{
|
|
period: { end: 1590018018 },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
beforeEach(() => {
|
|
stripeHelper.stripe = {
|
|
...(stripeHelper.stripe || {}),
|
|
paymentIntents: {
|
|
...(stripeHelper.stripe?.paymentIntents || {}),
|
|
retrieve: sinon.stub().resolves(successfulPaymentIntent),
|
|
},
|
|
invoices: {
|
|
...(stripeHelper.stripe?.invoices || {}),
|
|
retrieve: sinon.stub().resolves(mockInvoice),
|
|
},
|
|
};
|
|
|
|
expandMock.onCall(0).resolves(mockCustomer);
|
|
expandMock.onCall(1).resolves(mockCharge);
|
|
});
|
|
|
|
it('extracts expected details from an invoice that requires requests to expand', async () => {
|
|
const result =
|
|
await stripeHelper.extractInvoiceDetailsForEmail(fixture);
|
|
assert.isTrue(stripeHelper.allAbbrevProducts.called);
|
|
assert.isFalse(mockStripe.products.retrieve.called);
|
|
sinon.assert.calledThrice(expandMock);
|
|
assert.deepEqual(result, expected);
|
|
});
|
|
|
|
it('extracts expected details from an invoice when product is missing from cache', async () => {
|
|
mockAllAbbrevProducts[0].product_id = 'nope';
|
|
const result =
|
|
await stripeHelper.extractInvoiceDetailsForEmail(fixture);
|
|
assert.isTrue(stripeHelper.allAbbrevProducts.called);
|
|
assert.isTrue(mockStripe.products.retrieve.called);
|
|
sinon.assert.calledThrice(expandMock);
|
|
assert.deepEqual(result, expected);
|
|
});
|
|
|
|
it('extracts expected details from an expanded invoice', async () => {
|
|
const fixture = deepCopy(invoicePaidSubscriptionCreate);
|
|
fixture.lines.data[0].plan = {
|
|
id: planId,
|
|
nickname: planName,
|
|
metadata: mockPlan.metadata,
|
|
product: mockProduct,
|
|
};
|
|
fixture.customer = mockCustomer;
|
|
fixture.charge = mockCharge;
|
|
const result =
|
|
await stripeHelper.extractInvoiceDetailsForEmail(fixture);
|
|
assert.isTrue(stripeHelper.allAbbrevProducts.called);
|
|
assert.isFalse(mockStripe.products.retrieve.called);
|
|
sinon.assert.calledThrice(expandMock);
|
|
assert.deepEqual(result, expected);
|
|
});
|
|
|
|
it('does not throw an exception when details on a payment method are missing', async () => {
|
|
const fixture = deepCopy(invoicePaidSubscriptionCreate);
|
|
fixture.lines.data[0].plan = {
|
|
id: planId,
|
|
nickname: planName,
|
|
metadata: mockPlan.metadata,
|
|
product: mockProduct,
|
|
};
|
|
fixture.customer = mockCustomer;
|
|
fixture.charge = null;
|
|
expandMock.onCall(1).resolves(null);
|
|
const result =
|
|
await stripeHelper.extractInvoiceDetailsForEmail(fixture);
|
|
assert.isTrue(stripeHelper.allAbbrevProducts.called);
|
|
assert.isFalse(mockStripe.products.retrieve.called);
|
|
sinon.assert.calledThrice(expandMock);
|
|
assert.deepEqual(result, {
|
|
...expected,
|
|
lastFour: null,
|
|
cardType: null,
|
|
});
|
|
});
|
|
|
|
it('extracts expected details from an invoice of an upgrade', async () => {
|
|
const fixture = deepCopy(invoicePaidSubscriptionCreate);
|
|
const subscriptionItem = deepCopy(fixture.lines.data[0]);
|
|
const subscriptionPeriodEnd = 1593032000;
|
|
fixture.lines.data.push(subscriptionItem);
|
|
fixture.lines.data[0].type = 'invoiceitem';
|
|
fixture.lines.data[1].period.end = subscriptionPeriodEnd;
|
|
|
|
const result =
|
|
await stripeHelper.extractInvoiceDetailsForEmail(fixture);
|
|
|
|
assert.isTrue(stripeHelper.allAbbrevProducts.called);
|
|
assert.isFalse(mockStripe.products.retrieve.called);
|
|
sinon.assert.calledThrice(expandMock);
|
|
assert.deepEqual(result, {
|
|
...expected,
|
|
nextInvoiceDate: new Date(subscriptionPeriodEnd * 1000),
|
|
});
|
|
});
|
|
|
|
it('extracts expected details from an invoice with invoiceitem for a previous subscription', async () => {
|
|
const result =
|
|
await stripeHelper.extractInvoiceDetailsForEmail(fixtureProrated);
|
|
assert.isTrue(stripeHelper.allAbbrevProducts.called);
|
|
assert.isFalse(mockStripe.products.retrieve.called);
|
|
sinon.assert.calledThrice(expandMock);
|
|
assert.deepEqual(result, expected);
|
|
});
|
|
|
|
it('extracts expected details from an invoice with discount', async () => {
|
|
const result =
|
|
await stripeHelper.extractInvoiceDetailsForEmail(fixtureDiscount);
|
|
assert.isTrue(stripeHelper.allAbbrevProducts.called);
|
|
assert.isFalse(mockStripe.products.retrieve.called);
|
|
sinon.assert.calledThrice(expandMock);
|
|
assert.deepEqual(result, expectedDiscount_foreverCoupon);
|
|
});
|
|
|
|
it('extracts expected details from an invoice with 100% discount', async () => {
|
|
const fixtureDiscount100 = fixtureDiscount;
|
|
fixtureDiscount100.total = 0;
|
|
fixtureDiscount100.total_discount_amounts[0].amount = 500;
|
|
const expectedDiscount100 = {
|
|
...expectedDiscount_foreverCoupon,
|
|
invoiceDiscountAmountInCents: 500,
|
|
invoiceTotalInCents: 0,
|
|
};
|
|
const result =
|
|
await stripeHelper.extractInvoiceDetailsForEmail(fixtureDiscount100);
|
|
assert.isTrue(stripeHelper.allAbbrevProducts.called);
|
|
assert.isFalse(mockStripe.products.retrieve.called);
|
|
sinon.assert.calledThrice(expandMock);
|
|
assert.deepEqual(result, expectedDiscount100);
|
|
});
|
|
|
|
it('extract expected details for Product with custom cancellationSurveyURL', async () => {
|
|
const mockAllAbbrevProducts = [
|
|
{
|
|
product_id: mockProduct.id,
|
|
product_name: mockProduct.name,
|
|
product_metadata: {
|
|
...mockProduct.metadata,
|
|
'product:cancellationSurveyURL': cancellationSurveyURL,
|
|
},
|
|
},
|
|
];
|
|
stripeHelper.allAbbrevProducts.resolves(mockAllAbbrevProducts);
|
|
const fixture = deepCopy(invoicePaidSubscriptionCreate);
|
|
const result =
|
|
await stripeHelper.extractInvoiceDetailsForEmail(fixture);
|
|
assert.isTrue(stripeHelper.allAbbrevProducts.called);
|
|
assert.isFalse(mockStripe.products.retrieve.called);
|
|
sinon.assert.calledThrice(expandMock);
|
|
assert.deepEqual(result, {
|
|
...expected,
|
|
productMetadata: {
|
|
...expected.productMetadata,
|
|
'product:cancellationSurveyURL': cancellationSurveyURL,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('extracts expected details for an invoice with tax', async () => {
|
|
const result =
|
|
await stripeHelper.extractInvoiceDetailsForEmail(fixtureTax);
|
|
assert.isTrue(stripeHelper.allAbbrevProducts.called);
|
|
assert.isFalse(mockStripe.products.retrieve.called);
|
|
sinon.assert.calledThrice(expandMock);
|
|
assert.deepEqual(result, {
|
|
...expected,
|
|
invoiceTaxAmountInCents: 54,
|
|
});
|
|
});
|
|
|
|
it('extracts expected details from an invoice with discount and tax', async () => {
|
|
const result =
|
|
await stripeHelper.extractInvoiceDetailsForEmail(fixtureTaxDiscount);
|
|
assert.isTrue(stripeHelper.allAbbrevProducts.called);
|
|
assert.isFalse(mockStripe.products.retrieve.called);
|
|
sinon.assert.calledThrice(expandMock);
|
|
assert.deepEqual(result, {
|
|
...expectedDiscount_foreverCoupon,
|
|
invoiceTaxAmountInCents: 48,
|
|
});
|
|
});
|
|
|
|
it('extracts expected details from an invoice without line item of type "subscription"', async () => {
|
|
const result = await stripeHelper.extractInvoiceDetailsForEmail(
|
|
fixtureProrationRefund
|
|
);
|
|
assert.isTrue(stripeHelper.allAbbrevProducts.called);
|
|
assert.isFalse(mockStripe.products.retrieve.called);
|
|
sinon.assert.calledTwice(expandMock);
|
|
assert.deepEqual(result, {
|
|
...expected,
|
|
invoiceStatus: 'draft',
|
|
offeringPriceInCents: 1200,
|
|
remainingAmountTotalInCents: 1200,
|
|
unusedAmountTotalInCents: -700,
|
|
});
|
|
});
|
|
|
|
it('throws an exception for deleted customer', async () => {
|
|
expandMock.onCall(0).resolves({ ...mockCustomer, deleted: true });
|
|
|
|
let thrownError = null;
|
|
try {
|
|
await stripeHelper.extractInvoiceDetailsForEmail(fixture);
|
|
} catch (err) {
|
|
thrownError = err;
|
|
}
|
|
assert.isNotNull(thrownError);
|
|
assert.equal(
|
|
thrownError.errno,
|
|
error.ERRNO.UNKNOWN_SUBSCRIPTION_CUSTOMER
|
|
);
|
|
assert.isFalse(stripeHelper.allAbbrevProducts.called);
|
|
assert.isFalse(mockStripe.products.retrieve.called);
|
|
sinon.assert.calledOnce(expandMock);
|
|
});
|
|
|
|
it('throws an exception for deleted product', async () => {
|
|
mockAllAbbrevProducts[0].product_id = 'nope';
|
|
mockStripe.products.retrieve = sinon
|
|
.stub()
|
|
.resolves({ ...mockProduct, deleted: true });
|
|
|
|
let thrownError = null;
|
|
try {
|
|
await stripeHelper.extractInvoiceDetailsForEmail(fixture);
|
|
} catch (err) {
|
|
thrownError = err;
|
|
}
|
|
assert.isNotNull(thrownError);
|
|
assert.equal(thrownError.errno, error.ERRNO.UNKNOWN_SUBSCRIPTION_PLAN);
|
|
assert.isTrue(mockStripe.products.retrieve.calledWith(productId));
|
|
assert.isTrue(stripeHelper.allAbbrevProducts.called);
|
|
sinon.assert.calledTwice(expandMock);
|
|
});
|
|
|
|
it('throws an exception with unexpected data', async () => {
|
|
const fixture = {
|
|
...invoicePaidSubscriptionCreate,
|
|
lines: null,
|
|
};
|
|
let thrownError = null;
|
|
try {
|
|
await stripeHelper.extractInvoiceDetailsForEmail(fixture);
|
|
} catch (err) {
|
|
thrownError = err;
|
|
}
|
|
assert.isNotNull(thrownError);
|
|
assert.equal(thrownError.name, 'TypeError');
|
|
});
|
|
|
|
it('throws an exception if invoice line items doesnt have type = "subscription" or "invoiceitem"', async () => {
|
|
const fixture = deepCopy(invoicePaidSubscriptionCreate);
|
|
fixture.lines.data[0].type = 'none';
|
|
try {
|
|
await stripeHelper.extractInvoiceDetailsForEmail(fixture);
|
|
assert.fail();
|
|
} catch (err) {
|
|
assert.isNotNull(err);
|
|
assert.equal(err.errno, error.ERRNO.INTERNAL_VALIDATION_ERROR);
|
|
}
|
|
});
|
|
|
|
it('throws an exception if an invoice has multiple discounts', async () => {
|
|
const fixtureDiscountMultiple = deepCopy(fixtureDiscount);
|
|
fixtureDiscountMultiple.discounts = ['discount1', 'discount2'];
|
|
let thrownError = null;
|
|
try {
|
|
await stripeHelper.extractInvoiceDetailsForEmail(
|
|
fixtureDiscountMultiple
|
|
);
|
|
} catch (err) {
|
|
thrownError = err;
|
|
}
|
|
|
|
assert.isNotNull(thrownError);
|
|
});
|
|
|
|
it('extracts the correct months and coupon type for a 3 month coupon', async () => {
|
|
const fixtureDiscount3Month = deepCopy(fixtureDiscount);
|
|
fixtureDiscount3Month.discount = {
|
|
coupon: {
|
|
duration: 'repeating',
|
|
duration_in_months: 3,
|
|
},
|
|
};
|
|
|
|
const actual = await stripeHelper.extractInvoiceDetailsForEmail(
|
|
fixtureDiscount3Month
|
|
);
|
|
assert.equal(actual.discountType, 'repeating');
|
|
assert.equal(actual.discountDuration, 3);
|
|
});
|
|
|
|
it('extracts the correct months and coupon type for a one time coupon', async () => {
|
|
const fixtureDiscountOneTime = deepCopy(fixtureDiscount);
|
|
fixtureDiscountOneTime.discount = {
|
|
coupon: {
|
|
duration: 'once',
|
|
duration_in_months: null,
|
|
},
|
|
};
|
|
|
|
const actual = await stripeHelper.extractInvoiceDetailsForEmail(
|
|
fixtureDiscountOneTime
|
|
);
|
|
assert.equal(actual.discountType, 'once');
|
|
assert.isNull(actual.discountDuration);
|
|
});
|
|
|
|
it('extracts the correct discount type when discounts property needs to be expanded', async () => {
|
|
const fixtureDiscountOneTime = deepCopy(fixture);
|
|
fixtureDiscountOneTime.discounts = ['discountId'];
|
|
sandbox.stub(stripeHelper, 'getInvoiceWithDiscount').resolves({
|
|
...fixtureDiscountOneTime,
|
|
discounts: [
|
|
{
|
|
coupon: {
|
|
duration: 'once',
|
|
duration_in_months: null,
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
const actual = await stripeHelper.extractInvoiceDetailsForEmail(
|
|
fixtureDiscountOneTime
|
|
);
|
|
assert.equal(actual.discountType, 'once');
|
|
assert.isNull(actual.discountDuration);
|
|
});
|
|
|
|
it('uses and includes Firestore based configs when available', async () => {
|
|
sandbox.stub(stripeHelper, 'maybeGetPlanConfig').resolves(planConfig);
|
|
const result =
|
|
await stripeHelper.extractInvoiceDetailsForEmail(fixture);
|
|
const expectedWithPlanConfig = {
|
|
...expected,
|
|
planConfig,
|
|
planEmailIconURL: planConfig.urls.emailIcon,
|
|
planSuccessActionButtonURL: planConfig.urls.successActionButton,
|
|
};
|
|
sinon.assert.calledOnce(stripeHelper.maybeGetPlanConfig);
|
|
assert.deepEqual(result, expectedWithPlanConfig);
|
|
});
|
|
});
|
|
|
|
describe('extractSourceDetailsForEmail', () => {
|
|
const fixture = { ...eventCustomerSourceExpiring.data.object };
|
|
|
|
const expected = {
|
|
uid,
|
|
email,
|
|
subscriptions: [
|
|
{
|
|
productId,
|
|
productName,
|
|
planId,
|
|
planConfig: {},
|
|
planName,
|
|
planEmailIconURL,
|
|
planSuccessActionButtonURL: successActionButtonURL,
|
|
productMetadata: {
|
|
successActionButtonURL,
|
|
emailIconURL: planEmailIconURL,
|
|
'product:privacyNoticeURL': privacyNoticeURL,
|
|
'product:termsOfServiceURL': termsOfServiceURL,
|
|
productOrder: '0',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
beforeEach(() => {
|
|
expandMock.onCall(0).resolves(mockCustomer);
|
|
expandMock.onCall(1).resolves(mockPlan);
|
|
});
|
|
|
|
it('extracts expected details from a source that requires requests to expand', async () => {
|
|
const result = await stripeHelper.extractSourceDetailsForEmail(fixture);
|
|
assert.isTrue(stripeHelper.allAbbrevProducts.called);
|
|
assert.isFalse(mockStripe.products.retrieve.called);
|
|
assert.deepEqual(result, expected);
|
|
sinon.assert.calledTwice(expandMock);
|
|
});
|
|
|
|
it('throws an exception for deleted customer', async () => {
|
|
expandMock.onCall(0).resolves({ ...mockCustomer, deleted: true });
|
|
let thrownError = null;
|
|
try {
|
|
await stripeHelper.extractSourceDetailsForEmail(fixture);
|
|
} catch (err) {
|
|
thrownError = err;
|
|
}
|
|
assert.isNotNull(thrownError);
|
|
assert.equal(
|
|
thrownError.errno,
|
|
error.ERRNO.UNKNOWN_SUBSCRIPTION_CUSTOMER
|
|
);
|
|
sinon.assert.calledOnce(expandMock);
|
|
assert.isFalse(stripeHelper.allAbbrevProducts.called);
|
|
assert.isFalse(mockStripe.products.retrieve.called);
|
|
});
|
|
|
|
it('throws an exception when unable to find plan or product', async () => {
|
|
mockCustomer.subscriptions.data = [];
|
|
let thrownError = null;
|
|
try {
|
|
await stripeHelper.extractSourceDetailsForEmail(fixture);
|
|
} catch (err) {
|
|
thrownError = err;
|
|
}
|
|
assert.isNotNull(thrownError);
|
|
assert.equal(
|
|
thrownError.errno,
|
|
error.ERRNO.UNKNOWN_SUBSCRIPTION_FOR_SOURCE
|
|
);
|
|
});
|
|
|
|
it('throws an exception with unexpected data', async () => {
|
|
const fixture = {
|
|
...eventCustomerSourceExpiring.data.object,
|
|
object: 'transfer',
|
|
};
|
|
let thrownError = null;
|
|
try {
|
|
await stripeHelper.extractSourceDetailsForEmail(fixture);
|
|
} catch (err) {
|
|
thrownError = err;
|
|
}
|
|
assert.isNotNull(thrownError);
|
|
assert.equal(thrownError.errno, error.ERRNO.INTERNAL_VALIDATION_ERROR);
|
|
});
|
|
});
|
|
|
|
const expectedBaseUpdateDetails = {
|
|
uid,
|
|
email,
|
|
planId,
|
|
productId,
|
|
productIdNew: productId,
|
|
productNameNew: productName,
|
|
productIconURLNew:
|
|
eventCustomerSubscriptionUpdated.data.object.plan.metadata.emailIconURL,
|
|
planIdNew: planId,
|
|
planConfig: {},
|
|
paymentAmountNewCurrency:
|
|
eventCustomerSubscriptionUpdated.data.object.plan.currency,
|
|
paymentAmountNewInCents:
|
|
eventCustomerSubscriptionUpdated.data.object.plan.amount,
|
|
productPaymentCycleNew:
|
|
eventCustomerSubscriptionUpdated.data.object.plan.interval,
|
|
closeDate: 1326853478,
|
|
invoiceOldCurrency: mockOldInvoice.currency,
|
|
invoiceTotalOldInCents: mockOldInvoice.total,
|
|
invoiceTaxOldInCents: 0,
|
|
productMetadata: {
|
|
emailIconURL:
|
|
eventCustomerSubscriptionUpdated.data.object.plan.metadata
|
|
.emailIconURL,
|
|
successActionButtonURL:
|
|
eventCustomerSubscriptionUpdated.data.object.plan.metadata
|
|
.successActionButtonURL,
|
|
'product:termsOfServiceURL': termsOfServiceURL,
|
|
'product:privacyNoticeURL': privacyNoticeURL,
|
|
productOrder: '0',
|
|
},
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockAllAbbrevPlans.unshift(
|
|
{
|
|
...eventCustomerSubscriptionUpdated.data.previous_attributes.plan,
|
|
plan_id:
|
|
eventCustomerSubscriptionUpdated.data.previous_attributes.plan.id,
|
|
product_id: expectedBaseUpdateDetails.productId,
|
|
plan_metadata:
|
|
eventCustomerSubscriptionUpdated.data.previous_attributes.plan
|
|
.metadata,
|
|
},
|
|
{
|
|
...eventCustomerSubscriptionUpdated.data.object.plan,
|
|
plan_id: eventCustomerSubscriptionUpdated.data.object.plan.id,
|
|
product_id: expectedBaseUpdateDetails.productIdNew,
|
|
plan_metadata:
|
|
eventCustomerSubscriptionUpdated.data.object.plan.metadata,
|
|
}
|
|
);
|
|
});
|
|
|
|
describe('extractSubscriptionDeletedEventDetailsForEmail', () => {
|
|
it('returns subscription invoice details', async () => {
|
|
const mockSubscription = deepCopy(subscription1);
|
|
const mockInvoice = deepCopy(invoicePaidSubscriptionCreate);
|
|
stripeHelper.extractInvoiceDetailsForEmail = sandbox
|
|
.stub()
|
|
.resolves(mockInvoice);
|
|
|
|
const result =
|
|
await stripeHelper.extractSubscriptionDeletedEventDetailsForEmail(
|
|
mockSubscription
|
|
);
|
|
assert.equal(result, mockInvoice);
|
|
sinon.assert.calledOnce(stripeHelper.extractInvoiceDetailsForEmail);
|
|
});
|
|
|
|
it('throws internalValidationError if latest_invoice is not present', async () => {
|
|
const mockSubscription = deepCopy(subscription1);
|
|
mockSubscription.latest_invoice = null;
|
|
let thrownError = null;
|
|
try {
|
|
await stripeHelper.extractSubscriptionDeletedEventDetailsForEmail(
|
|
mockSubscription
|
|
);
|
|
} catch (err) {
|
|
thrownError = err;
|
|
}
|
|
assert.isNotNull(thrownError);
|
|
assert.equal(thrownError.errno, error.ERRNO.INTERNAL_VALIDATION_ERROR);
|
|
});
|
|
});
|
|
|
|
describe('extractSubscriptionUpdateEventDetailsForEmail', () => {
|
|
const mockReactivationDetails = 'mockReactivationDetails';
|
|
const mockCancellationDetails = 'mockCancellationDetails';
|
|
const mockUpgradeDowngradeDetails = 'mockUpgradeDowngradeDetails';
|
|
|
|
beforeEach(() => {
|
|
sandbox.stub(stripeHelper, 'getInvoice').resolves(mockOldInvoice);
|
|
sandbox.stub(stripeHelper, 'getSubsequentPrices').resolves({
|
|
exclusiveTax: 0,
|
|
total: mockOldInvoice.total,
|
|
});
|
|
sandbox
|
|
.stub(
|
|
stripeHelper,
|
|
'extractSubscriptionUpdateCancellationDetailsForEmail'
|
|
)
|
|
.resolves(mockCancellationDetails);
|
|
sandbox
|
|
.stub(
|
|
stripeHelper,
|
|
'extractSubscriptionUpdateReactivationDetailsForEmail'
|
|
)
|
|
.resolves(mockReactivationDetails);
|
|
sandbox
|
|
.stub(
|
|
stripeHelper,
|
|
'extractSubscriptionUpdateUpgradeDowngradeDetailsForEmail'
|
|
)
|
|
.resolves(mockUpgradeDowngradeDetails);
|
|
expandMock.onCall(0).resolves(mockCustomer);
|
|
});
|
|
|
|
function assertOnlyExpectedHelperCalledWith(expectedHelperName, ...args) {
|
|
const allHelperNames = [
|
|
'extractSubscriptionUpdateReactivationDetailsForEmail',
|
|
'extractSubscriptionUpdateUpgradeDowngradeDetailsForEmail',
|
|
'extractSubscriptionUpdateCancellationDetailsForEmail',
|
|
];
|
|
for (const helperName of allHelperNames) {
|
|
if (helperName !== expectedHelperName) {
|
|
assert.isTrue(stripeHelper[helperName].notCalled);
|
|
} else {
|
|
assert.isTrue(stripeHelper[helperName].called);
|
|
assert.deepEqual(stripeHelper[helperName].args[0], args);
|
|
}
|
|
}
|
|
}
|
|
|
|
it('calls the expected helper method for cancellation, with retrieveUpcoming error', async () => {
|
|
const error = new Error('Stripe error');
|
|
error.type = 'StripeInvalidRequestError';
|
|
error.code = 'invoice_upcoming_none';
|
|
mockStripe.invoices.retrieveUpcoming = sinon.stub().rejects(error);
|
|
const event = deepCopy(eventCustomerSubscriptionUpdated);
|
|
event.data.object.cancel_at_period_end = true;
|
|
event.data.previous_attributes = {
|
|
cancel_at_period_end: false,
|
|
latest_invoice: 'mock_latest_invoice_id',
|
|
};
|
|
const result =
|
|
await stripeHelper.extractSubscriptionUpdateEventDetailsForEmail(
|
|
event
|
|
);
|
|
assert.equal(result, mockCancellationDetails);
|
|
assertOnlyExpectedHelperCalledWith(
|
|
'extractSubscriptionUpdateCancellationDetailsForEmail',
|
|
event.data.object,
|
|
expectedBaseUpdateDetails,
|
|
mockInvoice,
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('rejects if invoices.retrieveUpcoming errors with unexpected error', async () => {
|
|
const error = new Error('Stripe error');
|
|
error.type = 'unexpected';
|
|
mockStripe.invoices.retrieveUpcoming = sinon.stub().rejects(error);
|
|
const event = deepCopy(eventCustomerSubscriptionUpdated);
|
|
event.data.object.cancel_at_period_end = true;
|
|
event.data.previous_attributes = {
|
|
cancel_at_period_end: false,
|
|
latest_invoice: 'mock_latest_invoice_id',
|
|
};
|
|
try {
|
|
await stripeHelper.extractSubscriptionUpdateEventDetailsForEmail(
|
|
event
|
|
);
|
|
} catch (err) {
|
|
assert.equal(err.type, 'unexpected');
|
|
}
|
|
assert.isTrue(
|
|
stripeHelper['extractSubscriptionUpdateCancellationDetailsForEmail']
|
|
.notCalled
|
|
);
|
|
});
|
|
|
|
it('calls the expected helper method for cancellation', async () => {
|
|
const mockInvoiceUpcomingWithData = {
|
|
...mockInvoiceUpcoming,
|
|
lines: {
|
|
data: [{ type: 'invoiceitem' }],
|
|
},
|
|
};
|
|
mockStripe.invoices.retrieveUpcoming = sinon
|
|
.stub()
|
|
.resolves(mockInvoiceUpcomingWithData);
|
|
const event = deepCopy(eventCustomerSubscriptionUpdated);
|
|
event.data.object.cancel_at_period_end = true;
|
|
event.data.previous_attributes = {
|
|
cancel_at_period_end: false,
|
|
latest_invoice: 'mock_latest_invoice_id',
|
|
};
|
|
const result =
|
|
await stripeHelper.extractSubscriptionUpdateEventDetailsForEmail(
|
|
event
|
|
);
|
|
assert.equal(result, mockCancellationDetails);
|
|
assertOnlyExpectedHelperCalledWith(
|
|
'extractSubscriptionUpdateCancellationDetailsForEmail',
|
|
event.data.object,
|
|
expectedBaseUpdateDetails,
|
|
mockInvoice,
|
|
mockInvoiceUpcomingWithData
|
|
);
|
|
});
|
|
|
|
it('calls the expected helper method for reactivation', async () => {
|
|
const event = deepCopy(eventCustomerSubscriptionUpdated);
|
|
event.data.object.cancel_at_period_end = false;
|
|
event.data.previous_attributes = {
|
|
cancel_at_period_end: true,
|
|
latest_invoice: 'mock_latest_invoice_id',
|
|
};
|
|
const result =
|
|
await stripeHelper.extractSubscriptionUpdateEventDetailsForEmail(
|
|
event
|
|
);
|
|
assert.equal(result, mockReactivationDetails);
|
|
assertOnlyExpectedHelperCalledWith(
|
|
'extractSubscriptionUpdateReactivationDetailsForEmail',
|
|
event.data.object,
|
|
expectedBaseUpdateDetails
|
|
);
|
|
});
|
|
|
|
it('calls the helper method when latest_invoice is not present', async () => {
|
|
const expected = {
|
|
...expectedBaseUpdateDetails,
|
|
invoiceTaxOldInCents: undefined,
|
|
invoiceTotalOldInCents: undefined,
|
|
};
|
|
const event = deepCopy(eventCustomerSubscriptionUpdated);
|
|
event.data.object.cancel_at_period_end = false;
|
|
event.data.previous_attributes = {
|
|
cancel_at_period_end: true,
|
|
latest_invoice: undefined,
|
|
};
|
|
const result =
|
|
await stripeHelper.extractSubscriptionUpdateEventDetailsForEmail(
|
|
event
|
|
);
|
|
assert.equal(result, mockReactivationDetails);
|
|
assertOnlyExpectedHelperCalledWith(
|
|
'extractSubscriptionUpdateReactivationDetailsForEmail',
|
|
event.data.object,
|
|
expected
|
|
);
|
|
});
|
|
|
|
it('calls the expected helper method for upgrade or downgrade', async () => {
|
|
const event = deepCopy(eventCustomerSubscriptionUpdated);
|
|
event.data.object.cancel_at_period_end = false;
|
|
event.data.previous_attributes.cancel_at_period_end = false;
|
|
event.data.previous_attributes.latest_invoice =
|
|
'mock_latest_invoice_id';
|
|
const result =
|
|
await stripeHelper.extractSubscriptionUpdateEventDetailsForEmail(
|
|
event
|
|
);
|
|
assert.equal(result, mockUpgradeDowngradeDetails);
|
|
const oldPlan = {
|
|
...event.data.object.plan,
|
|
...event.data.previous_attributes.plan,
|
|
};
|
|
assertOnlyExpectedHelperCalledWith(
|
|
'extractSubscriptionUpdateUpgradeDowngradeDetailsForEmail',
|
|
event.data.object,
|
|
expectedBaseUpdateDetails,
|
|
mockInvoice,
|
|
undefined,
|
|
oldPlan
|
|
);
|
|
});
|
|
|
|
it('calls the expected helper method for upgrade or downgrade if previously cancelled', async () => {
|
|
const event = deepCopy(eventCustomerSubscriptionUpdated);
|
|
event.data.object.cancel_at_period_end = false;
|
|
event.data.previous_attributes.cancel_at_period_end = true;
|
|
event.data.previous_attributes.latest_invoice =
|
|
'mock_latest_invoice_id';
|
|
const result =
|
|
await stripeHelper.extractSubscriptionUpdateEventDetailsForEmail(
|
|
event
|
|
);
|
|
assert.equal(result, mockUpgradeDowngradeDetails);
|
|
const oldPlan = {
|
|
...event.data.object.plan,
|
|
...event.data.previous_attributes.plan,
|
|
};
|
|
assertOnlyExpectedHelperCalledWith(
|
|
'extractSubscriptionUpdateUpgradeDowngradeDetailsForEmail',
|
|
event.data.object,
|
|
expectedBaseUpdateDetails,
|
|
mockInvoice,
|
|
undefined,
|
|
oldPlan
|
|
);
|
|
});
|
|
|
|
it('includes the Firestore based plan config when available', async () => {
|
|
const mockPlanConfig = { firestore: 'yes' };
|
|
sandbox
|
|
.stub(stripeHelper, 'maybeGetPlanConfig')
|
|
.resolves(mockPlanConfig);
|
|
const event = deepCopy(eventCustomerSubscriptionUpdated);
|
|
event.data.object.cancel_at_period_end = true;
|
|
event.data.previous_attributes = {
|
|
cancel_at_period_end: false,
|
|
latest_invoice: 'mock_latest_invoice_id',
|
|
};
|
|
const result =
|
|
await stripeHelper.extractSubscriptionUpdateEventDetailsForEmail(
|
|
event
|
|
);
|
|
assert.equal(result, mockCancellationDetails);
|
|
assertOnlyExpectedHelperCalledWith(
|
|
'extractSubscriptionUpdateCancellationDetailsForEmail',
|
|
event.data.object,
|
|
{ ...expectedBaseUpdateDetails, planConfig: mockPlanConfig },
|
|
mockInvoice,
|
|
undefined
|
|
);
|
|
});
|
|
});
|
|
|
|
const productNameOld = '123 Done Pro Plus Monthly';
|
|
const productIconURLOld = 'http://example.com/icon-old';
|
|
const productDownloadURLOld = 'http://example.com/download-old';
|
|
const productNameNew = '123 Done Pro Monthly';
|
|
const productIconURLNew = 'http://example.com/icon-new';
|
|
const productDownloadURLNew = 'http://example.com/download-new';
|
|
|
|
describe('extractSubscriptionUpdateUpgradeDowngradeDetailsForEmail', () => {
|
|
const commonTest =
|
|
(upcomingInvoice = undefined, expectedPaymentProratedInCents = 0) =>
|
|
async () => {
|
|
const event = deepCopy(eventCustomerSubscriptionUpdated);
|
|
const productIdOld = event.data.previous_attributes.plan.product;
|
|
const productIdNew = event.data.object.plan.product;
|
|
|
|
const baseDetails = {
|
|
...expectedBaseUpdateDetails,
|
|
productIdNew,
|
|
productNameNew,
|
|
productIconURLNew,
|
|
productMetadata: {
|
|
...expectedBaseUpdateDetails.productMetadata,
|
|
emailIconURL: productIconURLNew,
|
|
successActionButtonURL: productDownloadURLNew,
|
|
},
|
|
};
|
|
|
|
mockAllAbbrevProducts.push(
|
|
{
|
|
product_id: productIdOld,
|
|
product_name: productNameOld,
|
|
product_metadata: {
|
|
...mockProduct.metadata,
|
|
emailIconUrl: productIconURLOld,
|
|
successActionButtonURL: productDownloadURLOld,
|
|
},
|
|
},
|
|
{
|
|
product_id: productIdNew,
|
|
product_name: productNameNew,
|
|
product_metadata: {
|
|
...mockProduct.metadata,
|
|
emailIconUrl: productIconURLNew,
|
|
successActionButtonURL: productDownloadURLNew,
|
|
},
|
|
}
|
|
);
|
|
mockAllAbbrevPlans.unshift(
|
|
{
|
|
...event.data.previous_attributes.plan,
|
|
plan_id: event.data.previous_attributes.plan.id,
|
|
product_id: productIdOld,
|
|
plan_metadata: event.data.previous_attributes.plan.metadata,
|
|
},
|
|
{
|
|
...event.data.object.plan,
|
|
plan_id: event.data.object.plan.id,
|
|
product_id: productIdNew,
|
|
plan_metadata: event.data.object.plan.metadata,
|
|
}
|
|
);
|
|
|
|
sandbox.stub(stripeHelper, 'getSubsequentPrices').resolves({
|
|
exclusiveTax: 0,
|
|
total: upcomingInvoice.total,
|
|
});
|
|
|
|
const result =
|
|
await stripeHelper.extractSubscriptionUpdateUpgradeDowngradeDetailsForEmail(
|
|
event.data.object,
|
|
baseDetails,
|
|
mockInvoice,
|
|
upcomingInvoice,
|
|
event.data.previous_attributes.plan
|
|
);
|
|
|
|
assert.deepEqual(result, {
|
|
...baseDetails,
|
|
productIdNew,
|
|
updateType: SUBSCRIPTION_UPDATE_TYPES.UPGRADE,
|
|
invoiceAmountDueInCents: upcomingInvoice.amount_due,
|
|
productIdOld,
|
|
productNameOld,
|
|
productIconURLOld,
|
|
productPaymentCycleOld:
|
|
event.data.previous_attributes.plan.interval,
|
|
paymentAmountOldCurrency:
|
|
event.data.previous_attributes.plan.currency,
|
|
paymentAmountOldInCents: baseDetails.invoiceTotalOldInCents,
|
|
paymentAmountNewCurrency: upcomingInvoice.currency,
|
|
paymentAmountNewInCents: upcomingInvoice.total,
|
|
paymentTaxNewInCents: 0,
|
|
paymentTaxOldInCents: baseDetails.invoiceTaxOldInCents,
|
|
paymentProratedCurrency: mockInvoice.currency,
|
|
paymentProratedInCents: mockInvoice.total,
|
|
invoiceNumber: mockInvoice.number,
|
|
invoiceId: mockInvoice.id,
|
|
});
|
|
};
|
|
|
|
it(
|
|
'extracts expected details for a subscription upgrade',
|
|
commonTest({
|
|
currency: 'usd',
|
|
total: 1234,
|
|
})
|
|
);
|
|
|
|
it('checks productPaymentCycleOld returns a value if it is not included in the old plan', async () => {
|
|
const event = deepCopy(eventCustomerSubscriptionUpdated);
|
|
|
|
// if the interval of old and new plans are the same,
|
|
// the old plan's previous_attributes object may not include interval value.
|
|
event.data.previous_attributes.interval = undefined;
|
|
|
|
const productIdOld = event.data.previous_attributes.plan.product;
|
|
const productIdNew = event.data.object.plan.product;
|
|
|
|
const baseDetails = {
|
|
...expectedBaseUpdateDetails,
|
|
productIdNew,
|
|
productNameNew,
|
|
productIconURLNew,
|
|
productMetadata: {
|
|
...expectedBaseUpdateDetails.productMetadata,
|
|
emailIconURL: productIconURLNew,
|
|
successActionButtonURL: productDownloadURLNew,
|
|
},
|
|
};
|
|
|
|
mockAllAbbrevProducts.push(
|
|
{
|
|
product_id: productIdOld,
|
|
product_name: productNameOld,
|
|
product_metadata: {
|
|
...mockProduct.metadata,
|
|
emailIconUrl: productIconURLOld,
|
|
successActionButtonURL: productDownloadURLOld,
|
|
},
|
|
},
|
|
{
|
|
product_id: productIdNew,
|
|
product_name: productNameNew,
|
|
product_metadata: {
|
|
...mockProduct.metadata,
|
|
emailIconUrl: productIconURLNew,
|
|
successActionButtonURL: productDownloadURLNew,
|
|
},
|
|
}
|
|
);
|
|
|
|
mockAllAbbrevPlans.unshift(
|
|
{
|
|
...event.data.previous_attributes.plan,
|
|
plan_id: event.data.previous_attributes.plan.id,
|
|
product_id: productIdOld,
|
|
plan_metadata: event.data.previous_attributes.plan.metadata,
|
|
},
|
|
{
|
|
...event.data.object.plan,
|
|
plan_id: event.data.object.plan.id,
|
|
product_id: productIdNew,
|
|
plan_metadata: event.data.object.plan.metadata,
|
|
}
|
|
);
|
|
|
|
const result =
|
|
await stripeHelper.extractSubscriptionUpdateUpgradeDowngradeDetailsForEmail(
|
|
event.data.object,
|
|
baseDetails,
|
|
mockInvoice,
|
|
undefined,
|
|
event.data.previous_attributes.plan
|
|
);
|
|
|
|
assert.equal(
|
|
result.productPaymentCycleOld,
|
|
result.productPaymentCycleNew
|
|
);
|
|
});
|
|
|
|
it(
|
|
'extracts expected details for a subscription upgrade with pending invoice items',
|
|
commonTest({
|
|
currency: 'usd',
|
|
total: 1234,
|
|
lines: {
|
|
data: [
|
|
{ type: 'invoiceitem', amount: -500 },
|
|
{ type: 'invoiceitem', amount: 2500 },
|
|
],
|
|
},
|
|
})
|
|
);
|
|
});
|
|
|
|
describe('extractSubscriptionUpdateReactivationDetailsForEmail', () => {
|
|
const { card } = mockCharge.payment_method_details;
|
|
const defaultExpected = {
|
|
updateType: SUBSCRIPTION_UPDATE_TYPES.REACTIVATION,
|
|
email,
|
|
uid,
|
|
productId,
|
|
planId,
|
|
planConfig: {},
|
|
planEmailIconURL: productIconURLNew,
|
|
productName,
|
|
invoiceTotalInCents: mockInvoice.total,
|
|
invoiceTotalCurrency: mockInvoice.currency,
|
|
cardType: card.brand,
|
|
lastFour: card.last4,
|
|
nextInvoiceDate: new Date(mockInvoice.lines.data[0].period.end * 1000),
|
|
};
|
|
|
|
const { lastFour, cardType } = defaultExpected;
|
|
|
|
const mockCustomer = {
|
|
invoice_settings: {
|
|
default_payment_method: {
|
|
card: {
|
|
last4: lastFour,
|
|
brand: cardType,
|
|
country: 'US',
|
|
},
|
|
billing_details: {
|
|
address: {
|
|
postal_code: '99999',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
beforeEach(() => {
|
|
expandMock.onCall(0).returns(mockCharge.payment_method_details);
|
|
});
|
|
|
|
it('extracts expected details for a subscription reactivation', async () => {
|
|
const event = deepCopy(eventCustomerSubscriptionUpdated);
|
|
sandbox.stub(stripeHelper, 'fetchCustomer').resolves(mockCustomer);
|
|
const result =
|
|
await stripeHelper.extractSubscriptionUpdateReactivationDetailsForEmail(
|
|
event.data.object,
|
|
expectedBaseUpdateDetails,
|
|
mockInvoice
|
|
);
|
|
assert.deepEqual(mockStripe.invoices.retrieveUpcoming.args, [
|
|
[
|
|
{
|
|
subscription: event.data.object.id,
|
|
},
|
|
],
|
|
]);
|
|
assert.deepEqual(result, defaultExpected);
|
|
});
|
|
|
|
it('does not throw an exception when payment method is missing', async () => {
|
|
const event = deepCopy(eventCustomerSubscriptionUpdated);
|
|
const customer = deepCopy(mockCustomer);
|
|
customer.invoice_settings.default_payment_method = null;
|
|
sandbox.stub(stripeHelper, 'fetchCustomer').resolves(customer);
|
|
const result =
|
|
await stripeHelper.extractSubscriptionUpdateReactivationDetailsForEmail(
|
|
event.data.object,
|
|
expectedBaseUpdateDetails,
|
|
mockInvoice
|
|
);
|
|
assert.deepEqual(result, {
|
|
...defaultExpected,
|
|
lastFour: null,
|
|
cardType: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('extractCustomerDefaultPaymentDetailsByUid', () => {
|
|
it('fetches the customer and calls extractCustomerDefaultPaymentDetails', async () => {
|
|
const paymentDetails = {
|
|
lastFour: '4242',
|
|
cardType: 'Moz',
|
|
country: 'GD',
|
|
postalCode: '99999',
|
|
};
|
|
sandbox.stub(stripeHelper, 'fetchCustomer').resolves(customer1);
|
|
sandbox
|
|
.stub(stripeHelper, 'extractCustomerDefaultPaymentDetails')
|
|
.resolves(paymentDetails);
|
|
const actual =
|
|
await stripeHelper.extractCustomerDefaultPaymentDetailsByUid(uid);
|
|
assert.deepEqual(actual, paymentDetails);
|
|
sinon.assert.calledOnceWithExactly(stripeHelper.fetchCustomer, uid, [
|
|
'invoice_settings.default_payment_method',
|
|
]);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.extractCustomerDefaultPaymentDetails,
|
|
customer1
|
|
);
|
|
});
|
|
|
|
it('throws for a deleted customer', async () => {
|
|
sandbox.stub(stripeHelper, 'fetchCustomer').resolves(null);
|
|
let thrown;
|
|
try {
|
|
await stripeHelper.extractCustomerDefaultPaymentDetailsByUid(uid);
|
|
} catch (err) {
|
|
thrown = err;
|
|
}
|
|
assert.equal(thrown.errno, error.ERRNO.UNKNOWN_SUBSCRIPTION_CUSTOMER);
|
|
});
|
|
});
|
|
|
|
describe('extractCustomerDefaultPaymentDetails', () => {
|
|
const mockPaymentMethod = {
|
|
card: {
|
|
last4: '4321',
|
|
brand: 'Mastercard',
|
|
country: 'US',
|
|
},
|
|
billing_details: {
|
|
address: {
|
|
postal_code: '99999',
|
|
},
|
|
},
|
|
};
|
|
|
|
const mockSource = {
|
|
id: sourceId,
|
|
last4: '0987',
|
|
brand: 'Visa',
|
|
country: 'US',
|
|
};
|
|
|
|
const mockCustomer = {
|
|
invoice_settings: {
|
|
default_payment_method: mockPaymentMethod,
|
|
},
|
|
default_source: mockSource.id,
|
|
sources: {
|
|
data: [mockSource],
|
|
},
|
|
};
|
|
|
|
beforeEach(() => {
|
|
expandMock.onCall(0).returns(mockPaymentMethod);
|
|
});
|
|
|
|
it('extracts from default payment method first when available', async () => {
|
|
const result =
|
|
await stripeHelper.extractCustomerDefaultPaymentDetails(mockCustomer);
|
|
assert.deepEqual(result, {
|
|
lastFour: mockPaymentMethod.card.last4,
|
|
cardType: mockPaymentMethod.card.brand,
|
|
country: mockPaymentMethod.card.country,
|
|
postalCode: mockPaymentMethod.billing_details.address.postal_code,
|
|
});
|
|
});
|
|
|
|
it('does not include the postal code when address is not available in payment method', async () => {
|
|
const customer = deepCopy(mockCustomer);
|
|
delete customer.invoice_settings.default_payment_method.billing_details
|
|
.address;
|
|
const result =
|
|
await stripeHelper.extractCustomerDefaultPaymentDetails(customer);
|
|
assert.deepEqual(result, {
|
|
lastFour: mockPaymentMethod.card.last4,
|
|
cardType: mockPaymentMethod.card.brand,
|
|
country: mockPaymentMethod.card.country,
|
|
postalCode: null,
|
|
});
|
|
});
|
|
|
|
it('extracts from default source when available', async () => {
|
|
expandMock.onCall(0).resolves(mockPaymentMethod);
|
|
const customer = deepCopy(mockCustomer);
|
|
customer.invoice_settings.default_payment_method = null;
|
|
const result =
|
|
await stripeHelper.extractCustomerDefaultPaymentDetails(customer);
|
|
assert.deepEqual(result, {
|
|
lastFour: mockPaymentMethod.card.last4,
|
|
cardType: mockPaymentMethod.card.brand,
|
|
country: mockPaymentMethod.card.country,
|
|
postalCode: mockPaymentMethod.billing_details.address.postal_code,
|
|
});
|
|
});
|
|
|
|
it('does not include the postal code when address is not available in source', async () => {
|
|
const noAddressPaymentMethod = deepCopy(mockPaymentMethod);
|
|
delete noAddressPaymentMethod.billing_details.address;
|
|
expandMock.onCall(0).resolves(noAddressPaymentMethod);
|
|
const customer = deepCopy(mockCustomer);
|
|
customer.invoice_settings.default_payment_method = null;
|
|
const result =
|
|
await stripeHelper.extractCustomerDefaultPaymentDetails(customer);
|
|
assert.deepEqual(result, {
|
|
lastFour: mockPaymentMethod.card.last4,
|
|
cardType: mockPaymentMethod.card.brand,
|
|
country: mockPaymentMethod.card.country,
|
|
postalCode: null,
|
|
});
|
|
});
|
|
|
|
it('returns undefined details when neither default payment method nor source is available', async () => {
|
|
const customer = deepCopy(mockCustomer);
|
|
customer.invoice_settings.default_payment_method = null;
|
|
customer.default_source = null;
|
|
const result =
|
|
await stripeHelper.extractCustomerDefaultPaymentDetails(customer);
|
|
assert.deepEqual(result, {
|
|
lastFour: null,
|
|
cardType: null,
|
|
country: null,
|
|
postalCode: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('extractSubscriptionUpdateCancellationDetailsForEmail', () => {
|
|
it('extracts expected details for a subscription cancellation', async () => {
|
|
const event = deepCopy(eventCustomerSubscriptionUpdated);
|
|
const result =
|
|
await stripeHelper.extractSubscriptionUpdateCancellationDetailsForEmail(
|
|
event.data.object,
|
|
expectedBaseUpdateDetails,
|
|
mockInvoice,
|
|
undefined
|
|
);
|
|
const subscription = event.data.object;
|
|
assert.deepEqual(result, {
|
|
updateType: SUBSCRIPTION_UPDATE_TYPES.CANCELLATION,
|
|
email,
|
|
uid,
|
|
productId,
|
|
planId,
|
|
planConfig: {},
|
|
planEmailIconURL: productIconURLNew,
|
|
productName,
|
|
invoiceDate: new Date(mockInvoice.created * 1000),
|
|
invoiceTotalInCents: mockInvoice.total,
|
|
invoiceTotalCurrency: mockInvoice.currency,
|
|
serviceLastActiveDate: new Date(
|
|
subscription.current_period_end * 1000
|
|
),
|
|
productMetadata: expectedBaseUpdateDetails.productMetadata,
|
|
showOutstandingBalance: false,
|
|
});
|
|
});
|
|
|
|
it('extracts expected details for a subscription cancellation with pending invoice items', async () => {
|
|
const mockUpcomingInvoice = {
|
|
total: '40839',
|
|
currency: 'usd',
|
|
created: 1666968725952,
|
|
};
|
|
const event = deepCopy(eventCustomerSubscriptionUpdated);
|
|
const result =
|
|
await stripeHelper.extractSubscriptionUpdateCancellationDetailsForEmail(
|
|
event.data.object,
|
|
expectedBaseUpdateDetails,
|
|
mockInvoice,
|
|
mockUpcomingInvoice
|
|
);
|
|
const subscription = event.data.object;
|
|
assert.deepEqual(result, {
|
|
updateType: SUBSCRIPTION_UPDATE_TYPES.CANCELLATION,
|
|
email,
|
|
uid,
|
|
productId,
|
|
planId,
|
|
planConfig: {},
|
|
planEmailIconURL: productIconURLNew,
|
|
productName,
|
|
invoiceDate: new Date(mockUpcomingInvoice.created * 1000),
|
|
invoiceTotalInCents: mockUpcomingInvoice.total,
|
|
invoiceTotalCurrency: mockUpcomingInvoice.currency,
|
|
serviceLastActiveDate: new Date(
|
|
subscription.current_period_end * 1000
|
|
),
|
|
productMetadata: expectedBaseUpdateDetails.productMetadata,
|
|
showOutstandingBalance: true,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('expandResource', () => {
|
|
let customer;
|
|
|
|
beforeEach(() => {
|
|
customer = deepCopy(customer1);
|
|
});
|
|
|
|
it('expands the customer', async () => {
|
|
stripeFirestore.retrieveAndFetchCustomer = sandbox
|
|
.stub()
|
|
.resolves(deepCopy(customer));
|
|
stripeFirestore.retrieveCustomerSubscriptions = sandbox
|
|
.stub()
|
|
.resolves(deepCopy(customer.subscriptions.data));
|
|
const result = await stripeHelper.expandResource(
|
|
customer.id,
|
|
CUSTOMER_RESOURCE
|
|
);
|
|
// Note that top level will mismatch because subscriptions is copied
|
|
// without the object type.
|
|
assert.deepEqual(result.subscriptions.data, customer.subscriptions.data);
|
|
assert.hasAllKeys(result, customer);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripeFirestore.retrieveAndFetchCustomer,
|
|
customer.id,
|
|
true
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripeFirestore.retrieveCustomerSubscriptions,
|
|
customer.id,
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('includes the empty subscriptions list on the expanded customer', async () => {
|
|
stripeFirestore.retrieveAndFetchCustomer = sandbox
|
|
.stub()
|
|
.resolves(deepCopy(customer));
|
|
stripeFirestore.retrieveCustomerSubscriptions = sandbox
|
|
.stub()
|
|
.resolves([]);
|
|
const result = await stripeHelper.expandResource(
|
|
customer.id,
|
|
CUSTOMER_RESOURCE
|
|
);
|
|
assert.deepEqual(result.subscriptions.data, []);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripeFirestore.retrieveAndFetchCustomer,
|
|
customer.id,
|
|
true
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripeFirestore.retrieveCustomerSubscriptions,
|
|
customer.id,
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('expands the subscription', async () => {
|
|
stripeFirestore.retrieveAndFetchSubscription = sandbox
|
|
.stub()
|
|
.resolves(deepCopy(subscription1));
|
|
const result = await stripeHelper.expandResource(
|
|
subscription1.id,
|
|
SUBSCRIPTIONS_RESOURCE
|
|
);
|
|
assert.deepEqual(result, subscription1);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripeFirestore.retrieveAndFetchSubscription,
|
|
subscription1.id,
|
|
true
|
|
);
|
|
});
|
|
|
|
it('expands the invoice', async () => {
|
|
stripeFirestore.retrieveInvoice = sandbox
|
|
.stub()
|
|
.resolves(invoicePaidSubscriptionCreate);
|
|
const result = await stripeHelper.expandResource(
|
|
invoicePaidSubscriptionCreate.id,
|
|
INVOICES_RESOURCE
|
|
);
|
|
assert.deepEqual(result, invoicePaidSubscriptionCreate);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripeFirestore.retrieveInvoice,
|
|
invoicePaidSubscriptionCreate.id
|
|
);
|
|
});
|
|
|
|
it('expands invoice when invoice isnt found and inserts it', async () => {
|
|
stripeFirestore.retrieveInvoice = sandbox
|
|
.stub()
|
|
.rejects(
|
|
newFirestoreStripeError(
|
|
'not found',
|
|
FirestoreStripeError.FIRESTORE_INVOICE_NOT_FOUND
|
|
)
|
|
);
|
|
stripeFirestore.retrieveAndFetchCustomer = sandbox
|
|
.stub()
|
|
.resolves(customer);
|
|
stripeHelper.stripe.invoices.retrieve = sandbox
|
|
.stub()
|
|
.resolves(deepCopy(invoicePaidSubscriptionCreate));
|
|
stripeFirestore.insertInvoiceRecord = sandbox.stub().resolves({});
|
|
|
|
const result = await stripeHelper.expandResource(
|
|
invoicePaidSubscriptionCreate.id,
|
|
INVOICES_RESOURCE
|
|
);
|
|
assert.deepEqual(result, invoicePaidSubscriptionCreate);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripeFirestore.retrieveInvoice,
|
|
invoicePaidSubscriptionCreate.id
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripeFirestore.retrieveAndFetchCustomer,
|
|
invoicePaidSubscriptionCreate.customer,
|
|
true
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('processWebhookEventToFirestore', () => {
|
|
let stripeFirestore;
|
|
|
|
beforeEach(() => {
|
|
stripeHelper.stripeFirestore = stripeFirestore = {};
|
|
});
|
|
|
|
it('handles invoice operations with firestore invoice', async () => {
|
|
const event = deepCopy(eventInvoiceCreated);
|
|
stripeFirestore.retrieveAndFetchSubscription = sandbox
|
|
.stub()
|
|
.resolves({});
|
|
stripeHelper.stripe.invoices.retrieve = sandbox
|
|
.stub()
|
|
.resolves(invoicePaidSubscriptionCreate);
|
|
stripeFirestore.retrieveInvoice = sandbox.stub().resolves({});
|
|
stripeFirestore.fetchAndInsertInvoice = sandbox.stub().resolves({});
|
|
const result = await stripeHelper.processWebhookEventToFirestore(event);
|
|
assert.isTrue(result);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripeFirestore.fetchAndInsertInvoice,
|
|
eventInvoiceCreated.data.object.id,
|
|
eventInvoiceCreated.created
|
|
);
|
|
});
|
|
|
|
it('handles invoice operations with no firestore invoice', async () => {
|
|
const event = deepCopy(eventInvoiceCreated);
|
|
stripeFirestore.retrieveAndFetchSubscription = sandbox
|
|
.stub()
|
|
.resolves({});
|
|
const insertStub = sandbox.stub();
|
|
stripeHelper.stripe.invoices.retrieve = sandbox
|
|
.stub()
|
|
.resolves(invoicePaidSubscriptionCreate);
|
|
stripeFirestore.fetchAndInsertInvoice = insertStub;
|
|
insertStub
|
|
.onCall(0)
|
|
.rejects(
|
|
newFirestoreStripeError(
|
|
'no invoice',
|
|
FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND
|
|
)
|
|
);
|
|
insertStub.onCall(1).resolves({});
|
|
stripeFirestore.fetchAndInsertCustomer = sandbox.stub().resolves({});
|
|
const result = await stripeHelper.processWebhookEventToFirestore(event);
|
|
assert.isTrue(result);
|
|
sinon.assert.calledTwice(
|
|
stripeHelper.stripeFirestore.fetchAndInsertInvoice
|
|
);
|
|
sinon.assert.calledWithExactly(
|
|
stripeHelper.stripeFirestore.fetchAndInsertInvoice.getCall(0),
|
|
eventInvoiceCreated.data.object.id,
|
|
eventInvoiceCreated.created
|
|
);
|
|
sinon.assert.calledWithExactly(
|
|
stripeHelper.stripeFirestore.fetchAndInsertInvoice.getCall(1),
|
|
eventInvoiceCreated.data.object.id,
|
|
eventInvoiceCreated.created
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeFirestore.fetchAndInsertCustomer,
|
|
event.data.object.customer
|
|
);
|
|
});
|
|
|
|
for (const type of [
|
|
'customer.created',
|
|
'customer.updated',
|
|
'customer.deleted',
|
|
]) {
|
|
it(`handles ${type} operations`, async () => {
|
|
const event = deepCopy(eventCustomerUpdated);
|
|
event.type = type;
|
|
event.request = null;
|
|
stripeFirestore.fetchAndInsertCustomer = sandbox.stub().resolves({});
|
|
dbStub.getUidAndEmailByStripeCustomerId.resolves({
|
|
uid: newCustomer.metadata.userid,
|
|
});
|
|
await stripeHelper.processWebhookEventToFirestore(event);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripeFirestore.fetchAndInsertCustomer,
|
|
eventCustomerUpdated.data.object.id
|
|
);
|
|
});
|
|
}
|
|
|
|
for (const hasCurrency of [true, false]) {
|
|
for (const type of [
|
|
'customer.subscription.created',
|
|
'customer.subscription.updated',
|
|
]) {
|
|
it(`handles ${type} operations with currency: ${hasCurrency}`, async () => {
|
|
const event = deepCopy(eventSubscriptionUpdated);
|
|
event.type = type;
|
|
delete event.data.previous_attributes;
|
|
stripeHelper.stripe.subscriptions.retrieve = sandbox
|
|
.stub()
|
|
.resolves(subscription1);
|
|
const customer = deepCopy(newCustomer);
|
|
if (hasCurrency) {
|
|
customer.currency = 'usd';
|
|
}
|
|
stripeHelper.expandResource = sandbox.stub().resolves(customer);
|
|
stripeFirestore.retrieveSubscription = sandbox.stub().resolves({});
|
|
stripeFirestore.retrieveCustomer = sandbox.stub().resolves(customer);
|
|
stripeFirestore.fetchAndInsertCustomer = sandbox.stub().resolves({});
|
|
stripeFirestore.fetchAndInsertSubscription = sandbox
|
|
.stub()
|
|
.resolves({});
|
|
await stripeHelper.processWebhookEventToFirestore(event);
|
|
if (!hasCurrency) {
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripe.subscriptions.retrieve,
|
|
event.data.object.id
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripeFirestore.fetchAndInsertCustomer,
|
|
event.data.object.customer
|
|
);
|
|
} else {
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripeFirestore.fetchAndInsertSubscription,
|
|
event.data.object.id,
|
|
customer.metadata.userid
|
|
);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const type of [
|
|
'payment_method.attached',
|
|
'payment_method.card_automatically_updated',
|
|
'payment_method.updated',
|
|
]) {
|
|
it(`handles ${type} operations`, async () => {
|
|
const event = deepCopy(eventPaymentMethodAttached);
|
|
event.type = type;
|
|
delete event.data.previous_attributes;
|
|
stripeFirestore.fetchAndInsertPaymentMethod = sandbox
|
|
.stub()
|
|
.resolves({});
|
|
await stripeHelper.processWebhookEventToFirestore(event);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripeFirestore.fetchAndInsertPaymentMethod,
|
|
event.data.object.id,
|
|
event.created
|
|
);
|
|
});
|
|
|
|
it(`ignores ${type} operations with no customer attached to event`, async () => {
|
|
const event = deepCopy(eventPaymentMethodAttached);
|
|
event.type = type;
|
|
event.data.object.customer = null;
|
|
delete event.data.previous_attributes;
|
|
stripeFirestore.fetchAndInsertPaymentMethod = sandbox.stub();
|
|
await stripeHelper.processWebhookEventToFirestore(event);
|
|
sinon.assert.notCalled(
|
|
stripeHelper.stripeFirestore.fetchAndInsertPaymentMethod
|
|
);
|
|
});
|
|
}
|
|
|
|
it('handles payment_method.detached operations', async () => {
|
|
const event = deepCopy(eventPaymentMethodDetached);
|
|
stripeFirestore.removePaymentMethodRecord = sandbox.stub().resolves({});
|
|
await stripeHelper.processWebhookEventToFirestore(event);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.stripeFirestore.removePaymentMethodRecord,
|
|
event.data.object.id
|
|
);
|
|
});
|
|
|
|
it('ignores the deleted stripe customer error when handling a payment method update event', async () => {
|
|
const event = deepCopy(eventPaymentMethodAttached);
|
|
event.type = 'payment_method.card_automatically_updated';
|
|
stripeFirestore.fetchAndInsertPaymentMethod = sandbox
|
|
.stub()
|
|
.throws(
|
|
newFirestoreStripeError(
|
|
'Customer deleted.',
|
|
FirestoreStripeError.STRIPE_CUSTOMER_DELETED
|
|
)
|
|
);
|
|
await stripeHelper.processWebhookEventToFirestore(event);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeFirestore.fetchAndInsertPaymentMethod,
|
|
event.data.object.id,
|
|
event.created
|
|
);
|
|
});
|
|
|
|
it('ignores the firestore record not found error when handling a payment method update event', async () => {
|
|
const event = deepCopy(eventPaymentMethodAttached);
|
|
event.type = 'payment_method.card_automatically_updated';
|
|
stripeFirestore.fetchAndInsertPaymentMethod = sandbox
|
|
.stub()
|
|
.throws(
|
|
newFirestoreStripeError(
|
|
'Customer deleted.',
|
|
FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND
|
|
)
|
|
);
|
|
await stripeHelper.processWebhookEventToFirestore(event);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeFirestore.fetchAndInsertPaymentMethod,
|
|
event.data.object.id,
|
|
event.created
|
|
);
|
|
});
|
|
|
|
it('does not handle wibble events', async () => {
|
|
const event = deepCopy(eventSubscriptionUpdated);
|
|
event.type = 'wibble';
|
|
const result = await stripeHelper.processWebhookEventToFirestore(event);
|
|
assert.isFalse(result);
|
|
});
|
|
});
|
|
|
|
describe('getBillingDetailsAndSubscriptions', () => {
|
|
const customer = { id: 'cus_xyz', currency: 'usd' };
|
|
const billingDetails = { payment_provider: 'paypal' };
|
|
const billingAgreementId = 'ba-123';
|
|
const mockInvoice = { status: 'paid' };
|
|
let getLatestInvoicesForActiveSubscriptionsStub;
|
|
let getPaymentAttemptsStub;
|
|
|
|
beforeEach(() => {
|
|
sandbox.stub(stripeHelper, 'fetchCustomer').resolves(customer);
|
|
sandbox
|
|
.stub(stripeHelper, 'extractBillingDetails')
|
|
.resolves(billingDetails);
|
|
sandbox
|
|
.stub(stripeHelper, 'getCustomerPaypalAgreement')
|
|
.returns(billingAgreementId);
|
|
sandbox
|
|
.stub(stripeHelper, 'hasSubscriptionRequiringPaymentMethod')
|
|
.returns(true);
|
|
getLatestInvoicesForActiveSubscriptionsStub = sandbox
|
|
.stub(stripeHelper, 'getLatestInvoicesForActiveSubscriptions')
|
|
.resolves([mockInvoice]);
|
|
sandbox
|
|
.stub(stripeHelper, 'hasOpenInvoiceWithPaymentAttempts')
|
|
.resolves(true);
|
|
getPaymentAttemptsStub = sandbox
|
|
.stub(stripeHelper, 'getPaymentAttempts')
|
|
.returns(0);
|
|
});
|
|
|
|
it('returns null when no customer is found', async () => {
|
|
stripeHelper.fetchCustomer.restore();
|
|
sandbox.stub(stripeHelper, 'fetchCustomer').resolves(undefined);
|
|
|
|
const actual =
|
|
await stripeHelper.getBillingDetailsAndSubscriptions('uid');
|
|
|
|
assert.equal(actual, null);
|
|
sinon.assert.calledOnceWithExactly(stripeHelper.fetchCustomer, 'uid', [
|
|
'invoice_settings.default_payment_method',
|
|
]);
|
|
});
|
|
|
|
it('includes the customer Stripe billing details', async () => {
|
|
const billingDetails = { payment_provider: 'stripe' };
|
|
stripeHelper.extractBillingDetails.restore();
|
|
sandbox
|
|
.stub(stripeHelper, 'extractBillingDetails')
|
|
.resolves(billingDetails);
|
|
|
|
const actual =
|
|
await stripeHelper.getBillingDetailsAndSubscriptions('uid');
|
|
|
|
assert.deepEqual(actual, {
|
|
customerId: customer.id,
|
|
customerCurrency: customer.currency,
|
|
subscriptions: [],
|
|
...billingDetails,
|
|
});
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.extractBillingDetails,
|
|
customer
|
|
);
|
|
});
|
|
|
|
it('includes the customer PayPal billing details', async () => {
|
|
stripeHelper.hasSubscriptionRequiringPaymentMethod.restore();
|
|
sandbox
|
|
.stub(stripeHelper, 'hasSubscriptionRequiringPaymentMethod')
|
|
.returns(false);
|
|
|
|
const actual =
|
|
await stripeHelper.getBillingDetailsAndSubscriptions('uid');
|
|
|
|
assert.deepEqual(actual, {
|
|
customerId: customer.id,
|
|
customerCurrency: customer.currency,
|
|
subscriptions: [],
|
|
billing_agreement_id: billingAgreementId,
|
|
...billingDetails,
|
|
});
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.getCustomerPaypalAgreement,
|
|
customer
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.hasSubscriptionRequiringPaymentMethod,
|
|
customer
|
|
);
|
|
});
|
|
|
|
it('includes the missing billing agreement error state', async () => {
|
|
stripeHelper.getCustomerPaypalAgreement.restore();
|
|
sandbox.stub(stripeHelper, 'getCustomerPaypalAgreement').returns(null);
|
|
|
|
const actual =
|
|
await stripeHelper.getBillingDetailsAndSubscriptions('uid');
|
|
|
|
assert.deepEqual(actual, {
|
|
customerId: customer.id,
|
|
customerCurrency: customer.currency,
|
|
subscriptions: [],
|
|
billing_agreement_id: null,
|
|
paypal_payment_error: PAYPAL_PAYMENT_ERROR_MISSING_AGREEMENT,
|
|
...billingDetails,
|
|
});
|
|
});
|
|
|
|
it('includes the funding source error state', async () => {
|
|
const openInvoice = { status: 'open' };
|
|
getLatestInvoicesForActiveSubscriptionsStub.resolves([openInvoice]);
|
|
getPaymentAttemptsStub.returns(1);
|
|
const actual =
|
|
await stripeHelper.getBillingDetailsAndSubscriptions('uid');
|
|
|
|
assert.deepEqual(actual, {
|
|
customerId: customer.id,
|
|
customerCurrency: customer.currency,
|
|
subscriptions: [],
|
|
billing_agreement_id: billingAgreementId,
|
|
paypal_payment_error: PAYPAL_PAYMENT_ERROR_FUNDING_SOURCE,
|
|
...billingDetails,
|
|
});
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.hasOpenInvoiceWithPaymentAttempts,
|
|
customer
|
|
);
|
|
});
|
|
|
|
it('excludes funding source error state with open invoices but no payment attempts', async () => {
|
|
const openInvoice = { status: 'open' };
|
|
getLatestInvoicesForActiveSubscriptionsStub.resolves([openInvoice]);
|
|
stripeHelper.hasOpenInvoiceWithPaymentAttempts.restore();
|
|
sandbox
|
|
.stub(stripeHelper, 'hasOpenInvoiceWithPaymentAttempts')
|
|
.returns(false);
|
|
const actual =
|
|
await stripeHelper.getBillingDetailsAndSubscriptions('uid');
|
|
|
|
assert.deepEqual(actual, {
|
|
customerId: customer.id,
|
|
customerCurrency: customer.currency,
|
|
subscriptions: [],
|
|
billing_agreement_id: billingAgreementId,
|
|
...billingDetails,
|
|
});
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.hasOpenInvoiceWithPaymentAttempts,
|
|
customer
|
|
);
|
|
});
|
|
|
|
it('includes a list of subscriptions', async () => {
|
|
const subscriptions = { data: [{ id: 'sub_testo', status: 'active' }] };
|
|
stripeHelper.fetchCustomer.restore();
|
|
sandbox
|
|
.stub(stripeHelper, 'fetchCustomer')
|
|
.resolves({ ...customer, subscriptions });
|
|
sandbox
|
|
.stub(stripeHelper, 'subscriptionsToResponse')
|
|
.resolves(subscriptions);
|
|
stripeHelper.hasSubscriptionRequiringPaymentMethod.restore();
|
|
sandbox
|
|
.stub(stripeHelper, 'hasSubscriptionRequiringPaymentMethod')
|
|
.returns(false);
|
|
|
|
const actual =
|
|
await stripeHelper.getBillingDetailsAndSubscriptions('uid');
|
|
assert.deepEqual(actual, {
|
|
customerId: customer.id,
|
|
customerCurrency: customer.currency,
|
|
subscriptions,
|
|
billing_agreement_id: billingAgreementId,
|
|
...billingDetails,
|
|
});
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.subscriptionsToResponse,
|
|
subscriptions
|
|
);
|
|
});
|
|
|
|
it('filters out canceled subscriptions', async () => {
|
|
const subscriptions = {
|
|
data: [
|
|
{ id: 'sub_testo', status: 'active' },
|
|
{ id: 'sub_testo', status: 'canceled' },
|
|
],
|
|
};
|
|
stripeHelper.fetchCustomer.restore();
|
|
sandbox
|
|
.stub(stripeHelper, 'fetchCustomer')
|
|
.resolves({ ...customer, subscriptions });
|
|
sandbox
|
|
.stub(stripeHelper, 'subscriptionsToResponse')
|
|
.resolves(subscriptions);
|
|
|
|
await stripeHelper.getBillingDetailsAndSubscriptions('uid');
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.subscriptionsToResponse,
|
|
{
|
|
data: [{ id: 'sub_testo', status: 'active' }],
|
|
} // no canceled subs passed here
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('extractBillingDetails', () => {
|
|
const paymentProvider = { payment_provider: 'stripe' };
|
|
const sourceId = eventCustomerSourceExpiring.data.object.id;
|
|
const card = {
|
|
id: sourceId,
|
|
brand: 'visa',
|
|
exp_month: 8,
|
|
exp_year: new Date().getFullYear(),
|
|
funding: 'credit',
|
|
last4: '4242',
|
|
};
|
|
const invoice_settings = {
|
|
default_payment_method: {
|
|
billing_details: {
|
|
name: 'Testo McTestson',
|
|
},
|
|
card,
|
|
},
|
|
};
|
|
const source = { name: 'Testo McTestson', object: 'card', ...card };
|
|
const mockPaymentMethod = {
|
|
card,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
sandbox.stub(stripeHelper, 'getPaymentProvider').returns('stripe');
|
|
});
|
|
|
|
it('returns the correct payment provider', async () => {
|
|
const customer = { id: 'cus_xyz', invoice_settings: {} };
|
|
const actual = await stripeHelper.extractBillingDetails(customer);
|
|
|
|
assert.deepEqual(actual, paymentProvider);
|
|
sinon.assert.calledOnceWithExactly(
|
|
await stripeHelper.getPaymentProvider,
|
|
customer
|
|
);
|
|
});
|
|
|
|
it('returns the card details from the default payment method', async () => {
|
|
const customer = {
|
|
id: 'cus_xyz',
|
|
invoice_settings,
|
|
};
|
|
|
|
const actual = await stripeHelper.extractBillingDetails(customer);
|
|
|
|
assert.deepEqual(actual, {
|
|
...paymentProvider,
|
|
billing_name:
|
|
customer.invoice_settings.default_payment_method.billing_details.name,
|
|
payment_type:
|
|
customer.invoice_settings.default_payment_method.card.funding,
|
|
last4: customer.invoice_settings.default_payment_method.card.last4,
|
|
exp_month:
|
|
customer.invoice_settings.default_payment_method.card.exp_month,
|
|
exp_year:
|
|
customer.invoice_settings.default_payment_method.card.exp_year,
|
|
brand: customer.invoice_settings.default_payment_method.card.brand,
|
|
});
|
|
sinon.assert.calledOnceWithExactly(
|
|
await stripeHelper.getPaymentProvider,
|
|
customer
|
|
);
|
|
});
|
|
|
|
it('returns the card details from default source', async () => {
|
|
sandbox.stub(stripeHelper, 'expandResource').resolves(mockPaymentMethod);
|
|
const customer = {
|
|
id: 'cus_xyz',
|
|
default_source: card.id,
|
|
invoice_settings: {
|
|
default_payment_method: null,
|
|
},
|
|
sources: { data: [source] },
|
|
};
|
|
|
|
const actual = await stripeHelper.extractBillingDetails(customer);
|
|
assert.deepEqual(actual, {
|
|
...paymentProvider,
|
|
billing_name: mockPaymentMethod.card.name,
|
|
payment_type: mockPaymentMethod.card.funding,
|
|
last4: mockPaymentMethod.card.last4,
|
|
exp_month: mockPaymentMethod.card.exp_month,
|
|
exp_year: mockPaymentMethod.card.exp_year,
|
|
brand: mockPaymentMethod.card.brand,
|
|
});
|
|
sinon.assert.calledOnceWithExactly(
|
|
await stripeHelper.getPaymentProvider,
|
|
customer
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('setCustomerLocation', () => {
|
|
const err = new Error('testo');
|
|
const expectedAddressArg = {
|
|
line1: '',
|
|
line2: '',
|
|
city: '',
|
|
state: 'ABD',
|
|
country: 'GD',
|
|
postalCode: '99999',
|
|
};
|
|
let sentryScope;
|
|
|
|
beforeEach(() => {
|
|
sentryScope = { setContext: sandbox.stub(), setExtra: sandbox.stub() };
|
|
sandbox.stub(Sentry, 'withScope').callsFake((cb) => cb(sentryScope));
|
|
sandbox.stub(sentryModule, 'reportSentryMessage');
|
|
sandbox.stub(Sentry, 'setExtra');
|
|
sandbox.stub(Sentry, 'captureException');
|
|
});
|
|
|
|
it('updates the Stripe customer address', async () => {
|
|
sandbox.stub(stripeHelper, 'updateCustomerBillingAddress').resolves();
|
|
const result = await stripeHelper.setCustomerLocation({
|
|
customerId: customer1.id,
|
|
postalCode: expectedAddressArg.postalCode,
|
|
country: expectedAddressArg.country,
|
|
});
|
|
assert.isTrue(result);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.googleMapsService.getStateFromZip,
|
|
'99999',
|
|
'GD'
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.updateCustomerBillingAddress,
|
|
{ customerId: customer1.id, options: expectedAddressArg }
|
|
);
|
|
});
|
|
|
|
it('fails when an error is thrown by Google Maps service', async () => {
|
|
sandbox.stub(stripeHelper, 'updateCustomerBillingAddress').resolves();
|
|
mockGoogleMapsService.getStateFromZip = sandbox.stub().rejects(err);
|
|
const result = await stripeHelper.setCustomerLocation({
|
|
customerId: customer1.id,
|
|
postalCode: expectedAddressArg.postalCode,
|
|
country: expectedAddressArg.country,
|
|
});
|
|
assert.isFalse(result);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.googleMapsService.getStateFromZip,
|
|
'99999',
|
|
'GD'
|
|
);
|
|
sinon.assert.notCalled(stripeHelper.updateCustomerBillingAddress);
|
|
sinon.assert.calledTwice(Sentry.withScope);
|
|
sinon.assert.calledOnceWithExactly(
|
|
sentryScope.setContext,
|
|
'setCustomerLocation',
|
|
{
|
|
customer: { id: customer1.id },
|
|
postalCode: expectedAddressArg.postalCode,
|
|
country: expectedAddressArg.country,
|
|
}
|
|
);
|
|
sinon.assert.calledOnceWithExactly(Sentry.captureException, err);
|
|
});
|
|
|
|
it('fails when an error is thrown while updating the customer address', async () => {
|
|
sandbox.stub(stripeHelper, 'updateCustomerBillingAddress').rejects(err);
|
|
const result = await stripeHelper.setCustomerLocation({
|
|
customerId: customer1.id,
|
|
postalCode: expectedAddressArg.postalCode,
|
|
country: expectedAddressArg.country,
|
|
});
|
|
assert.isFalse(result);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.googleMapsService.getStateFromZip,
|
|
'99999',
|
|
'GD'
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.updateCustomerBillingAddress,
|
|
{ customerId: customer1.id, options: expectedAddressArg }
|
|
);
|
|
sinon.assert.calledTwice(Sentry.withScope);
|
|
sinon.assert.calledOnceWithExactly(
|
|
sentryScope.setContext,
|
|
'setCustomerLocation',
|
|
{
|
|
customer: { id: customer1.id },
|
|
postalCode: expectedAddressArg.postalCode,
|
|
country: expectedAddressArg.country,
|
|
}
|
|
);
|
|
sinon.assert.calledOnceWithExactly(Sentry.captureException, err);
|
|
});
|
|
});
|
|
|
|
describe('IAP helpers', () => {
|
|
let subPurchase;
|
|
let productId;
|
|
let priceId;
|
|
let productName;
|
|
let mockPrice;
|
|
let mockAllAbbrevPlans;
|
|
|
|
beforeEach(() => {
|
|
productId = 'prod_test';
|
|
priceId = 'price_test';
|
|
productName = 'testProduct';
|
|
mockPrice = {
|
|
plan_id: priceId,
|
|
plan_metadata: {
|
|
[STRIPE_PRICE_METADATA.PLAY_SKU_IDS]: 'testSku,testSku2',
|
|
[STRIPE_PRICE_METADATA.APP_STORE_PRODUCT_IDS]:
|
|
'cooking.with.Foxkeh,skydiving.with.foxkeh',
|
|
},
|
|
product_id: productId,
|
|
product_name: productName,
|
|
product_metadata: {},
|
|
};
|
|
mockAllAbbrevPlans = [
|
|
mockPrice,
|
|
{
|
|
plan_id: 'wrong_price_id',
|
|
product_id: 'wrongProduct',
|
|
product_name: 'Wrong Product',
|
|
plan_metadata: {},
|
|
product_metadata: {},
|
|
},
|
|
];
|
|
sandbox.stub(stripeHelper, 'allAbbrevPlans').resolves(mockAllAbbrevPlans);
|
|
});
|
|
|
|
describe('priceToIapIdentifiers', () => {
|
|
it('formats Play skus from price metadata, including transforming them to lowercase', () => {
|
|
const result = stripeHelper.priceToIapIdentifiers(
|
|
mockPrice,
|
|
MozillaSubscriptionTypes.IAP_GOOGLE
|
|
);
|
|
assert.deepEqual(result, ['testsku', 'testsku2']);
|
|
});
|
|
|
|
it('formats App Store productIds from price metadata, including transforming them to lowercase', () => {
|
|
const result = stripeHelper.priceToIapIdentifiers(
|
|
mockPrice,
|
|
MozillaSubscriptionTypes.IAP_APPLE
|
|
);
|
|
assert.deepEqual(result, [
|
|
'cooking.with.foxkeh',
|
|
'skydiving.with.foxkeh',
|
|
]);
|
|
});
|
|
|
|
it('handles empty price metadata', () => {
|
|
const price = {
|
|
...mockPrice,
|
|
plan_metadata: {},
|
|
};
|
|
const result = stripeHelper.priceToIapIdentifiers(
|
|
price,
|
|
MozillaSubscriptionTypes.IAP_GOOGLE
|
|
);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
});
|
|
|
|
describe('iapPurchasesToPriceIds', () => {
|
|
beforeEach(() => {
|
|
const apiResponse = {
|
|
kind: 'androidpublisher#subscriptionPurchase',
|
|
startTimeMillis: `${Date.now() - 10000}`, // some time in the past
|
|
expiryTimeMillis: `${Date.now() + 10000}`, // some time in the future
|
|
autoRenewing: true,
|
|
priceCurrencyCode: 'JPY',
|
|
priceAmountMicros: '99000000',
|
|
countryCode: 'JP',
|
|
developerPayload: '',
|
|
paymentState: 1,
|
|
orderId: 'GPA.3313-5503-3858-32549',
|
|
};
|
|
|
|
subPurchase = PlayStoreSubscriptionPurchase.fromApiResponse(
|
|
apiResponse,
|
|
'testPackage',
|
|
'testToken',
|
|
'testSku',
|
|
Date.now()
|
|
);
|
|
});
|
|
|
|
it('returns price ids for the Play subscription purchase', async () => {
|
|
const result = await stripeHelper.iapPurchasesToPriceIds([subPurchase]);
|
|
assert.deepEqual(result, [priceId]);
|
|
sinon.assert.calledOnce(stripeHelper.allAbbrevPlans);
|
|
});
|
|
|
|
it('returns price ids for the App Store subscription purchase', async () => {
|
|
const apiResponse = deepCopy(appStoreApiResponse);
|
|
const { originalTransactionId, status } = apiResponse;
|
|
const decodedTransactionInfo = deepCopy(transactionInfo);
|
|
const decodedRenewalInfo = deepCopy(renewalInfo);
|
|
const verifiedAt = Date.now();
|
|
subPurchase = AppStoreSubscriptionPurchase.fromApiResponse(
|
|
apiResponse,
|
|
status,
|
|
decodedTransactionInfo,
|
|
decodedRenewalInfo,
|
|
originalTransactionId,
|
|
verifiedAt
|
|
);
|
|
const result = await stripeHelper.iapPurchasesToPriceIds([subPurchase]);
|
|
assert.deepEqual(result, [priceId]);
|
|
sinon.assert.calledOnce(stripeHelper.allAbbrevPlans);
|
|
});
|
|
|
|
it('returns no price ids for unknown subscription purchase', async () => {
|
|
subPurchase.sku = 'wrongSku';
|
|
const result = await stripeHelper.iapPurchasesToPriceIds([subPurchase]);
|
|
assert.deepEqual(result, []);
|
|
sinon.assert.calledOnce(stripeHelper.allAbbrevPlans);
|
|
});
|
|
});
|
|
|
|
describe('addPriceInfoToIapPurchases', () => {
|
|
let mockPlayPurchase;
|
|
let mockAppStorePurchase;
|
|
|
|
beforeEach(() => {
|
|
mockPlayPurchase = {
|
|
auto_renewing: true,
|
|
expiry_time_millis: Date.now(),
|
|
package_name: 'org.mozilla.cooking.with.foxkeh',
|
|
sku: 'testSku',
|
|
};
|
|
mockAppStorePurchase = {
|
|
autoRenewStatus: 1,
|
|
productId: 'skydiving.with.foxkeh',
|
|
bundleId: 'hmm',
|
|
};
|
|
});
|
|
|
|
it('adds matching product info to a Play Store subscription purchase', async () => {
|
|
const expected = {
|
|
...mockPlayPurchase,
|
|
price_id: priceId,
|
|
product_id: productId,
|
|
product_name: productName,
|
|
};
|
|
const result = await stripeHelper.addPriceInfoToIapPurchases(
|
|
[mockPlayPurchase],
|
|
MozillaSubscriptionTypes.IAP_GOOGLE
|
|
);
|
|
assert.deepEqual([expected], result);
|
|
});
|
|
|
|
it('adds matching product info to an App Store subscription purchase', async () => {
|
|
const expected = {
|
|
...mockAppStorePurchase,
|
|
price_id: priceId,
|
|
product_id: productId,
|
|
product_name: productName,
|
|
};
|
|
const result = await stripeHelper.addPriceInfoToIapPurchases(
|
|
[mockAppStorePurchase],
|
|
MozillaSubscriptionTypes.IAP_APPLE
|
|
);
|
|
assert.deepEqual([expected], result);
|
|
});
|
|
|
|
it('returns an empty list if no matching product ids are found', async () => {
|
|
const mockPlayPurchase1 = {
|
|
...mockPlayPurchase,
|
|
sku: 'notMatchingSku',
|
|
};
|
|
const result = await stripeHelper.addPriceInfoToIapPurchases(
|
|
[mockPlayPurchase1],
|
|
MozillaSubscriptionTypes.IAP_GOOGLE
|
|
);
|
|
assert.isEmpty(result);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('maybeGetPlanConfig', () => {
|
|
it('returns an empty object when config manager is not available', async () => {
|
|
stripeHelper.paymentConfigManager = undefined;
|
|
const actual = await stripeHelper.maybeGetPlanConfig('testo');
|
|
assert.deepEqual(actual, {});
|
|
});
|
|
|
|
it('returns an empty object when a config doc is not found', async () => {
|
|
stripeHelper.paymentConfigManager = {
|
|
getMergedPlanConfiguration: sandbox.stub().resolves(undefined),
|
|
};
|
|
const actual = await stripeHelper.maybeGetPlanConfig('testo');
|
|
sinon.assert.calledOnceWithExactly(
|
|
stripeHelper.paymentConfigManager.getMergedPlanConfiguration,
|
|
'testo'
|
|
);
|
|
assert.deepEqual(actual, {});
|
|
});
|
|
|
|
it('returns the config from the config manager', async () => {
|
|
const planConfig = { fizz: 'wibble' };
|
|
stripeHelper.paymentConfigManager = {
|
|
getMergedPlanConfiguration: sandbox.stub().resolves(planConfig),
|
|
};
|
|
const actual = await stripeHelper.maybeGetPlanConfig('testo');
|
|
assert.deepEqual(actual, planConfig);
|
|
});
|
|
});
|
|
|
|
describe('isCustomerStripeTaxEligible', () => {
|
|
it('returns true for a taxable customer', () => {
|
|
const actual = stripeHelper.isCustomerStripeTaxEligible({
|
|
tax: {
|
|
automatic_tax: 'supported',
|
|
},
|
|
});
|
|
|
|
assert.equal(actual, true);
|
|
});
|
|
|
|
it('returns true for a customer in a not-collecting location', () => {
|
|
const actual = stripeHelper.isCustomerStripeTaxEligible({
|
|
tax: {
|
|
automatic_tax: 'not_collecting',
|
|
},
|
|
});
|
|
|
|
assert.equal(actual, true);
|
|
});
|
|
|
|
it('returns false for a customer in a unrecognized location', () => {
|
|
const actual = stripeHelper.isCustomerStripeTaxEligible({
|
|
tax: {
|
|
automatic_tax: 'unrecognized_location',
|
|
},
|
|
});
|
|
|
|
assert.equal(actual, false);
|
|
});
|
|
});
|
|
|
|
describe('isCustomerTaxableWithSubscriptionCurrency', () => {
|
|
it('returns true when currency is compatible with country and customer is stripe taxable', () => {
|
|
sandbox
|
|
.stub(stripeHelper.currencyHelper, 'isCurrencyCompatibleWithCountry')
|
|
.returns(true);
|
|
|
|
const actual = stripeHelper.isCustomerTaxableWithSubscriptionCurrency(
|
|
{
|
|
tax: {
|
|
automatic_tax: 'supported',
|
|
location: {
|
|
country: 'US',
|
|
},
|
|
},
|
|
},
|
|
'USD'
|
|
);
|
|
|
|
assert.equal(actual, true);
|
|
});
|
|
|
|
it('returns false for a currency not compatible with the tax country', () => {
|
|
sandbox
|
|
.stub(stripeHelper.currencyHelper, 'isCurrencyCompatibleWithCountry')
|
|
.returns(false);
|
|
|
|
const actual = stripeHelper.isCustomerTaxableWithSubscriptionCurrency(
|
|
{
|
|
tax: {
|
|
automatic_tax: 'supported',
|
|
location: {
|
|
country: 'US',
|
|
},
|
|
},
|
|
},
|
|
'USD'
|
|
);
|
|
|
|
assert.equal(actual, false);
|
|
});
|
|
|
|
it('returns false if customer does not have tax location', () => {
|
|
sandbox
|
|
.stub(stripeHelper.currencyHelper, 'isCurrencyCompatibleWithCountry')
|
|
.returns(false);
|
|
|
|
const actual = stripeHelper.isCustomerTaxableWithSubscriptionCurrency(
|
|
{
|
|
tax: {
|
|
automatic_tax: 'supported',
|
|
location: undefined,
|
|
},
|
|
},
|
|
'USD'
|
|
);
|
|
|
|
assert.equal(actual, false);
|
|
});
|
|
|
|
it('returns false for a customer in a unrecognized location', () => {
|
|
const actual = stripeHelper.isCustomerTaxableWithSubscriptionCurrency({
|
|
tax: {
|
|
automatic_tax: 'unrecognized_location',
|
|
location: {
|
|
country: 'US',
|
|
},
|
|
},
|
|
});
|
|
|
|
assert.equal(actual, false);
|
|
});
|
|
});
|
|
|
|
describe('removeFirestoreCustomer', () => {
|
|
it('completes successfully and returns array of deleted paths', async () => {
|
|
const expected = ['/path', '/path/subpath'];
|
|
stripeFirestore.removeCustomerRecursive = sandbox
|
|
.stub()
|
|
.resolves(expected);
|
|
const actual = await stripeHelper.removeFirestoreCustomer('uid');
|
|
assert.equal(actual, expected);
|
|
});
|
|
|
|
it('does not report error to sentry and rejects with error', async () => {
|
|
sandbox.stub(Sentry, 'captureException');
|
|
const expectedError = new Error('bad things');
|
|
stripeFirestore.removeCustomerRecursive = sandbox
|
|
.stub()
|
|
.rejects(expectedError);
|
|
try {
|
|
await stripeHelper.removeFirestoreCustomer('uid');
|
|
} catch (error) {
|
|
assert.equal(error.message, expectedError.message);
|
|
sinon.assert.notCalled(Sentry.captureException);
|
|
}
|
|
});
|
|
|
|
it('reports error to sentry and rejects with error', async () => {
|
|
sandbox.stub(Sentry, 'captureException');
|
|
const primaryError = new Error('not good');
|
|
const expectedError = new StripeFirestoreMultiError([primaryError]);
|
|
stripeFirestore.removeCustomerRecursive = sandbox
|
|
.stub()
|
|
.rejects(expectedError);
|
|
try {
|
|
await stripeHelper.removeFirestoreCustomer('uid');
|
|
} catch (error) {
|
|
assert.equal(error.message, expectedError.message);
|
|
sinon.assert.calledOnceWithExactly(
|
|
Sentry.captureException,
|
|
expectedError
|
|
);
|
|
}
|
|
});
|
|
});
|
|
});
|