Skip to content

Commit c7371cd

Browse files
authored
Merge pull request #19783 from mozilla/updates-to-scripts
feat: improve customer mover and cancellation scripts
2 parents 7de3b2f + 1196233 commit c7371cd

8 files changed

Lines changed: 353 additions & 91 deletions

File tree

packages/fxa-auth-server/lib/payments/paypal/helper.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -714,10 +714,10 @@ export class PayPalHelper {
714714

715715
if (
716716
behavior.refundType === RefundType.Partial &&
717-
behavior.amount >= invoice.amount_paid
717+
behavior.amount >= invoice.amount_due
718718
) {
719719
throw new RefundError(
720-
'Partial refunds must be less than the amount paid on the invoice'
720+
'Partial refunds must be less than the amount due on the invoice'
721721
);
722722
}
723723
const amount =

packages/fxa-auth-server/scripts/cancel-subscriptions-to-plan.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ const parseRemainingValueMode = (
3232
return mode as "noaction" | "refund" | "prorate" | "proratedRefund";
3333
};
3434

35+
const parseProratedRefundRate = (proratedRefundRate: string) => {
36+
if (!proratedRefundRate) return null;
37+
const value = parseInt(proratedRefundRate);
38+
if (isNaN(value) || value <= 0) throw new Error("Invalid proratedRefundRate");
39+
return value;
40+
};
41+
3542
async function init() {
3643
program
3744
.version(pckg.version)
@@ -59,6 +66,10 @@ async function init() {
5966
'How to handle remaining subscription value: noaction, refund, prorate, proratedRefund',
6067
'noaction'
6168
)
69+
.option(
70+
'-p, --prorated-refund-rate [number]',
71+
'The rate per day (in whole cents) at which to refund subscriptions in proratedRefund mode'
72+
)
6273
.option(
6374
'--dry-run',
6475
'List the customers that would be cancelled without actually cancelling them'
@@ -87,13 +98,19 @@ async function init() {
8798
const rateLimit = parseRateLimit(program.rateLimit);
8899
const excludePlanIds = parseExcludePlanIds(program.exclude);
89100
const remainingValueMode = parseRemainingValueMode(program.mode);
101+
const proratedRefundRate = parseProratedRefundRate(program.proratedRefundRate);
90102

91103
const dryRun = !!program.dryRun;
92104
if (!program.price) throw new Error('--price must be provided');
93105

106+
if (remainingValueMode === 'proratedRefund' && proratedRefundRate === null) {
107+
throw new Error('--prorated-refund-rate must be provided when using proratedRefund mode');
108+
}
109+
94110
const planCanceller = new PlanCanceller(
95111
program.price,
96112
remainingValueMode,
113+
proratedRefundRate,
97114
excludePlanIds,
98115
program.outputFile,
99116
stripeHelper,

packages/fxa-auth-server/scripts/cancel-subscriptions-to-plan/cancel-subscriptions-to-plan.ts

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ import { StripeHelper } from '../../lib/payments/stripe';
1010
import { PayPalHelper } from 'packages/fxa-auth-server/lib/payments/paypal';
1111
import { RefundType } from '@fxa/payments/paypal';
1212

13+
/**
14+
* For RAM-preserving pruposes only
15+
*/
16+
const QUEUE_SIZE_LIMIT = 1000;
17+
/**
18+
* For RAM-preserving pruposes only
19+
*/
20+
const QUEUE_CONCURRENCY_LIMIT = 100;
21+
1322
export class PlanCanceller {
1423
private stripeQueue: PQueue;
1524
private stripe: Stripe;
@@ -18,6 +27,7 @@ export class PlanCanceller {
1827
* A tool to cancel all subscriptions under a plan
1928
* @param priceId A Stripe plan or price ID for which all subscriptions will be cancelled
2029
* @param remainingValueMode Configuration for how to handle remaining subscription value
30+
* @param proratedRefundRate The rate per day (in whole cents) at which to refund subscriptions in proratedRefund mode
2131
* @param excludePlanIds A list of Stripe plan or price ID which if customers have will not be cancelled
2232
* @param outputFile A CSV file to output a report of affected subscriptions to
2333
* @param stripeHelper An instance of StripeHelper
@@ -28,13 +38,22 @@ export class PlanCanceller {
2838
constructor(
2939
private priceId: string,
3040
private remainingValueMode: "noaction" | "refund" | "prorate" | "proratedRefund",
41+
private proratedRefundRate: number | null,
3142
private excludePlanIds: string[],
3243
private outputFile: string,
3344
private stripeHelper: StripeHelper,
3445
private paypalHelper: PayPalHelper,
3546
public dryRun: boolean,
3647
rateLimit: number
3748
) {
49+
if (remainingValueMode === 'proratedRefund' && proratedRefundRate === null) {
50+
throw new Error("proratedRefundRate must be provided when using proratedRefund mode");
51+
}
52+
53+
if (proratedRefundRate !== null && proratedRefundRate <= 0) {
54+
throw new Error("proratedRefundRate must be greater than zero");
55+
}
56+
3857
this.stripe = this.stripeHelper.stripe;
3958

4059
this.stripeQueue = new PQueue({
@@ -46,12 +65,22 @@ export class PlanCanceller {
4665
async run(): Promise<void> {
4766
await this.writeReportHeader();
4867

49-
await this.stripe.subscriptions.list({
68+
const conversionQueue = new PQueue({ concurrency: QUEUE_CONCURRENCY_LIMIT });
69+
70+
for await (const subscription of this.stripe.subscriptions.list({
5071
price: this.priceId,
5172
limit: 100,
52-
}).autoPagingEach((subscription) => {
53-
return this.processSubscription(subscription);
54-
});
73+
})) {
74+
if (conversionQueue.size + conversionQueue.pending >= QUEUE_SIZE_LIMIT) {
75+
await conversionQueue.onSizeLessThan(QUEUE_SIZE_LIMIT - QUEUE_CONCURRENCY_LIMIT);
76+
}
77+
78+
conversionQueue.add(() => {
79+
return this.processSubscription(subscription);
80+
});
81+
}
82+
83+
await conversionQueue.onIdle();
5584
}
5685

5786
async processSubscription(
@@ -64,6 +93,8 @@ export class PlanCanceller {
6493
: subscription.customer.id;
6594

6695
try {
96+
console.log(`Processing ${subscription.id}`);
97+
6798
const customer = await this.fetchCustomer(customerId);
6899
if (!customer?.subscriptions?.data) {
69100
throw new Error(`Customer not found: ${customerId}`);
@@ -75,7 +106,7 @@ export class PlanCanceller {
75106
let approximateAmountWasOwed: number | null = null;
76107
let daysSinceLastBill: number | null = null;
77108
let daysUntilNextBill: number | null = null;
78-
let previousInvoiceAmountPaid: number | null = null;
109+
let previousInvoiceAmountDue: number | null = null;
79110
let isOwed = !isExcluded;
80111

81112
if (!isExcluded) {
@@ -85,7 +116,7 @@ export class PlanCanceller {
85116
approximateAmountWasOwed = calculation.refundAmount;
86117
daysSinceLastBill = calculation.daysSinceBill;
87118
daysUntilNextBill = calculation.daysUntilNextBill;
88-
previousInvoiceAmountPaid = calculation.invoice.amount_paid;
119+
previousInvoiceAmountDue = calculation.invoice.amount_due;
89120
} catch(e) {
90121
console.warn(e);
91122
}
@@ -95,6 +126,9 @@ export class PlanCanceller {
95126
await this.enqueueRequest(() =>
96127
this.stripe.subscriptions.cancel(subscription.id, {
97128
prorate: this.remainingValueMode === "prorate",
129+
cancellation_details: {
130+
comment: "administrative_cancellation:subplat_script"
131+
}
98132
})
99133
);
100134
}
@@ -131,7 +165,7 @@ export class PlanCanceller {
131165
approximateAmountWasOwed,
132166
daysSinceLastBill,
133167
daysUntilNextBill,
134-
previousInvoiceAmountPaid,
168+
previousInvoiceAmountDue,
135169
isOwed,
136170
error: false,
137171
});
@@ -147,7 +181,7 @@ export class PlanCanceller {
147181
approximateAmountWasOwed: null,
148182
daysSinceLastBill: null,
149183
daysUntilNextBill: null,
150-
previousInvoiceAmountPaid: null,
184+
previousInvoiceAmountDue: null,
151185
isOwed: false,
152186
error: true,
153187
});
@@ -213,7 +247,7 @@ export class PlanCanceller {
213247
}
214248
}
215249

216-
return invoice.amount_paid;
250+
return invoice.amount_due;
217251
}
218252

219253
/**
@@ -222,6 +256,10 @@ export class PlanCanceller {
222256
* @returns Object containing refundAmount, daysSinceBill, daysUntilNextBill, and invoice
223257
*/
224258
async calculateRefundAmount(subscription: Stripe.Subscription): Promise<{ refundAmount: number, daysSinceBill: number, daysUntilNextBill: number, invoice: Stripe.Invoice }> {
259+
if (this.proratedRefundRate === null) {
260+
throw new Error("proratedRefundRate must be specified to use calculateRefundAmount");
261+
}
262+
225263
if (!subscription.latest_invoice) {
226264
throw new Error(`No latest invoice for ${subscription.id}`);
227265
}
@@ -245,15 +283,13 @@ export class PlanCanceller {
245283
const periodEnd = new Date(subscription.current_period_end * 1000);
246284
const now = new Date();
247285

248-
const totalPeriodMs = periodEnd.getTime() - periodStart.getTime();
249286
const timeElapsedMs = now.getTime() - periodStart.getTime();
250287
const timeRemainingMs = periodEnd.getTime() - now.getTime();
251288

252289
const daysSinceBill = Math.floor(timeElapsedMs / oneDayMs);
253-
const totalDaysInPeriod = Math.floor(totalPeriodMs / oneDayMs);
254290
const daysUntilNextBill = Math.floor(timeRemainingMs / oneDayMs);
255291

256-
const refundAmount = Math.floor((daysUntilNextBill / totalDaysInPeriod) * invoice.amount_paid);
292+
const refundAmount = daysUntilNextBill * this.proratedRefundRate;
257293

258294
return { refundAmount, daysSinceBill, daysUntilNextBill, invoice };
259295
}
@@ -263,16 +299,16 @@ export class PlanCanceller {
263299
const refundAmount = calculation.refundAmount;
264300
const invoice = calculation.invoice;
265301

266-
if (refundAmount > invoice.amount_paid) {
267-
throw new Error(`Will not refund ${invoice.id} for ${refundAmount} as it would eclipse the amount paid on the invoice`);
302+
if (refundAmount > invoice.amount_due) {
303+
throw new Error(`Will not refund ${invoice.id} for ${refundAmount} as it would eclipse the amount due on the invoice`);
268304
}
269305

270306
if (refundAmount <= 0) {
271307
throw new Error(`Will not refund ${invoice.id} for ${refundAmount} as it is less than or equal to zero`);
272308
}
273309

274310
if (invoice.paid_out_of_band) {
275-
const behavior = refundAmount === invoice.amount_paid ? {
311+
const behavior = refundAmount === invoice.amount_due ? {
276312
refundType: RefundType.Full
277313
} as const : {
278314
refundType: RefundType.Partial,
@@ -333,7 +369,7 @@ export class PlanCanceller {
333369
"approximateAmountWasOwed",
334370
"daysSinceLastBill",
335371
"daysUntilNextBill",
336-
"previousInvoiceAmountPaid",
372+
"previousInvoiceAmountDue",
337373
"isOwed",
338374
"error"
339375
];
@@ -354,7 +390,7 @@ export class PlanCanceller {
354390
approximateAmountWasOwed: number | null,
355391
daysSinceLastBill: number | null,
356392
daysUntilNextBill: number | null,
357-
previousInvoiceAmountPaid: number | null,
393+
previousInvoiceAmountDue: number | null,
358394
isOwed: boolean,
359395
error: boolean
360396
}) {
@@ -370,13 +406,14 @@ export class PlanCanceller {
370406
args.approximateAmountWasOwed ?? "null",
371407
args.daysSinceLastBill ?? "null",
372408
args.daysUntilNextBill ?? "null",
373-
args.previousInvoiceAmountPaid ?? "null",
409+
args.previousInvoiceAmountDue ?? "null",
374410
args.isOwed,
375411
args.error
376412
];
377413

378414
const reportCSV = data.join(',') + '\n';
379415

416+
console.log(reportCSV);
380417
await writeFile(this.outputFile, reportCSV, {
381418
flag: 'a+',
382419
encoding: 'utf-8',

packages/fxa-auth-server/scripts/move-customers-to-new-plan-v2.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ async function init() {
8282
'--skip-subscription-if-set-to-cancel',
8383
'Skip subscriptions that are set to cancel at period end'
8484
)
85+
.option(
86+
'--reset-billing-cycle-anchor',
87+
'Reset the billing cycle anchor to now when updating subscriptions. If not set, billing cycle anchor remains unchanged.'
88+
)
8589
.parse(process.argv);
8690

8791
const { stripeHelper, log } = await setupProcessingTaskObjects(
@@ -95,6 +99,7 @@ async function init() {
9599

96100
const dryRun = !!program.dryRun;
97101
const skipSubscriptionIfSetToCancel = !!program.skipSubscriptionIfSetToCancel;
102+
const resetBillingCycleAnchor = !!program.resetBillingCycleAnchor;
98103

99104
const statsd = {
100105
increment: () => {},
@@ -123,6 +128,7 @@ async function init() {
123128
program.coupon,
124129
prorationBehavior,
125130
skipSubscriptionIfSetToCancel,
131+
resetBillingCycleAnchor,
126132
paypalHelper,
127133
);
128134

0 commit comments

Comments
 (0)