From aa6a176fb93b63ca5d682d56c103b87ae9b5fdf9 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sat, 23 May 2026 15:54:51 +0100 Subject: [PATCH] feat(migration): convert annual contributionAmount to cadence-unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-time script to convert existing annual Member records from the legacy monthly-equivalent interpretation to cadence-unit. Multiplies contributionAmount by 12 for billingCadence='annual' members. Companion to commit 5023fb1 which dropped the server's ×12 on annual recurringAmount. Must run after deploy, before annual members renew. Idempotent via a transient `contributionAmountConverted: true` marker field on each migrated doc — re-runs are safe. Dry-run by default; `--apply` to write. Skips null/undefined contributionAmount, logs $0 amounts as no-ops. --- ...te-annual-contribution-to-cadence-unit.cjs | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 scripts/migrate-annual-contribution-to-cadence-unit.cjs diff --git a/scripts/migrate-annual-contribution-to-cadence-unit.cjs b/scripts/migrate-annual-contribution-to-cadence-unit.cjs new file mode 100644 index 0000000..ed3d02a --- /dev/null +++ b/scripts/migrate-annual-contribution-to-cadence-unit.cjs @@ -0,0 +1,112 @@ +/** + * One-time migration: convert Member.contributionAmount from monthly-equivalent + * to cadence-unit for billingCadence='annual' members. After this migration, + * an annual member with contributionAmount=180 means "$180/year" (vs the old + * interpretation "$15/month × 12 = $180/year"). + * + * Companion to commit 5023fb1 which dropped × 12 from the server's recurringAmount + * computation. Run AFTER deploying the new server code, BEFORE annual members + * try to renew or update. + * + * IDEMPOTENT via a transient marker field (`contributionAmountConverted: true`). + * The script only acts on rows where this flag is not set, so re-running is + * safe — the second run will find 0 unconverted rows. + * + * Trade-off: this pollutes the schema with a transient field. A follow-up + * migration can $unset `contributionAmountConverted` from all docs once every + * environment is confirmed migrated. The field is harmless if left in place. + * (Option A from Task 9's plan — chosen because no migration-tracking + * collection exists in this codebase.) + * + * Usage: + * node scripts/migrate-annual-contribution-to-cadence-unit.cjs # dry-run + * node scripts/migrate-annual-contribution-to-cadence-unit.cjs --apply # writes + */ +require('dotenv').config() +const mongoose = require('mongoose') + +async function run() { + const apply = process.argv.includes('--apply') + if (!process.env.MONGODB_URI) { + console.error('MONGODB_URI not set in environment') + process.exit(1) + } + + await mongoose.connect(process.env.MONGODB_URI) + const db = mongoose.connection.db + const col = db.collection('members') + + // Find annual members that haven't been converted yet. + const filter = { + billingCadence: 'annual', + contributionAmountConverted: { $ne: true }, + } + const legacyCount = await col.countDocuments(filter) + console.log(`Found ${legacyCount} annual members not yet converted`) + + if (legacyCount === 0) { + console.log('Nothing to migrate.') + await mongoose.disconnect() + return + } + + const cursor = col.find(filter) + let updated = 0 + let skipped = 0 + + while (await cursor.hasNext()) { + const doc = await cursor.next() + const current = doc.contributionAmount + + if (current === null || current === undefined) { + console.warn(` SKIP ${doc._id}: contributionAmount is ${current}`) + skipped++ + continue + } + + if (!Number.isInteger(current) || current < 0) { + console.warn( + ` SKIP ${doc._id}: contributionAmount=${JSON.stringify(current)} is not a non-negative integer`, + ) + skipped++ + continue + } + + const next = current * 12 + + if (current === 0) { + console.log(` ${doc._id}: contributionAmount=0, no change (still 0 after ×12)`) + } else { + console.log( + ` ${apply ? 'Update' : 'Would update'} ${doc._id}: contributionAmount ${current} → ${next}`, + ) + } + + if (apply) { + await col.updateOne( + { _id: doc._id }, + { + $set: { + contributionAmount: next, + contributionAmountConverted: true, + }, + }, + ) + } + updated++ + } + + console.log( + `\n${apply ? 'Updated' : 'Would update'} ${updated} documents, skipped ${skipped}`, + ) + if (!apply) console.log('Dry-run complete. Re-run with --apply to write changes.') + await mongoose.disconnect() +} + +run().catch(async (err) => { + console.error(err) + try { + await mongoose.disconnect() + } catch {} + process.exit(1) +})