mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-15 14:43:46 +01:00
[web] fix bug where pending downgrades are removed when subscriptions change (#30420)
* preserve pending changes when generating change requests * re-apply pending term_end changes after immediate updates * block changes when Stripe subscription has multiple phases * handle MultiplePendingChangesError & rm PendingChangeError GitOrigin-RevId: 0af11044766ff48e683d684ad6d62b732d17290c
This commit is contained in:
+12
@@ -6,6 +6,7 @@ import {
|
||||
annualActiveSubscription,
|
||||
annualActiveSubscriptionEuro,
|
||||
annualActiveSubscriptionPro,
|
||||
pendingAddOnChange,
|
||||
pendingSubscriptionChange,
|
||||
} from '../../../../../fixtures/subscriptions'
|
||||
import { ActiveSubscription } from '../../../../../../../../../frontend/js/features/subscription/components/dashboard/states/active/active'
|
||||
@@ -69,6 +70,17 @@ describe('<ChangePlanModal />', function () {
|
||||
screen.getByRole('button', { name: 'Keep my current plan' })
|
||||
})
|
||||
|
||||
it('renders "Your plan" when there is a pending add-on change but no plan change', async function () {
|
||||
renderActiveSubscription(pendingAddOnChange)
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Change plan' })
|
||||
fireEvent.click(button)
|
||||
|
||||
await screen.findByText('Your plan')
|
||||
expect(screen.queryByRole('button', { name: 'Keep my current plan' })).to.be
|
||||
.null
|
||||
})
|
||||
|
||||
it('does not render when Recurly did not load', function () {
|
||||
const { container } = renderWithSubscriptionDashContext(
|
||||
<ActiveSubscription subscription={annualActiveSubscription} />,
|
||||
|
||||
@@ -409,6 +409,63 @@ export const canceledSubscription: PaidSubscription = {
|
||||
},
|
||||
}
|
||||
|
||||
export const pendingAddOnChange: PaidSubscription = {
|
||||
manager_ids: ['abc123'],
|
||||
member_ids: [],
|
||||
invited_emails: [],
|
||||
groupPlan: false,
|
||||
membersLimit: 0,
|
||||
_id: 'add-on-change-123',
|
||||
admin_id: 'abc123',
|
||||
teamInvites: [],
|
||||
planCode: 'collaborator-annual',
|
||||
plan: {
|
||||
planCode: 'collaborator-annual',
|
||||
name: 'Standard (Collaborator) Annual',
|
||||
price_in_cents: 21900,
|
||||
annual: true,
|
||||
featureDescription: [],
|
||||
canUseFlexibleLicensing: false,
|
||||
},
|
||||
payment: {
|
||||
taxRate: 0,
|
||||
billingDetailsLink: '/user/subscription/payment/billing-details',
|
||||
accountManagementLink: '/user/subscription/payment/account-management',
|
||||
additionalLicenses: 0,
|
||||
totalLicenses: 0,
|
||||
nextPaymentDueAt,
|
||||
nextPaymentDueDate,
|
||||
currency: 'USD',
|
||||
state: 'active',
|
||||
trialEndsAtFormatted: null,
|
||||
trialEndsAt: null,
|
||||
activeCoupons: [],
|
||||
accountEmail: 'fake@example.com',
|
||||
hasPastDueInvoice: false,
|
||||
displayPrice: '$199.00',
|
||||
planOnlyDisplayPrice: '$199.00',
|
||||
addOns: [
|
||||
{
|
||||
code: 'AI',
|
||||
quantity: 1,
|
||||
unitPrice: 1000,
|
||||
name: 'AI Add-on',
|
||||
},
|
||||
],
|
||||
addOnDisplayPricesWithoutAdditionalLicense: {},
|
||||
isEligibleForGroupPlan: true,
|
||||
isEligibleForPause: false,
|
||||
isEligibleForDowngradeUpsell: false,
|
||||
},
|
||||
pendingPlan: {
|
||||
planCode: 'collaborator-annual',
|
||||
name: 'Standard (Collaborator) Annual',
|
||||
price_in_cents: 21900,
|
||||
annual: true,
|
||||
featureDescription: [],
|
||||
},
|
||||
}
|
||||
|
||||
export const pendingSubscriptionChange: PaidSubscription = {
|
||||
manager_ids: ['abc123'],
|
||||
member_ids: [],
|
||||
|
||||
@@ -104,6 +104,13 @@ describe('PaymentProviderEntities', function () {
|
||||
subscription: ctx.subscription,
|
||||
timeframe: 'now',
|
||||
planCode: 'premium-plan',
|
||||
addOnUpdates: [
|
||||
new PaymentProviderSubscriptionAddOnUpdate({
|
||||
code: ctx.addOn.code,
|
||||
quantity: ctx.addOn.quantity,
|
||||
unitPrice: ctx.addOn.unitPrice,
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -121,6 +128,13 @@ describe('PaymentProviderEntities', function () {
|
||||
subscription: ctx.subscription,
|
||||
timeframe: 'term_end',
|
||||
planCode: 'cheap-plan',
|
||||
addOnUpdates: [
|
||||
new PaymentProviderSubscriptionAddOnUpdate({
|
||||
code: ctx.addOn.code,
|
||||
quantity: ctx.addOn.quantity,
|
||||
unitPrice: ctx.addOn.unitPrice,
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -141,6 +155,13 @@ describe('PaymentProviderEntities', function () {
|
||||
subscription: ctx.subscription,
|
||||
timeframe: 'now',
|
||||
planCode: 'cheap-plan',
|
||||
addOnUpdates: [
|
||||
new PaymentProviderSubscriptionAddOnUpdate({
|
||||
code: ctx.addOn.code,
|
||||
quantity: ctx.addOn.quantity,
|
||||
unitPrice: ctx.addOn.unitPrice,
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -163,6 +184,7 @@ describe('PaymentProviderEntities', function () {
|
||||
new PaymentProviderSubscriptionAddOnUpdate({
|
||||
code: AI_ADD_ON_CODE,
|
||||
quantity: 1,
|
||||
unitPrice: ctx.addOn.unitPrice,
|
||||
}),
|
||||
],
|
||||
})
|
||||
@@ -187,6 +209,7 @@ describe('PaymentProviderEntities', function () {
|
||||
new PaymentProviderSubscriptionAddOnUpdate({
|
||||
code: AI_ADD_ON_CODE,
|
||||
quantity: 1,
|
||||
unitPrice: ctx.addOn.unitPrice,
|
||||
}),
|
||||
],
|
||||
})
|
||||
@@ -235,6 +258,11 @@ describe('PaymentProviderEntities', function () {
|
||||
timeframe: 'now',
|
||||
planCode: 'group_collaborator',
|
||||
addOnUpdates: [
|
||||
new PaymentProviderSubscriptionAddOnUpdate({
|
||||
code: ctx.addOn.code,
|
||||
quantity: ctx.addOn.quantity,
|
||||
unitPrice: ctx.addOn.unitPrice,
|
||||
}),
|
||||
new PaymentProviderSubscriptionAddOnUpdate({
|
||||
code: 'additional-license',
|
||||
quantity: 10,
|
||||
@@ -262,18 +290,84 @@ describe('PaymentProviderEntities', function () {
|
||||
timeframe: 'now',
|
||||
planCode: 'group_collaborator',
|
||||
addOnUpdates: [
|
||||
new PaymentProviderSubscriptionAddOnUpdate({
|
||||
code: 'additional-license',
|
||||
quantity: 10,
|
||||
}),
|
||||
new PaymentProviderSubscriptionAddOnUpdate({
|
||||
code: AI_ADD_ON_CODE,
|
||||
quantity: 1,
|
||||
unitPrice: ctx.addOn.unitPrice,
|
||||
}),
|
||||
new PaymentProviderSubscriptionAddOnUpdate({
|
||||
code: 'additional-license',
|
||||
quantity: 10,
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('with pending add-on removal', function () {
|
||||
beforeEach(function (ctx) {
|
||||
// Reset to a subscription with an add-on and pending removal
|
||||
const {
|
||||
PaymentProviderSubscription,
|
||||
PaymentProviderSubscriptionAddOn,
|
||||
} = ctx.PaymentProviderEntities
|
||||
ctx.addOn = new PaymentProviderSubscriptionAddOn({
|
||||
code: 'add-on-code',
|
||||
name: 'My Add-On',
|
||||
quantity: 1,
|
||||
unitPrice: 2,
|
||||
})
|
||||
ctx.subscription = new PaymentProviderSubscription({
|
||||
id: 'subscription-id',
|
||||
userId: 'user-id',
|
||||
planCode: 'regular-plan',
|
||||
planName: 'My Plan',
|
||||
planPrice: 10,
|
||||
addOns: [ctx.addOn],
|
||||
subtotal: 10.99,
|
||||
taxRate: 0.2,
|
||||
taxAmount: 2.4,
|
||||
total: 14.4,
|
||||
currency: 'USD',
|
||||
})
|
||||
// Set up a pending change that removes the add-on at term_end
|
||||
ctx.subscription.pendingChange =
|
||||
new PaymentProviderSubscriptionChange({
|
||||
subscription: ctx.subscription,
|
||||
nextPlanCode: ctx.subscription.planCode,
|
||||
nextPlanName: ctx.subscription.planName,
|
||||
nextPlanPrice: ctx.subscription.planPrice,
|
||||
nextAddOns: [], // Add-on is scheduled to be removed
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves current add-ons for immediate upgrade', function (ctx) {
|
||||
const changeRequest = ctx.subscription.getRequestForPlanChange(
|
||||
'premium-plan',
|
||||
1,
|
||||
false // immediate change
|
||||
)
|
||||
expect(changeRequest.timeframe).to.equal('now')
|
||||
expect(changeRequest.addOnUpdates).to.deep.equal([
|
||||
new PaymentProviderSubscriptionAddOnUpdate({
|
||||
code: ctx.addOn.code,
|
||||
quantity: ctx.addOn.quantity,
|
||||
unitPrice: ctx.addOn.unitPrice,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('stacks with pending add-on removal for term_end downgrade', function (ctx) {
|
||||
const changeRequest = ctx.subscription.getRequestForPlanChange(
|
||||
'cheap-plan',
|
||||
1,
|
||||
true // term_end change
|
||||
)
|
||||
expect(changeRequest.timeframe).to.equal('term_end')
|
||||
// Should have NO add-ons because the pending change already removed them
|
||||
expect(changeRequest.addOnUpdates).to.deep.equal([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRequestForAddOnPurchase()', function () {
|
||||
@@ -340,6 +434,51 @@ describe('PaymentProviderEntities', function () {
|
||||
ctx.subscription.getRequestForAddOnPurchase(ctx.addOn.code)
|
||||
).to.throw(Errors.DuplicateAddOnError)
|
||||
})
|
||||
|
||||
describe('with pending plan downgrade', function () {
|
||||
beforeEach(function (ctx) {
|
||||
// Scenario: downgrade is scheduled, and then they want to buy an add-on immediately
|
||||
const {
|
||||
PaymentProviderSubscription,
|
||||
PaymentProviderSubscriptionChange,
|
||||
} = ctx.PaymentProviderEntities
|
||||
ctx.subscription = new PaymentProviderSubscription({
|
||||
id: 'subscription-id',
|
||||
userId: 'user-id',
|
||||
planCode: 'premium-plan',
|
||||
planName: 'Premium Plan',
|
||||
planPrice: 20,
|
||||
addOns: [],
|
||||
subtotal: 20,
|
||||
taxRate: 0.2,
|
||||
taxAmount: 4,
|
||||
total: 24,
|
||||
currency: 'USD',
|
||||
})
|
||||
// Pending downgrade to cheaper plan at term_end
|
||||
ctx.subscription.pendingChange =
|
||||
new PaymentProviderSubscriptionChange({
|
||||
subscription: ctx.subscription,
|
||||
nextPlanCode: 'cheap-plan',
|
||||
nextPlanName: 'Cheap Plan',
|
||||
nextPlanPrice: 5,
|
||||
nextAddOns: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('uses current add-ons for immediate add-on purchase', function (ctx) {
|
||||
const changeRequest =
|
||||
ctx.subscription.getRequestForAddOnPurchase('assistant')
|
||||
expect(changeRequest.timeframe).to.equal('now')
|
||||
// Should only have the new add-on, based on current state, client will reapply the pending change
|
||||
expect(changeRequest.addOnUpdates).to.deep.equal([
|
||||
new PaymentProviderSubscriptionAddOnUpdate({
|
||||
code: 'assistant',
|
||||
quantity: 1,
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRequestForAddOnUpdate()', function () {
|
||||
@@ -410,6 +549,57 @@ describe('PaymentProviderEntities', function () {
|
||||
ctx.subscription.getRequestForAddOnRemoval('another-add-on')
|
||||
).to.throw(Errors.AddOnNotPresentError)
|
||||
})
|
||||
|
||||
describe('with pending changes', function () {
|
||||
beforeEach(function (ctx) {
|
||||
// Scenario: a subscription has two add-ons, one already scheduled for removal
|
||||
const {
|
||||
PaymentProviderSubscription,
|
||||
PaymentProviderSubscriptionAddOn,
|
||||
} = ctx.PaymentProviderEntities
|
||||
ctx.addOn1 = new PaymentProviderSubscriptionAddOn({
|
||||
code: 'addon-1',
|
||||
name: 'Add-On 1',
|
||||
quantity: 1,
|
||||
unitPrice: 2,
|
||||
})
|
||||
ctx.addOn2 = new PaymentProviderSubscriptionAddOn({
|
||||
code: 'addon-2',
|
||||
name: 'Add-On 2',
|
||||
quantity: 1,
|
||||
unitPrice: 3,
|
||||
})
|
||||
ctx.subscription = new PaymentProviderSubscription({
|
||||
id: 'subscription-id',
|
||||
userId: 'user-id',
|
||||
planCode: 'regular-plan',
|
||||
planName: 'My Plan',
|
||||
planPrice: 10,
|
||||
addOns: [ctx.addOn1, ctx.addOn2],
|
||||
subtotal: 10.99,
|
||||
taxRate: 0.2,
|
||||
taxAmount: 2.4,
|
||||
total: 14.4,
|
||||
currency: 'USD',
|
||||
})
|
||||
// Set up a pending change that removes addon-1 at term_end
|
||||
ctx.subscription.pendingChange =
|
||||
new PaymentProviderSubscriptionChange({
|
||||
subscription: ctx.subscription,
|
||||
nextPlanCode: ctx.subscription.planCode,
|
||||
nextPlanName: ctx.subscription.planName,
|
||||
nextPlanPrice: ctx.subscription.planPrice,
|
||||
nextAddOns: [ctx.addOn2], // Only addon-2 remains
|
||||
})
|
||||
})
|
||||
|
||||
it('stacks multiple add-on removals at term_end', function (ctx) {
|
||||
const changeRequest =
|
||||
ctx.subscription.getRequestForAddOnRemoval('addon-2')
|
||||
expect(changeRequest.timeframe).to.equal('term_end')
|
||||
expect(changeRequest.addOnUpdates).to.deep.equal([]) // empty because both are scheduled for removal
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRequestForAddOnReactivation()', function () {
|
||||
@@ -471,6 +661,172 @@ describe('PaymentProviderEntities', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRequestForPlanChangeCancellation()', function () {
|
||||
it('returns null when there is no pending change', function (ctx) {
|
||||
ctx.subscription.pendingChange = null
|
||||
const result = ctx.subscription.getRequestForPlanChangeCancellation()
|
||||
expect(result).to.be.null
|
||||
})
|
||||
|
||||
it('returns null when pending change has no add-on changes', function (ctx) {
|
||||
// Same add-ons in pending change as current subscription
|
||||
ctx.subscription.pendingChange =
|
||||
new PaymentProviderSubscriptionChange({
|
||||
subscription: ctx.subscription,
|
||||
nextPlanCode: 'cheap-plan', // only plan change, no add-on change
|
||||
nextPlanName: 'Cheap Plan',
|
||||
nextPlanPrice: 5,
|
||||
nextAddOns: [ctx.addOn], // same add-on as current subscription
|
||||
})
|
||||
const result = ctx.subscription.getRequestForPlanChangeCancellation()
|
||||
expect(result).to.be.null
|
||||
})
|
||||
|
||||
it('returns a change request preserving add-on removal when canceling plan change', function (ctx) {
|
||||
// Pending change has both plan downgrade AND add-on removal
|
||||
ctx.subscription.pendingChange =
|
||||
new PaymentProviderSubscriptionChange({
|
||||
subscription: ctx.subscription,
|
||||
nextPlanCode: 'cheap-plan',
|
||||
nextPlanName: 'Cheap Plan',
|
||||
nextPlanPrice: 5,
|
||||
nextAddOns: [], // add-on is being removed
|
||||
})
|
||||
|
||||
const result = ctx.subscription.getRequestForPlanChangeCancellation()
|
||||
expect(result).to.not.be.null
|
||||
expect(result.timeframe).to.equal('term_end')
|
||||
expect(result.planCode).to.equal(ctx.subscription.planCode) // keep current plan
|
||||
expect(result.addOnUpdates).to.deep.equal([]) // preserve add-on removal
|
||||
})
|
||||
|
||||
it('returns a change request preserving add-on addition when canceling plan change', function (ctx) {
|
||||
const {
|
||||
PaymentProviderSubscriptionAddOn,
|
||||
PaymentProviderSubscriptionAddOnUpdate,
|
||||
} = ctx.PaymentProviderEntities
|
||||
|
||||
// Current subscription has one add-on
|
||||
// Pending change has plan downgrade AND a new add-on
|
||||
const newAddOn = new PaymentProviderSubscriptionAddOn({
|
||||
code: 'new-addon',
|
||||
name: 'New Add-On',
|
||||
quantity: 1,
|
||||
unitPrice: 3,
|
||||
})
|
||||
ctx.subscription.pendingChange =
|
||||
new PaymentProviderSubscriptionChange({
|
||||
subscription: ctx.subscription,
|
||||
nextPlanCode: 'cheap-plan',
|
||||
nextPlanName: 'Cheap Plan',
|
||||
nextPlanPrice: 5,
|
||||
nextAddOns: [ctx.addOn, newAddOn], // current add-on plus new one
|
||||
})
|
||||
|
||||
const result = ctx.subscription.getRequestForPlanChangeCancellation()
|
||||
expect(result).to.not.be.null
|
||||
expect(result.timeframe).to.equal('term_end')
|
||||
expect(result.planCode).to.equal(ctx.subscription.planCode) // keep current plan
|
||||
expect(result.addOnUpdates).to.deep.equal([
|
||||
new PaymentProviderSubscriptionAddOnUpdate({
|
||||
code: ctx.addOn.code,
|
||||
quantity: ctx.addOn.quantity,
|
||||
unitPrice: ctx.addOn.unitPrice,
|
||||
}),
|
||||
new PaymentProviderSubscriptionAddOnUpdate({
|
||||
code: newAddOn.code,
|
||||
quantity: newAddOn.quantity,
|
||||
unitPrice: newAddOn.unitPrice,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('removes additional-license add-on when canceling pending group plan upgrade', function (ctx) {
|
||||
const { MEMBERS_LIMIT_ADD_ON_CODE } = ctx.PaymentProviderEntities
|
||||
|
||||
// Current: professional-annual (no add-ons, not a group plan)
|
||||
ctx.subscription.planCode = 'professional-annual'
|
||||
ctx.subscription.planName = 'Professional Annual'
|
||||
ctx.subscription.addOns = []
|
||||
|
||||
// Pending: group_professional_10_educational + 5 additional-license
|
||||
const additionalLicenseAddOn =
|
||||
new ctx.PaymentProviderEntities.PaymentProviderSubscriptionAddOn({
|
||||
code: MEMBERS_LIMIT_ADD_ON_CODE,
|
||||
name: 'Additional License',
|
||||
quantity: 5,
|
||||
unitPrice: 10,
|
||||
})
|
||||
|
||||
ctx.subscription.pendingChange =
|
||||
new PaymentProviderSubscriptionChange({
|
||||
subscription: ctx.subscription,
|
||||
nextPlanCode: 'group_professional_10_educational',
|
||||
nextPlanName: 'Group Professional Educational',
|
||||
nextPlanPrice: 100,
|
||||
nextAddOns: [additionalLicenseAddOn],
|
||||
})
|
||||
|
||||
const result = ctx.subscription.getRequestForPlanChangeCancellation()
|
||||
|
||||
// Should return a change request that keeps the current plan
|
||||
// but removes the additional-license add-on
|
||||
expect(result).to.not.be.null
|
||||
expect(result.timeframe).to.equal('term_end')
|
||||
expect(result.planCode).to.equal('professional-annual')
|
||||
expect(result.addOnUpdates).to.deep.equal([])
|
||||
})
|
||||
|
||||
it('preserves non-members-limit add-ons when canceling pending group plan upgrade', function (ctx) {
|
||||
const { MEMBERS_LIMIT_ADD_ON_CODE } = ctx.PaymentProviderEntities
|
||||
|
||||
// Current: professional-annual with AI add-on (not a group plan)
|
||||
ctx.subscription.planCode = 'professional-annual'
|
||||
ctx.subscription.planName = 'Professional Annual'
|
||||
const aiAddOn =
|
||||
new ctx.PaymentProviderEntities.PaymentProviderSubscriptionAddOn({
|
||||
code: AI_ADD_ON_CODE,
|
||||
name: 'AI Add-On',
|
||||
quantity: 1,
|
||||
unitPrice: 20,
|
||||
})
|
||||
ctx.subscription.addOns = [aiAddOn]
|
||||
|
||||
// Pending: group_professional_10_educational + AI add-on + 5 additional-license
|
||||
const additionalLicenseAddOn =
|
||||
new ctx.PaymentProviderEntities.PaymentProviderSubscriptionAddOn({
|
||||
code: MEMBERS_LIMIT_ADD_ON_CODE,
|
||||
name: 'Additional License',
|
||||
quantity: 5,
|
||||
unitPrice: 10,
|
||||
})
|
||||
|
||||
ctx.subscription.pendingChange =
|
||||
new PaymentProviderSubscriptionChange({
|
||||
subscription: ctx.subscription,
|
||||
nextPlanCode: 'group_professional_10_educational',
|
||||
nextPlanName: 'Group Professional Educational',
|
||||
nextPlanPrice: 100,
|
||||
nextAddOns: [aiAddOn, additionalLicenseAddOn],
|
||||
})
|
||||
|
||||
const result = ctx.subscription.getRequestForPlanChangeCancellation()
|
||||
|
||||
// Should return a change request that keeps the current plan and AI add-on
|
||||
// but removes the additional-license add-on
|
||||
expect(result).to.not.be.null
|
||||
expect(result.timeframe).to.equal('term_end')
|
||||
expect(result.planCode).to.equal('professional-annual')
|
||||
expect(result.addOnUpdates).to.deep.equal([
|
||||
new PaymentProviderSubscriptionAddOnUpdate({
|
||||
code: AI_ADD_ON_CODE,
|
||||
quantity: 1,
|
||||
unitPrice: 20,
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('with an add-on pending cancellation', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.subscription.pendingChange =
|
||||
@@ -491,6 +847,7 @@ describe('PaymentProviderEntities', function () {
|
||||
new PaymentProviderSubscriptionChangeRequest({
|
||||
subscription: ctx.subscription,
|
||||
timeframe: 'term_end',
|
||||
planCode: ctx.subscription.pendingChange.nextPlanCode,
|
||||
addOnUpdates: [ctx.addOn.toAddOnUpdate()],
|
||||
})
|
||||
)
|
||||
|
||||
@@ -5,8 +5,10 @@ import PaymentProviderEntities from '../../../../app/src/Features/Subscription/P
|
||||
|
||||
const {
|
||||
PaymentProviderSubscription,
|
||||
PaymentProviderSubscriptionChange,
|
||||
PaymentProviderSubscriptionChangeRequest,
|
||||
PaymentProviderSubscriptionUpdateRequest,
|
||||
PaymentProviderSubscriptionAddOn,
|
||||
PaymentProviderSubscriptionAddOnUpdate,
|
||||
PaymentProviderAccount,
|
||||
PaymentProviderCoupon,
|
||||
@@ -459,6 +461,241 @@ describe('RecurlyClient', function () {
|
||||
})
|
||||
).to.be.rejectedWith(Error)
|
||||
})
|
||||
|
||||
describe('when subscription has a pending add-on removal', function () {
|
||||
beforeEach(function (ctx) {
|
||||
// Create subscription with an add-on that has a pending removal scheduled
|
||||
ctx.subscriptionWithAddOn = new PaymentProviderSubscription({
|
||||
id: 'subscription-id',
|
||||
userId: 'user-id',
|
||||
currency: 'EUR',
|
||||
planCode: 'collaborator',
|
||||
planName: 'Collaborator Plan',
|
||||
planPrice: 13,
|
||||
addOns: [
|
||||
new PaymentProviderSubscriptionAddOn({
|
||||
code: 'assistant',
|
||||
name: 'AI Assistant',
|
||||
quantity: 1,
|
||||
unitPrice: 5,
|
||||
}),
|
||||
],
|
||||
subtotal: 18,
|
||||
taxRate: 0.1,
|
||||
taxAmount: 1.8,
|
||||
total: 19.8,
|
||||
periodStart: new Date(),
|
||||
periodEnd: new Date(),
|
||||
collectionMethod: 'automatic',
|
||||
service: 'recurly',
|
||||
state: 'active',
|
||||
})
|
||||
ctx.subscriptionWithAddOn.pendingChange =
|
||||
new PaymentProviderSubscriptionChange({
|
||||
subscription: ctx.subscriptionWithAddOn,
|
||||
nextPlanCode: 'collaborator',
|
||||
nextPlanName: 'Collaborator Plan',
|
||||
nextPlanPrice: 13,
|
||||
nextAddOns: [], // add-on is being removed
|
||||
})
|
||||
|
||||
ctx.client.getSubscription = sinon.stub().resolves({
|
||||
uuid: 'subscription-id',
|
||||
account: { code: 'user-id' },
|
||||
plan: { code: 'professional', name: 'Professional Plan' },
|
||||
addOns: [
|
||||
{
|
||||
addOn: { code: 'assistant', name: 'AI Assistant' },
|
||||
quantity: 1,
|
||||
unitAmount: 5,
|
||||
},
|
||||
],
|
||||
unitAmount: 20,
|
||||
subtotal: 25,
|
||||
taxInfo: { rate: 0.1 },
|
||||
tax: 2.5,
|
||||
total: 27.5,
|
||||
currency: 'EUR',
|
||||
currentPeriodStartedAt: new Date(),
|
||||
currentPeriodEndsAt: new Date(),
|
||||
collectionMethod: 'automatic',
|
||||
netTerms: 0,
|
||||
poNumber: '',
|
||||
termsAndConditions: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('re-applies pending add-on removal after immediate plan upgrade', async function (ctx) {
|
||||
await ctx.RecurlyClient.promises.applySubscriptionChangeRequest(
|
||||
new PaymentProviderSubscriptionChangeRequest({
|
||||
subscription: ctx.subscriptionWithAddOn,
|
||||
timeframe: 'now',
|
||||
planCode: 'professional',
|
||||
})
|
||||
)
|
||||
|
||||
// immediate change
|
||||
expect(ctx.client.createSubscriptionChange).to.have.been.calledWith(
|
||||
'uuid-subscription-id',
|
||||
{ timeframe: 'now', planCode: 'professional' }
|
||||
)
|
||||
|
||||
// re-apply pending change: only add-on removal, NOT the plan downgrade
|
||||
// because the immediate change was a plan change, the user's new plan choice supersedes
|
||||
expect(ctx.client.createSubscriptionChange).to.have.been.calledWith(
|
||||
'uuid-subscription-id',
|
||||
{
|
||||
timeframe: 'term_end',
|
||||
addOns: [],
|
||||
}
|
||||
)
|
||||
|
||||
expect(ctx.client.createSubscriptionChange.callCount).to.equal(2)
|
||||
})
|
||||
|
||||
it('does not re-apply pending change if plan and add-ons are the same', async function (ctx) {
|
||||
ctx.subscriptionWithAddOn.pendingChange =
|
||||
new PaymentProviderSubscriptionChange({
|
||||
subscription: ctx.subscriptionWithAddOn,
|
||||
nextPlanCode: 'professional', // pending change to professional
|
||||
nextPlanName: 'Professional Plan',
|
||||
nextPlanPrice: 20,
|
||||
nextAddOns: [
|
||||
new PaymentProviderSubscriptionAddOn({
|
||||
code: 'assistant',
|
||||
name: 'AI Assistant',
|
||||
quantity: 1,
|
||||
unitPrice: 5,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
await ctx.RecurlyClient.promises.applySubscriptionChangeRequest(
|
||||
new PaymentProviderSubscriptionChangeRequest({
|
||||
subscription: ctx.subscriptionWithAddOn,
|
||||
timeframe: 'now',
|
||||
planCode: 'professional',
|
||||
})
|
||||
)
|
||||
|
||||
expect(ctx.client.createSubscriptionChange.callCount).to.equal(1)
|
||||
})
|
||||
|
||||
it('does not re-apply anything if no pending change existed', async function (ctx) {
|
||||
ctx.subscriptionWithAddOn.pendingChange = null
|
||||
|
||||
await ctx.RecurlyClient.promises.applySubscriptionChangeRequest(
|
||||
new PaymentProviderSubscriptionChangeRequest({
|
||||
subscription: ctx.subscriptionWithAddOn,
|
||||
timeframe: 'now',
|
||||
planCode: 'professional',
|
||||
})
|
||||
)
|
||||
|
||||
expect(ctx.client.createSubscriptionChange.callCount).to.equal(1)
|
||||
})
|
||||
|
||||
it('does not re-apply when timeframe is term_end', async function (ctx) {
|
||||
await ctx.RecurlyClient.promises.applySubscriptionChangeRequest(
|
||||
new PaymentProviderSubscriptionChangeRequest({
|
||||
subscription: ctx.subscriptionWithAddOn,
|
||||
timeframe: 'term_end',
|
||||
planCode: 'professional',
|
||||
})
|
||||
)
|
||||
|
||||
expect(ctx.client.createSubscriptionChange.callCount).to.equal(1)
|
||||
})
|
||||
|
||||
it('re-applies pending plan downgrade while preserving newly purchased add-on', async function (ctx) {
|
||||
const subscriptionWithPendingDowngrade =
|
||||
new PaymentProviderSubscription({
|
||||
id: 'subscription-id',
|
||||
userId: 'user-id',
|
||||
currency: 'EUR',
|
||||
planCode: 'professional',
|
||||
planName: 'Professional Plan',
|
||||
planPrice: 20,
|
||||
addOns: [], // NO add-ons originally
|
||||
subtotal: 20,
|
||||
taxRate: 0.1,
|
||||
taxAmount: 2,
|
||||
total: 22,
|
||||
periodStart: new Date(),
|
||||
periodEnd: new Date(),
|
||||
collectionMethod: 'automatic',
|
||||
service: 'recurly',
|
||||
state: 'active',
|
||||
})
|
||||
subscriptionWithPendingDowngrade.pendingChange =
|
||||
new PaymentProviderSubscriptionChange({
|
||||
subscription: subscriptionWithPendingDowngrade,
|
||||
nextPlanCode: 'collaborator',
|
||||
nextPlanName: 'Collaborator Plan',
|
||||
nextPlanPrice: 13,
|
||||
nextAddOns: [], // NO add-ons in pending change
|
||||
})
|
||||
|
||||
// mock the subscription fetch after immediate update - now has add-on
|
||||
ctx.client.getSubscription = sinon.stub().resolves({
|
||||
uuid: 'subscription-id',
|
||||
account: { code: 'user-id' },
|
||||
plan: { code: 'professional', name: 'Professional Plan' },
|
||||
addOns: [
|
||||
{
|
||||
addOn: { code: 'assistant', name: 'AI Assistant' },
|
||||
quantity: 1,
|
||||
unitAmount: 5,
|
||||
},
|
||||
],
|
||||
unitAmount: 20,
|
||||
subtotal: 25,
|
||||
taxInfo: { rate: 0.1 },
|
||||
tax: 2.5,
|
||||
total: 27.5,
|
||||
currency: 'EUR',
|
||||
currentPeriodStartedAt: new Date(),
|
||||
currentPeriodEndsAt: new Date(),
|
||||
collectionMethod: 'automatic',
|
||||
netTerms: 0,
|
||||
poNumber: '',
|
||||
termsAndConditions: '',
|
||||
})
|
||||
|
||||
await ctx.RecurlyClient.promises.applySubscriptionChangeRequest(
|
||||
new PaymentProviderSubscriptionChangeRequest({
|
||||
subscription: subscriptionWithPendingDowngrade,
|
||||
timeframe: 'now',
|
||||
addOnUpdates: [
|
||||
new PaymentProviderSubscriptionAddOnUpdate({
|
||||
code: 'assistant',
|
||||
quantity: 1,
|
||||
unitPrice: 5,
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
expect(ctx.client.createSubscriptionChange).to.have.been.calledWith(
|
||||
'uuid-subscription-id',
|
||||
{
|
||||
timeframe: 'now',
|
||||
addOns: [{ code: 'assistant', quantity: 1, unitAmount: 5 }],
|
||||
}
|
||||
)
|
||||
|
||||
expect(ctx.client.createSubscriptionChange).to.have.been.calledWith(
|
||||
'uuid-subscription-id',
|
||||
{
|
||||
timeframe: 'term_end',
|
||||
planCode: 'collaborator',
|
||||
addOns: [{ code: 'assistant', quantity: 1, unitAmount: 5 }], // add-on preserved!
|
||||
}
|
||||
)
|
||||
|
||||
expect(ctx.client.createSubscriptionChange.callCount).to.equal(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateSubscriptionDetails', function () {
|
||||
|
||||
@@ -1122,6 +1122,24 @@ describe('SubscriptionController', function () {
|
||||
)
|
||||
).to.be.true
|
||||
})
|
||||
|
||||
it('should handle MultiplePendingChangesError and return 422 with JSON response', async function (ctx) {
|
||||
ctx.req.params.addOnCode = AI_ADD_ON_CODE
|
||||
ctx.SubscriptionHandler.promises.purchaseAddon.rejects(
|
||||
new SubscriptionErrors.MultiplePendingChangesError()
|
||||
)
|
||||
|
||||
await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
|
||||
|
||||
expect(ctx.res.status).toHaveBeenCalledWith(422)
|
||||
expect(ctx.res.json).toHaveBeenCalledWith({
|
||||
code: 'multiple_pending_changes',
|
||||
message:
|
||||
'Cannot complete purchase while there are multiple pending subscription changes. Please contact support.',
|
||||
})
|
||||
expect(ctx.FeaturesUpdater.promises.refreshFeatures).to.not.have.been
|
||||
.called
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeAddon', function () {
|
||||
@@ -1173,6 +1191,21 @@ describe('SubscriptionController', function () {
|
||||
{ addon: AI_ADD_ON_CODE }
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle MultiplePendingChangesError and return 422 with JSON response', async function (ctx) {
|
||||
ctx.SubscriptionHandler.promises.removeAddon.rejects(
|
||||
new SubscriptionErrors.MultiplePendingChangesError()
|
||||
)
|
||||
|
||||
await ctx.SubscriptionController.removeAddon(ctx.req, ctx.res, ctx.next)
|
||||
|
||||
expect(ctx.res.status).toHaveBeenCalledWith(422)
|
||||
expect(ctx.res.json).toHaveBeenCalledWith({
|
||||
code: 'multiple_pending_changes',
|
||||
message:
|
||||
'Cannot remove add-on while there are multiple pending subscription changes. Please contact support.',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkSubscriptionPauseStatus', function () {
|
||||
|
||||
@@ -66,7 +66,6 @@ describe('SubscriptionGroupController', function () {
|
||||
ensureFlexibleLicensingEnabled: sinon.stub().resolves(),
|
||||
ensureSubscriptionIsActive: sinon.stub().resolves(),
|
||||
ensureSubscriptionCollectionMethodIsNotManual: sinon.stub().resolves(),
|
||||
ensureSubscriptionHasNoPendingChanges: sinon.stub().resolves(),
|
||||
ensureSubscriptionHasNoPastDueInvoice: sinon.stub().resolves(),
|
||||
getGroupPlanUpgradePreview: sinon
|
||||
.stub()
|
||||
@@ -132,7 +131,6 @@ describe('SubscriptionGroupController', function () {
|
||||
ctx.Errors = {
|
||||
MissingBillingInfoError: class extends Error {},
|
||||
ManuallyCollectedError: class extends Error {},
|
||||
PendingChangeError: class extends Error {},
|
||||
InactiveError: class extends Error {},
|
||||
SubtotalLimitExceededError: class extends Error {},
|
||||
HasPastDueInvoiceError: class extends Error {},
|
||||
@@ -143,6 +141,7 @@ describe('SubscriptionGroupController', function () {
|
||||
this.info = info
|
||||
}
|
||||
},
|
||||
MultiplePendingChangesError: class extends Error {},
|
||||
}
|
||||
|
||||
vi.doMock(
|
||||
@@ -440,9 +439,6 @@ describe('SubscriptionGroupController', function () {
|
||||
ctx.SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled
|
||||
.calledWith(ctx.plan)
|
||||
.should.equal(true)
|
||||
ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges
|
||||
.calledWith(ctx.recurlySubscription)
|
||||
.should.equal(true)
|
||||
ctx.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive
|
||||
.calledWith(ctx.subscription)
|
||||
.should.equal(true)
|
||||
@@ -542,22 +538,6 @@ describe('SubscriptionGroupController', function () {
|
||||
})
|
||||
})
|
||||
|
||||
it('should redirect to subscription page when there is a pending change', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges =
|
||||
sinon.stub().throws(new ctx.Errors.PendingChangeError())
|
||||
|
||||
const res = {
|
||||
redirect: url => {
|
||||
url.should.equal('/user/subscription')
|
||||
resolve()
|
||||
},
|
||||
}
|
||||
|
||||
ctx.Controller.addSeatsToGroupSubscription(ctx.req, res)
|
||||
})
|
||||
})
|
||||
|
||||
it('should redirect to subscription page when subscription is not active', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive = sinon
|
||||
@@ -756,6 +736,28 @@ describe('SubscriptionGroupController', function () {
|
||||
ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res)
|
||||
})
|
||||
})
|
||||
|
||||
it('should return 422 with MultiplePendingChangesError', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.req.body = { adding: 2 }
|
||||
ctx.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange =
|
||||
sinon.stub().throws(new ctx.Errors.MultiplePendingChangesError())
|
||||
|
||||
const res = {
|
||||
status: statusCode => {
|
||||
statusCode.should.equal(422)
|
||||
|
||||
return {
|
||||
end: () => {
|
||||
resolve()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitForm', function () {
|
||||
@@ -963,5 +965,30 @@ describe('SubscriptionGroupController', function () {
|
||||
ctx.Controller.upgradeSubscription(ctx.req, res)
|
||||
})
|
||||
})
|
||||
|
||||
it('should send 422 response with MultiplePendingChangesError', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.SubscriptionGroupHandler.promises.upgradeGroupPlan = sinon
|
||||
.stub()
|
||||
.rejects(new ctx.Errors.MultiplePendingChangesError())
|
||||
const res = {
|
||||
status: code => {
|
||||
code.should.equal(422)
|
||||
return {
|
||||
json: data => {
|
||||
data.should.deep.equal({
|
||||
code: 'multiple_pending_changes',
|
||||
message:
|
||||
'Cannot upgrade subscription while there are multiple pending subscription changes. Please contact support.',
|
||||
})
|
||||
resolve()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
ctx.Controller.upgradeSubscription(ctx.req, res)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -819,22 +819,6 @@ describe('SubscriptionGroupHandler', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureSubscriptionHasNoPendingChanges', function () {
|
||||
it('should throw if the subscription has pending change', async function (ctx) {
|
||||
await expect(
|
||||
ctx.Handler.promises.ensureSubscriptionHasNoPendingChanges({
|
||||
pendingChange: {},
|
||||
})
|
||||
).to.be.rejectedWith('This subscription has a pending change')
|
||||
})
|
||||
|
||||
it('should not throw if the subscription has no pending change', async function (ctx) {
|
||||
await expect(
|
||||
ctx.Handler.promises.ensureSubscriptionHasNoPendingChanges({})
|
||||
).to.not.be.rejected
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureSubscriptionHasNoPastDueInvoice', function () {
|
||||
it('should throw if the subscription has past due invoice', async function (ctx) {
|
||||
ctx.Modules.promises.hooks.fire
|
||||
|
||||
@@ -395,7 +395,7 @@ describe('SubscriptionHandler', function () {
|
||||
})
|
||||
})
|
||||
|
||||
it('should not fire cancelPendingPaidSubscriptionChange hook if user has no subscription', async function (ctx) {
|
||||
it('should not fire hooks if user has no subscription', async function (ctx) {
|
||||
ctx.LimitationsManager.promises.userHasSubscription.resolves({
|
||||
hasSubscription: false,
|
||||
subscription: null,
|
||||
@@ -404,25 +404,91 @@ describe('SubscriptionHandler', function () {
|
||||
ctx.user,
|
||||
ctx.plan_code
|
||||
)
|
||||
expect(ctx.Modules.promises.hooks.fire).to.not.have.been.calledWith(
|
||||
'cancelPendingPaidSubscriptionChange',
|
||||
sinon.match.any
|
||||
)
|
||||
expect(ctx.Modules.promises.hooks.fire).to.not.have.been.called
|
||||
})
|
||||
|
||||
it('should fire cancelPendingPaidSubscriptionChange to update a valid subscription', async function (ctx) {
|
||||
it('should get payment record and apply change request', async function (ctx) {
|
||||
const changeRequest = { subscription: { id: 'sub_123' } }
|
||||
const paymentProviderSubscription = {
|
||||
id: 'sub_123',
|
||||
service: 'stripe',
|
||||
getRequestForPlanChangeCancellation: sinon
|
||||
.stub()
|
||||
.returns(changeRequest),
|
||||
}
|
||||
const paymentRecord = { subscription: paymentProviderSubscription }
|
||||
|
||||
ctx.LimitationsManager.promises.userHasSubscription.resolves({
|
||||
hasSubscription: true,
|
||||
subscription: ctx.subscription,
|
||||
})
|
||||
ctx.Modules.promises.hooks.fire
|
||||
.withArgs('getPaymentFromRecord', ctx.subscription)
|
||||
.resolves([paymentRecord])
|
||||
ctx.Modules.promises.hooks.fire
|
||||
.withArgs(
|
||||
'applySubscriptionChangeRequestAndSync',
|
||||
changeRequest,
|
||||
ctx.user._id.toString()
|
||||
)
|
||||
.resolves([Promise.resolve()])
|
||||
|
||||
await ctx.SubscriptionHandler.promises.cancelPendingSubscriptionChange(
|
||||
ctx.user,
|
||||
ctx.plan_code
|
||||
)
|
||||
|
||||
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
|
||||
'getPaymentFromRecord',
|
||||
ctx.subscription
|
||||
)
|
||||
expect(paymentProviderSubscription.getRequestForPlanChangeCancellation).to
|
||||
.have.been.called
|
||||
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
|
||||
'applySubscriptionChangeRequestAndSync',
|
||||
changeRequest,
|
||||
ctx.user._id.toString()
|
||||
)
|
||||
})
|
||||
|
||||
it('should remove pending change when there are no add-on changes to preserve', async function (ctx) {
|
||||
const paymentProviderSubscription = {
|
||||
id: 'sub_123',
|
||||
service: 'stripe',
|
||||
pendingChange: { nextPlanCode: 'student' },
|
||||
getRequestForPlanChangeCancellation: sinon.stub().returns(null),
|
||||
}
|
||||
const paymentRecord = { subscription: paymentProviderSubscription }
|
||||
|
||||
ctx.LimitationsManager.promises.userHasSubscription.resolves({
|
||||
hasSubscription: true,
|
||||
subscription: ctx.subscription,
|
||||
})
|
||||
ctx.Modules.promises.hooks.fire
|
||||
.withArgs('getPaymentFromRecord', ctx.subscription)
|
||||
.resolves([paymentRecord])
|
||||
ctx.Modules.promises.hooks.fire
|
||||
.withArgs('cancelPendingPaidSubscriptionChange', ctx.subscription)
|
||||
.resolves([Promise.resolve()])
|
||||
|
||||
await ctx.SubscriptionHandler.promises.cancelPendingSubscriptionChange(
|
||||
ctx.user,
|
||||
ctx.plan_code
|
||||
)
|
||||
|
||||
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
|
||||
'getPaymentFromRecord',
|
||||
ctx.subscription
|
||||
)
|
||||
expect(paymentProviderSubscription.getRequestForPlanChangeCancellation).to
|
||||
.have.been.called
|
||||
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
|
||||
'cancelPendingPaidSubscriptionChange',
|
||||
ctx.subscription
|
||||
)
|
||||
expect(ctx.Modules.promises.hooks.fire).to.not.have.been.calledWith(
|
||||
'applySubscriptionChangeRequestAndSync'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -378,7 +378,7 @@ describe('UserMembershipController', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should be false when recurly subscription has pending changes', async ({
|
||||
it('should be true when recurly subscription has pending changes', async ({
|
||||
UserMembershipController,
|
||||
req,
|
||||
RecurlyClient,
|
||||
@@ -390,7 +390,7 @@ describe('UserMembershipController', () => {
|
||||
})
|
||||
await UserMembershipController.manageGroupMembers(req, {
|
||||
render: (viewPath, viewParams) => {
|
||||
expect(viewParams.canUseAddSeatsFeature).to.equal(false)
|
||||
expect(viewParams.canUseAddSeatsFeature).to.equal(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user