mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-27 11:01:56 +02:00
614 lines
20 KiB
JavaScript
614 lines
20 KiB
JavaScript
const sinon = require('sinon')
|
|
const { expect } = require('chai')
|
|
const recurly = require('recurly')
|
|
const SandboxedModule = require('sandboxed-module')
|
|
const {
|
|
PaymentProviderSubscription,
|
|
PaymentProviderSubscriptionChangeRequest,
|
|
PaymentProviderSubscriptionAddOnUpdate,
|
|
PaymentProviderAccount,
|
|
PaymentProviderCoupon,
|
|
} = require('../../../../app/src/Features/Subscription/PaymentProviderEntities')
|
|
|
|
const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyClient'
|
|
|
|
describe('RecurlyClient', function () {
|
|
beforeEach(function () {
|
|
this.settings = {
|
|
apis: {
|
|
recurly: {
|
|
apiKey: 'nonsense',
|
|
privateKey: 'private_nonsense',
|
|
},
|
|
},
|
|
plans: [],
|
|
features: [],
|
|
}
|
|
|
|
this.user = { _id: '123456', email: 'joe@example.com', first_name: 'Joe' }
|
|
this.subscriptionChange = { id: 'subscription-change-123' }
|
|
this.recurlyAccount = new recurly.Account()
|
|
Object.assign(this.recurlyAccount, {
|
|
code: this.user._id,
|
|
email: this.user.email,
|
|
})
|
|
|
|
this.subscriptionAddOn = {
|
|
code: 'addon-code',
|
|
name: 'My Add-On',
|
|
quantity: 1,
|
|
unitPrice: 2,
|
|
preTaxTotal: 2,
|
|
}
|
|
|
|
this.subscription = new PaymentProviderSubscription({
|
|
id: 'subscription-id',
|
|
userId: 'user-id',
|
|
currency: 'EUR',
|
|
planCode: 'plan-code',
|
|
planName: 'plan-name',
|
|
planPrice: 13,
|
|
addOns: [this.subscriptionAddOn],
|
|
subtotal: 15,
|
|
taxRate: 0.1,
|
|
taxAmount: 1.5,
|
|
total: 16.5,
|
|
periodStart: new Date(),
|
|
periodEnd: new Date(),
|
|
collectionMethod: 'automatic',
|
|
})
|
|
|
|
this.recurlySubscription = {
|
|
uuid: this.subscription.id,
|
|
account: {
|
|
code: this.subscription.userId,
|
|
},
|
|
plan: {
|
|
code: this.subscription.planCode,
|
|
name: this.subscription.planName,
|
|
},
|
|
addOns: [
|
|
{
|
|
addOn: {
|
|
code: this.subscriptionAddOn.code,
|
|
name: this.subscriptionAddOn.name,
|
|
},
|
|
quantity: this.subscriptionAddOn.quantity,
|
|
unitAmount: this.subscriptionAddOn.unitPrice,
|
|
},
|
|
],
|
|
unitAmount: this.subscription.planPrice,
|
|
subtotal: this.subscription.subtotal,
|
|
taxInfo: { rate: this.subscription.taxRate },
|
|
tax: this.subscription.taxAmount,
|
|
total: this.subscription.total,
|
|
currency: this.subscription.currency,
|
|
currentPeriodStartedAt: this.subscription.periodStart,
|
|
currentPeriodEndsAt: this.subscription.periodEnd,
|
|
collectionMethod: this.subscription.collectionMethod,
|
|
}
|
|
|
|
this.recurlySubscriptionChange = new recurly.SubscriptionChange()
|
|
Object.assign(this.recurlySubscriptionChange, this.subscriptionChange)
|
|
|
|
this.UserGetter = {
|
|
promises: {
|
|
getUser: sinon.stub().callsFake(userId => {
|
|
if (userId === this.user._id) {
|
|
return this.user
|
|
}
|
|
}),
|
|
},
|
|
}
|
|
|
|
let client
|
|
this.client = client = {
|
|
getAccount: sinon.stub(),
|
|
getBillingInfo: sinon.stub(),
|
|
listAccountSubscriptions: sinon.stub(),
|
|
listActiveCouponRedemptions: sinon.stub(),
|
|
previewSubscriptionChange: sinon.stub(),
|
|
}
|
|
this.recurly = {
|
|
errors: recurly.errors,
|
|
Client: function () {
|
|
return client
|
|
},
|
|
}
|
|
this.Errors = {
|
|
MissingBillingInfoError: class extends Error {},
|
|
SubtotalLimitExceededError: class extends Error {},
|
|
}
|
|
|
|
return (this.RecurlyClient = SandboxedModule.require(MODULE_PATH, {
|
|
globals: {
|
|
console,
|
|
},
|
|
requires: {
|
|
'@overleaf/settings': this.settings,
|
|
recurly: this.recurly,
|
|
'@overleaf/logger': {
|
|
err: sinon.stub(),
|
|
error: sinon.stub(),
|
|
warn: sinon.stub(),
|
|
log: sinon.stub(),
|
|
debug: sinon.stub(),
|
|
},
|
|
'../User/UserGetter': this.UserGetter,
|
|
'./Errors': this.Errors,
|
|
},
|
|
}))
|
|
})
|
|
|
|
describe('initalizing recurly client with undefined API key parameter', function () {
|
|
it('should create a client without error', function () {
|
|
let testClient
|
|
expect(() => {
|
|
testClient = new recurly.Client(undefined)
|
|
}).to.not.throw()
|
|
expect(testClient).to.be.instanceOf(recurly.Client)
|
|
})
|
|
})
|
|
|
|
describe('getAccountForUserId', function () {
|
|
it('should return an Account if one exists', async function () {
|
|
this.client.getAccount = sinon.stub().resolves(this.recurlyAccount)
|
|
const account = await this.RecurlyClient.promises.getAccountForUserId(
|
|
this.user._id
|
|
)
|
|
const expectedAccount = new PaymentProviderAccount({
|
|
code: this.user._id,
|
|
email: this.user.email,
|
|
hasPastDueInvoice: false,
|
|
})
|
|
expect(account).to.deep.equal(expectedAccount)
|
|
})
|
|
|
|
it('should return null if no account found', async function () {
|
|
this.client.getAccount = sinon
|
|
.stub()
|
|
.throws(new recurly.errors.NotFoundError())
|
|
const account =
|
|
await this.RecurlyClient.promises.getAccountForUserId('nonsense')
|
|
expect(account).to.equal(null)
|
|
})
|
|
|
|
it('should re-throw caught errors', async function () {
|
|
this.client.getAccount = sinon.stub().throws()
|
|
await expect(
|
|
this.RecurlyClient.promises.getAccountForUserId(this.user._id)
|
|
).to.eventually.be.rejectedWith(Error)
|
|
})
|
|
})
|
|
|
|
describe('createAccountForUserId', function () {
|
|
it('should return the Account as created by recurly', async function () {
|
|
this.client.createAccount = sinon.stub().resolves(this.recurlyAccount)
|
|
await expect(
|
|
this.RecurlyClient.promises.createAccountForUserId(this.user._id)
|
|
)
|
|
.to.eventually.be.an.instanceOf(recurly.Account)
|
|
.that.has.property('code', this.user._id)
|
|
})
|
|
|
|
it('should throw any API errors', async function () {
|
|
this.client.createAccount = sinon.stub().throws()
|
|
await expect(
|
|
this.RecurlyClient.promises.createAccountForUserId(this.user._id)
|
|
).to.eventually.be.rejectedWith(Error)
|
|
})
|
|
})
|
|
|
|
describe('getActiveCouponsForUserId', function () {
|
|
it('should return an empty array if no coupons returned', async function () {
|
|
this.client.listActiveCouponRedemptions.returns({
|
|
each: async function* () {},
|
|
})
|
|
const coupons =
|
|
await this.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
|
|
expect(coupons).to.deep.equal([])
|
|
})
|
|
|
|
it('should return a coupons returned by recurly', async function () {
|
|
const recurlyCoupon = {
|
|
coupon: {
|
|
code: 'coupon-code',
|
|
name: 'Coupon Name',
|
|
hostedPageDescription: 'hosted page description',
|
|
invoiceDescription: 'invoice description',
|
|
},
|
|
}
|
|
this.client.listActiveCouponRedemptions.returns({
|
|
each: async function* () {
|
|
yield recurlyCoupon
|
|
},
|
|
})
|
|
const coupons =
|
|
await this.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
|
|
const expectedCoupons = [
|
|
new PaymentProviderCoupon({
|
|
code: 'coupon-code',
|
|
name: 'Coupon Name',
|
|
description: 'hosted page description',
|
|
}),
|
|
]
|
|
expect(coupons).to.deep.equal(expectedCoupons)
|
|
})
|
|
|
|
it('should not throw for Recurly not found error', async function () {
|
|
this.client.listActiveCouponRedemptions = sinon
|
|
.stub()
|
|
.throws(new recurly.errors.NotFoundError())
|
|
const coupons =
|
|
await this.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
|
|
expect(coupons).to.deep.equal([])
|
|
})
|
|
|
|
it('should throw any other API errors', async function () {
|
|
this.client.listActiveCouponRedemptions = sinon.stub().throws()
|
|
await expect(
|
|
this.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
|
|
).to.eventually.be.rejectedWith(Error)
|
|
})
|
|
})
|
|
|
|
describe('getSubscription', function () {
|
|
it('should return the subscription found by recurly', async function () {
|
|
this.client.getSubscription = sinon
|
|
.stub()
|
|
.withArgs('uuid-subscription-id')
|
|
.resolves(this.recurlySubscription)
|
|
const subscription = await this.RecurlyClient.promises.getSubscription(
|
|
this.subscription.id
|
|
)
|
|
expect(subscription).to.deep.equal(this.subscription)
|
|
})
|
|
|
|
it('should throw any API errors', async function () {
|
|
this.client.getSubscription = sinon.stub().throws()
|
|
await expect(
|
|
this.RecurlyClient.promises.getSubscription(this.user._id)
|
|
).to.eventually.be.rejectedWith(Error)
|
|
})
|
|
})
|
|
|
|
describe('getSubscriptionForUser', function () {
|
|
it("should return null if the account doesn't exist", async function () {
|
|
this.client.listAccountSubscriptions.returns({
|
|
// eslint-disable-next-line require-yield
|
|
each: async function* () {
|
|
throw new recurly.errors.NotFoundError('account not found')
|
|
},
|
|
})
|
|
const subscription =
|
|
await this.RecurlyClient.promises.getSubscriptionForUser('some-user')
|
|
expect(subscription).to.be.null
|
|
})
|
|
|
|
it("should return null if the account doesn't have subscriptions", async function () {
|
|
this.client.listAccountSubscriptions.returns({
|
|
each: async function* () {},
|
|
})
|
|
const subscription =
|
|
await this.RecurlyClient.promises.getSubscriptionForUser('some-user')
|
|
expect(subscription).to.be.null
|
|
})
|
|
|
|
it('should return the subscription if the account has one subscription', async function () {
|
|
const recurlySubscription = this.recurlySubscription
|
|
this.client.listAccountSubscriptions.returns({
|
|
each: async function* () {
|
|
yield recurlySubscription
|
|
},
|
|
})
|
|
const subscription =
|
|
await this.RecurlyClient.promises.getSubscriptionForUser('some-user')
|
|
expect(subscription).to.deep.equal(this.subscription)
|
|
})
|
|
|
|
it('should throw an error if the account has more than one subscription', async function () {
|
|
const recurlySubscription = this.recurlySubscription
|
|
this.client.listAccountSubscriptions.returns({
|
|
each: async function* () {
|
|
yield recurlySubscription
|
|
yield { another: 'subscription' }
|
|
},
|
|
})
|
|
await expect(
|
|
this.RecurlyClient.promises.getSubscriptionForUser('some-user')
|
|
).to.be.rejected
|
|
})
|
|
})
|
|
|
|
describe('applySubscriptionChangeRequest', function () {
|
|
beforeEach(function () {
|
|
this.client.createSubscriptionChange = sinon
|
|
.stub()
|
|
.resolves(this.recurlySubscriptionChange)
|
|
})
|
|
|
|
it('handles plan changes', async function () {
|
|
await this.RecurlyClient.promises.applySubscriptionChangeRequest(
|
|
new PaymentProviderSubscriptionChangeRequest({
|
|
subscription: this.subscription,
|
|
timeframe: 'now',
|
|
planCode: 'new-plan',
|
|
})
|
|
)
|
|
expect(this.client.createSubscriptionChange).to.be.calledWith(
|
|
'uuid-subscription-id',
|
|
{ timeframe: 'now', planCode: 'new-plan' }
|
|
)
|
|
})
|
|
|
|
it('handles add-on changes', async function () {
|
|
await this.RecurlyClient.promises.applySubscriptionChangeRequest(
|
|
new PaymentProviderSubscriptionChangeRequest({
|
|
subscription: this.subscription,
|
|
timeframe: 'now',
|
|
addOnUpdates: [
|
|
new PaymentProviderSubscriptionAddOnUpdate({
|
|
code: 'new-add-on',
|
|
quantity: 2,
|
|
unitPrice: 8.99,
|
|
}),
|
|
],
|
|
})
|
|
)
|
|
expect(this.client.createSubscriptionChange).to.be.calledWith(
|
|
'uuid-subscription-id',
|
|
{
|
|
timeframe: 'now',
|
|
addOns: [{ code: 'new-add-on', quantity: 2, unitAmount: 8.99 }],
|
|
}
|
|
)
|
|
})
|
|
|
|
it('should throw any API errors', async function () {
|
|
this.client.createSubscriptionChange = sinon.stub().throws()
|
|
await expect(
|
|
this.RecurlyClient.promises.applySubscriptionChangeRequest({
|
|
subscription: this.subscription,
|
|
})
|
|
).to.eventually.be.rejectedWith(Error)
|
|
})
|
|
|
|
it('should throw SubtotalLimitExceededError', async function () {
|
|
class ValidationError extends recurly.errors.ValidationError {
|
|
constructor() {
|
|
super()
|
|
this.params = [{ param: 'subtotal_amount_in_cents' }]
|
|
}
|
|
}
|
|
this.client.createSubscriptionChange = sinon
|
|
.stub()
|
|
.throws(new ValidationError())
|
|
await expect(
|
|
this.RecurlyClient.promises.applySubscriptionChangeRequest({
|
|
subscription: this.subscription,
|
|
})
|
|
).to.be.rejectedWith(this.Errors.SubtotalLimitExceededError)
|
|
})
|
|
|
|
it('should rethrow errors different than SubtotalLimitExceededError', async function () {
|
|
this.client.createSubscriptionChange = sinon.stub().throws(new Error())
|
|
await expect(
|
|
this.RecurlyClient.promises.applySubscriptionChangeRequest({
|
|
subscription: this.subscription,
|
|
})
|
|
).to.be.rejectedWith(Error)
|
|
})
|
|
})
|
|
|
|
describe('removeSubscriptionChange', function () {
|
|
beforeEach(function () {
|
|
this.client.removeSubscriptionChange = sinon.stub().resolves()
|
|
})
|
|
|
|
it('should attempt to remove a pending subscription change', async function () {
|
|
this.RecurlyClient.promises.removeSubscriptionChange(
|
|
this.subscription.id,
|
|
{}
|
|
)
|
|
expect(this.client.removeSubscriptionChange).to.be.calledWith(
|
|
this.subscription.id
|
|
)
|
|
})
|
|
|
|
it('should throw any API errors', async function () {
|
|
this.client.removeSubscriptionChange = sinon.stub().throws()
|
|
await expect(
|
|
this.RecurlyClient.promises.removeSubscriptionChange(
|
|
this.subscription.id,
|
|
{}
|
|
)
|
|
).to.eventually.be.rejectedWith(Error)
|
|
})
|
|
|
|
describe('removeSubscriptionChangeByUuid', function () {
|
|
it('should attempt to remove a pending subscription change', async function () {
|
|
this.RecurlyClient.promises.removeSubscriptionChangeByUuid(
|
|
this.subscription.uuid,
|
|
{}
|
|
)
|
|
expect(this.client.removeSubscriptionChange).to.be.calledWith(
|
|
'uuid-' + this.subscription.uuid
|
|
)
|
|
})
|
|
|
|
it('should throw any API errors', async function () {
|
|
this.client.removeSubscriptionChange = sinon.stub().throws()
|
|
await expect(
|
|
this.RecurlyClient.promises.removeSubscriptionChangeByUuid(
|
|
this.subscription.id,
|
|
{}
|
|
)
|
|
).to.eventually.be.rejectedWith(Error)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('reactivateSubscriptionByUuid', function () {
|
|
it('should attempt to reactivate the subscription', async function () {
|
|
this.client.reactivateSubscription = sinon
|
|
.stub()
|
|
.resolves(this.recurlySubscription)
|
|
const subscription =
|
|
await this.RecurlyClient.promises.reactivateSubscriptionByUuid(
|
|
this.subscription.uuid
|
|
)
|
|
expect(subscription).to.deep.equal(this.recurlySubscription)
|
|
expect(this.client.reactivateSubscription).to.be.calledWith(
|
|
'uuid-' + this.subscription.uuid
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('cancelSubscriptionByUuid', function () {
|
|
it('should attempt to cancel the subscription', async function () {
|
|
this.client.cancelSubscription = sinon
|
|
.stub()
|
|
.resolves(this.recurlySubscription)
|
|
const subscription =
|
|
await this.RecurlyClient.promises.cancelSubscriptionByUuid(
|
|
this.subscription.uuid
|
|
)
|
|
expect(subscription).to.deep.equal(this.recurlySubscription)
|
|
expect(this.client.cancelSubscription).to.be.calledWith(
|
|
'uuid-' + this.subscription.uuid
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('pauseSubscriptionByUuid', function () {
|
|
it('should attempt to pause the subscription', async function () {
|
|
this.client.pauseSubscription = sinon
|
|
.stub()
|
|
.resolves(this.recurlySubscription)
|
|
const subscription =
|
|
await this.RecurlyClient.promises.pauseSubscriptionByUuid(
|
|
this.subscription.uuid,
|
|
3
|
|
)
|
|
expect(subscription).to.deep.equal(this.recurlySubscription)
|
|
expect(this.client.pauseSubscription).to.be.calledWith(
|
|
'uuid-' + this.subscription.uuid,
|
|
{ remainingPauseCycles: 3 }
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('previewSubscriptionChange', function () {
|
|
describe('compute immediate charge', function () {
|
|
it('only has charge invoice', async function () {
|
|
this.client.previewSubscriptionChange.resolves({
|
|
plan: { code: 'test_code', name: 'test name' },
|
|
unitAmount: 0,
|
|
invoiceCollection: {
|
|
chargeInvoice: {
|
|
subtotal: 100,
|
|
tax: 20,
|
|
total: 120,
|
|
},
|
|
},
|
|
})
|
|
const { immediateCharge } =
|
|
await this.RecurlyClient.promises.previewSubscriptionChange(
|
|
new PaymentProviderSubscriptionChangeRequest({
|
|
subscription: this.subscription,
|
|
timeframe: 'now',
|
|
planCode: 'new-plan',
|
|
})
|
|
)
|
|
expect(immediateCharge.subtotal).to.be.equal(100)
|
|
expect(immediateCharge.tax).to.be.equal(20)
|
|
expect(immediateCharge.total).to.be.equal(120)
|
|
})
|
|
|
|
it('credit invoice with imprecise float number', async function () {
|
|
this.client.previewSubscriptionChange.resolves({
|
|
plan: { code: 'test_code', name: 'test name' },
|
|
unitAmount: 0,
|
|
invoiceCollection: {
|
|
chargeInvoice: {
|
|
subtotal: 100.3,
|
|
tax: 20.3,
|
|
total: 120.3,
|
|
},
|
|
creditInvoices: [
|
|
{
|
|
subtotal: -20.1,
|
|
tax: -4.1,
|
|
total: -24.1,
|
|
},
|
|
],
|
|
},
|
|
})
|
|
const { immediateCharge } =
|
|
await this.RecurlyClient.promises.previewSubscriptionChange(
|
|
new PaymentProviderSubscriptionChangeRequest({
|
|
subscription: this.subscription,
|
|
timeframe: 'now',
|
|
planCode: 'new-plan',
|
|
})
|
|
)
|
|
expect(immediateCharge.subtotal).to.be.equal(80.2)
|
|
expect(immediateCharge.tax).to.be.equal(16.2)
|
|
expect(immediateCharge.total).to.be.equal(96.2)
|
|
})
|
|
})
|
|
|
|
it('should throw SubtotalLimitExceededError', async function () {
|
|
class ValidationError extends recurly.errors.ValidationError {
|
|
constructor() {
|
|
super()
|
|
this.params = [{ param: 'subtotal_amount_in_cents' }]
|
|
}
|
|
}
|
|
this.client.previewSubscriptionChange = sinon
|
|
.stub()
|
|
.throws(new ValidationError())
|
|
await expect(
|
|
this.RecurlyClient.promises.previewSubscriptionChange(
|
|
new PaymentProviderSubscriptionChangeRequest({
|
|
subscription: this.subscription,
|
|
timeframe: 'now',
|
|
planCode: 'new-plan',
|
|
})
|
|
)
|
|
).to.be.rejectedWith(this.Errors.SubtotalLimitExceededError)
|
|
})
|
|
|
|
it('should rethrow errors different than SubtotalLimitExceededError', async function () {
|
|
this.client.previewSubscriptionChange = sinon.stub().throws(new Error())
|
|
await expect(
|
|
this.RecurlyClient.promises.previewSubscriptionChange(
|
|
new PaymentProviderSubscriptionChangeRequest({
|
|
subscription: this.subscription,
|
|
timeframe: 'now',
|
|
planCode: 'new-plan',
|
|
})
|
|
)
|
|
).to.be.rejectedWith(Error)
|
|
})
|
|
})
|
|
|
|
describe('getPaymentMethod', function () {
|
|
it('should throw MissingBillingInfoError', async function () {
|
|
this.client.getBillingInfo = sinon
|
|
.stub()
|
|
.throws(new recurly.errors.NotFoundError())
|
|
await expect(
|
|
this.RecurlyClient.promises.getPaymentMethod(this.user._id)
|
|
).to.be.rejectedWith(this.Errors.MissingBillingInfoError)
|
|
})
|
|
|
|
it('should rethrow errors different than MissingBillingInfoError', async function () {
|
|
this.client.getBillingInfo = sinon.stub().throws(new Error())
|
|
await expect(
|
|
this.RecurlyClient.promises.getPaymentMethod(this.user._id)
|
|
).to.be.rejectedWith(Error)
|
|
})
|
|
})
|
|
})
|