feat(scripts): helcim plan consolidation migration (dry-run default)

This commit is contained in:
Jennie Robinson Faber 2026-04-18 18:12:43 +01:00
parent fb337a4277
commit daea8b65be
3 changed files with 433 additions and 0 deletions

3
.gitignore vendored
View file

@ -26,6 +26,9 @@ logs
!.env.example !.env.example
scripts/*.js scripts/*.js
# Migration backup files
.migration-backup-*.json
# Playwright # Playwright
e2e/test-results/ e2e/test-results/
playwright-report/ playwright-report/

51
scripts/README.md Normal file
View file

@ -0,0 +1,51 @@
# scripts/
One-off admin and migration scripts. None of these run in CI.
---
## helcim-plan-consolidation.js
Consolidates Helcim payment plans from the legacy per-tier model (multiple plans at $15/$30/$50) to two unified plans (Monthly Membership at $15, Annual Membership at $150), and backfills Mongo members.
### Required env
```
HELCIM_API_TOKEN=<token>
MONGODB_URI=<mongodb connection string>
```
Copy `.env.example``.env` if you haven't already.
### Usage
```bash
# Dry-run (default) — no mutations, writes backup file
node scripts/helcim-plan-consolidation.js
# Execute all steps: delete subs, delete legacy plans, create new plans, update Mongo
node scripts/helcim-plan-consolidation.js --confirm
# Mongo cleanup only — skips all Helcim steps (use if Helcim steps already ran)
node scripts/helcim-plan-consolidation.js --mongo-only
```
### What it does
1. **Backup** — always written to `.migration-backup-<timestamp>.json`, even in dry-run. Contains all Helcim subscriptions, payment plans, and Mongo members with `helcimSubscriptionId`.
2. **Delete subscriptions** — deletes all Helcim subscriptions (`--confirm` only).
3. **Delete legacy plans** — deletes plan IDs `20162`, `21596`, `21597`, `21598`, plus any plan whose name matches the legacy pattern (e.g. "Ghost Guild - Member ($15)").
4. **Create Monthly Membership** — $15/month. Idempotent: skipped if a plan named "Monthly Membership" already exists.
5. **Create Annual Membership** — $150/year. Same idempotency. Uses `billingPeriod: "yearly"` (Helcim v2 convention); if Helcim returns 4xx, the script aborts and prints the full error so you can correct the field name.
6. **Mongo cleanup**`updateMany` with `runValidators: false` on all members with `helcimSubscriptionId`: sets `contributionTier: '0'` and `billingCadence: 'monthly'`, unsets `helcimSubscriptionId`. Does **not** change `status`.
At the end, prints new plan IDs formatted for copy-paste into `.env`.
### Recovery
If something goes wrong mid-run, the backup JSON contains the pre-migration state. You can use the backup to:
- Identify which subscriptions existed before deletion.
- Identify which plan IDs to recreate manually in Helcim.
- Identify which members had `helcimSubscriptionId` before it was unset.
Backup files are gitignored (`.migration-backup-*.json`).

View file

@ -0,0 +1,379 @@
/**
* One-off migration: consolidate Helcim payment plans from per-tier model
* to unified Monthly/Annual Membership plans and backfill Mongo members.
*
* Usage:
* node scripts/helcim-plan-consolidation.js # dry-run (default)
* node scripts/helcim-plan-consolidation.js --confirm # execute all steps
* node scripts/helcim-plan-consolidation.js --mongo-only # Mongo cleanup only (no Helcim)
*
* Required env: HELCIM_API_TOKEN, MONGODB_URI
*/
import dotenv from 'dotenv'
import { fileURLToPath } from 'url'
import { dirname, resolve } from 'path'
import { writeFileSync } from 'fs'
import mongoose from 'mongoose'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
dotenv.config({ path: resolve(__dirname, '../.env') })
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
// Legacy plan IDs to delete
const LEGACY_PLAN_IDS = ['20162', '21596', '21597', '21598']
// Legacy plan name pattern — catch any additional per-tier plans
const LEGACY_PLAN_NAME_RE = /^Ghost Guild\s*[-]\s*Member/i
const MONTHLY_PLAN = {
name: 'Monthly Membership',
recurringAmount: 15,
billingPeriod: 'monthly',
}
const ANNUAL_PLAN = {
name: 'Annual Membership',
recurringAmount: 150,
billingPeriod: 'yearly',
}
const HELCIM_PLAN_PAYLOAD_BASE = {
description: 'Ghost Guild membership',
type: 'subscription',
status: 'active',
currency: 'CAD',
setupAmount: 0,
billingPeriodIncrements: 1,
dateBilling: 'Sign-up',
termType: 'forever',
freeTrialPeriod: 0,
taxType: 'customer',
paymentMethod: 'card',
}
// ---------------------------------------------------------------------------
// Flag parsing
// ---------------------------------------------------------------------------
const args = process.argv.slice(2)
const CONFIRM = args.includes('--confirm')
const MONGO_ONLY = args.includes('--mongo-only')
// --mongo-only implies destructive Mongo ops even without --confirm
// --confirm enables all destructive ops (Helcim + Mongo)
// ---------------------------------------------------------------------------
// Helcim helpers
// ---------------------------------------------------------------------------
function helcimHeaders() {
const token = process.env.HELCIM_API_TOKEN
if (!token) throw new Error('HELCIM_API_TOKEN not set in .env')
return {
accept: 'application/json',
'content-type': 'application/json',
'api-token': token,
}
}
async function helcimGet(path) {
const res = await fetch(`${HELCIM_API_BASE}${path}`, {
headers: helcimHeaders(),
})
if (!res.ok) {
const body = await res.text()
throw new Error(`GET ${path} failed (${res.status}): ${body}`)
}
return res.json()
}
async function helcimDelete(path) {
const res = await fetch(`${HELCIM_API_BASE}${path}`, {
method: 'DELETE',
headers: helcimHeaders(),
})
if (!res.ok) {
const body = await res.text()
throw new Error(`DELETE ${path} failed (${res.status}): ${body}`)
}
return res
}
async function helcimPost(path, payload) {
const res = await fetch(`${HELCIM_API_BASE}${path}`, {
method: 'POST',
headers: helcimHeaders(),
body: JSON.stringify(payload),
})
const body = await res.text()
let parsed
try { parsed = JSON.parse(body) } catch { parsed = body }
return { ok: res.ok, status: res.status, body: parsed }
}
// ---------------------------------------------------------------------------
// Mongo helpers
// ---------------------------------------------------------------------------
async function connectMongo() {
const uri = process.env.MONGODB_URI
if (!uri) throw new Error('MONGODB_URI not set in .env')
await mongoose.connect(uri)
}
// ---------------------------------------------------------------------------
// Timestamp helper
// ---------------------------------------------------------------------------
function safeTimestamp() {
return new Date().toISOString().replace(/:/g, '-')
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log('=== Helcim Plan Consolidation' + (CONFIRM ? '' : ' (DRY RUN)') + ' ===')
console.log(`Mode: ${CONFIRM ? 'LIVE' : 'dry-run'}`)
console.log(`Flags: --confirm=${CONFIRM}, --mongo-only=${MONGO_ONLY}`)
console.log()
// -------------------------------------------------------------------------
// Step 1: Backup
// -------------------------------------------------------------------------
console.log('Step 1: Backup')
let subscriptions = []
let plans = []
let mongoMembers = []
if (!MONGO_ONLY) {
process.stdout.write(' Fetching Helcim subscriptions... ')
const subData = await helcimGet('/subscriptions')
subscriptions = Array.isArray(subData) ? subData : (subData.subscriptions ?? subData.data ?? [])
console.log(`Found ${subscriptions.length} subscriptions`)
process.stdout.write(' Fetching Helcim payment plans... ')
const planData = await helcimGet('/payment-plans')
plans = Array.isArray(planData) ? planData : (planData.plans ?? planData.data ?? [])
console.log(`Found ${plans.length} plans`)
} else {
console.log(' [--mongo-only] Skipping Helcim backup')
}
// Always connect Mongo for member backup
process.stdout.write(' Fetching Mongo members with helcimSubscriptionId... ')
await connectMongo()
const membersColl = mongoose.connection.collection('members')
const rawMembers = await membersColl
.find({ helcimSubscriptionId: { $exists: true } })
.project({ _id: 1, email: 1, status: 1, helcimSubscriptionId: 1, contributionTier: 1, billingCadence: 1 })
.toArray()
mongoMembers = rawMembers
console.log(`Found ${mongoMembers.length} members`)
const backupFilename = `.migration-backup-${safeTimestamp()}.json`
const backupData = {
timestamp: new Date().toISOString(),
flags: { confirm: CONFIRM, mongoOnly: MONGO_ONLY },
helcimSubscriptions: subscriptions,
helcimPlans: plans,
mongoMembers,
}
writeFileSync(backupFilename, JSON.stringify(backupData, null, 2))
console.log(` Backup written: ${backupFilename}`)
console.log()
// -------------------------------------------------------------------------
// Step 2: Delete subscriptions
// -------------------------------------------------------------------------
if (!MONGO_ONLY) {
console.log('Step 2: Delete subscriptions')
if (!CONFIRM) {
console.log(` [DRY RUN] Would delete ${subscriptions.length} subscriptions:`)
for (const sub of subscriptions) {
const memberEmail = sub.customer?.email ?? sub.email ?? sub.customerId ?? 'unknown'
console.log(` - ${sub.id} (member: ${memberEmail})`)
}
} else {
console.log(` Deleting ${subscriptions.length} subscriptions...`)
for (const sub of subscriptions) {
try {
await helcimDelete(`/subscriptions/${sub.id}`)
console.log(` [OK] Deleted subscription ${sub.id}`)
} catch (err) {
console.error(` [ERROR] Failed to delete subscription ${sub.id}: ${err.message}`)
}
}
}
console.log()
}
// -------------------------------------------------------------------------
// Step 3: Delete legacy plans
// -------------------------------------------------------------------------
if (!MONGO_ONLY) {
console.log('Step 3: Delete legacy plans')
// Detect any extra legacy plans by name pattern
const extraLegacyIds = plans
.filter((p) => {
const idStr = String(p.id)
return !LEGACY_PLAN_IDS.includes(idStr) && LEGACY_PLAN_NAME_RE.test(p.name)
})
.map((p) => String(p.id))
if (extraLegacyIds.length > 0) {
console.log(` Detected additional legacy plans by name pattern: ${extraLegacyIds.join(', ')}`)
}
const allLegacyIds = [...new Set([...LEGACY_PLAN_IDS, ...extraLegacyIds])]
if (!CONFIRM) {
console.log(` [DRY RUN] Would delete ${allLegacyIds.length} plans: ${allLegacyIds.join(', ')}`)
} else {
console.log(` Deleting ${allLegacyIds.length} plans: ${allLegacyIds.join(', ')}`)
for (const planId of allLegacyIds) {
try {
await helcimDelete(`/payment-plans/${planId}`)
console.log(` [OK] Deleted plan ${planId}`)
} catch (err) {
console.error(` [ERROR] Failed to delete plan ${planId}: ${err.message}`)
}
}
}
console.log()
}
// -------------------------------------------------------------------------
// Steps 4 & 5: Create new plans
// -------------------------------------------------------------------------
let monthlyPlanId = null
let annualPlanId = null
if (!MONGO_ONLY) {
// Idempotency: check if plans already exist in backup
const existingMonthly = plans.find((p) => p.name === MONTHLY_PLAN.name)
const existingAnnual = plans.find((p) => p.name === ANNUAL_PLAN.name)
// Step 4: Monthly Membership
console.log('Step 4: Create Monthly Membership')
const monthlyPayload = {
...HELCIM_PLAN_PAYLOAD_BASE,
name: MONTHLY_PLAN.name,
recurringAmount: MONTHLY_PLAN.recurringAmount,
billingPeriod: MONTHLY_PLAN.billingPeriod,
}
if (existingMonthly) {
monthlyPlanId = existingMonthly.id
console.log(` [SKIP] Plan "${MONTHLY_PLAN.name}" already exists with id ${monthlyPlanId}`)
} else if (!CONFIRM) {
console.log(` [DRY RUN] Would POST /payment-plans with:`)
console.log(' ', JSON.stringify(monthlyPayload, null, 4).split('\n').join('\n '))
} else {
console.log(` POSTing /payment-plans with:`)
console.log(' ', JSON.stringify(monthlyPayload, null, 4).split('\n').join('\n '))
const result = await helcimPost('/payment-plans', monthlyPayload)
if (!result.ok) {
console.error(` [ERROR] POST failed (${result.status}):`)
console.error(' ', JSON.stringify(result.body, null, 2))
console.error(' Aborting — fix the payload and re-run.')
await mongoose.disconnect()
process.exit(1)
}
monthlyPlanId = result.body.id
console.log(` [OK] Created Monthly Membership plan id: ${monthlyPlanId}`)
}
console.log()
// Step 5: Annual Membership
console.log('Step 5: Create Annual Membership')
const annualPayload = {
...HELCIM_PLAN_PAYLOAD_BASE,
name: ANNUAL_PLAN.name,
recurringAmount: ANNUAL_PLAN.recurringAmount,
billingPeriod: ANNUAL_PLAN.billingPeriod,
}
if (existingAnnual) {
annualPlanId = existingAnnual.id
console.log(` [SKIP] Plan "${ANNUAL_PLAN.name}" already exists with id ${annualPlanId}`)
} else if (!CONFIRM) {
console.log(` [DRY RUN] Would POST /payment-plans with:`)
console.log(' ', JSON.stringify(annualPayload, null, 4).split('\n').join('\n '))
console.log(' Note: billingPeriod="yearly" is Helcim v2 convention.')
console.log(' If 4xx on --confirm, check the Helcim API docs for the correct field value.')
} else {
console.log(` POSTing /payment-plans with:`)
console.log(' ', JSON.stringify(annualPayload, null, 4).split('\n').join('\n '))
const result = await helcimPost('/payment-plans', annualPayload)
if (!result.ok) {
console.error(` [ERROR] POST failed (${result.status}):`)
console.error(' ', JSON.stringify(result.body, null, 2))
console.error(' If billingPeriod is wrong, update ANNUAL_PLAN.billingPeriod and re-run.')
console.error(' Aborting.')
await mongoose.disconnect()
process.exit(1)
}
annualPlanId = result.body.id
console.log(` [OK] Created Annual Membership plan id: ${annualPlanId}`)
}
console.log()
}
// -------------------------------------------------------------------------
// Step 6: Mongo cleanup
// -------------------------------------------------------------------------
const runMongoDestructive = CONFIRM || MONGO_ONLY
console.log('Step 6: Mongo cleanup')
if (!runMongoDestructive) {
console.log(` [DRY RUN] Would updateMany on ${mongoMembers.length} members:`)
console.log(' { $set: { contributionTier: \'0\', billingCadence: \'monthly\' }, $unset: { helcimSubscriptionId: \'\' } }')
console.log(' (Status field is NOT changed — active stays active, pending_payment stays pending_payment)')
} else {
console.log(` Running updateMany on ${mongoMembers.length} members with helcimSubscriptionId...`)
const result = await membersColl.updateMany(
{ helcimSubscriptionId: { $exists: true } },
{
$set: { contributionTier: '0', billingCadence: 'monthly' },
$unset: { helcimSubscriptionId: '' },
},
{ runValidators: false },
)
console.log(` [OK] Modified ${result.modifiedCount} member(s)`)
}
console.log()
// -------------------------------------------------------------------------
// Summary
// -------------------------------------------------------------------------
if (!CONFIRM && !MONGO_ONLY) {
console.log('=== To execute, re-run with --confirm ===')
console.log('=== To run Mongo cleanup only, re-run with --mongo-only ===')
} else {
if (!MONGO_ONLY) {
console.log('=== New plan IDs — copy into .env ===')
console.log(`NUXT_HELCIM_MONTHLY_PLAN_ID=${monthlyPlanId ?? '<check backup>'}`)
console.log(`NUXT_HELCIM_ANNUAL_PLAN_ID=${annualPlanId ?? '<check backup>'}`)
}
console.log('=== Migration complete ===')
}
await mongoose.disconnect()
}
main().catch((err) => {
console.error('\n[FATAL]', err.message)
mongoose.disconnect().catch(() => {})
process.exit(1)
})