ghostguild-org/scripts/helcim-plan-consolidation.js
Jennie Robinson Faber 549a849bc0 fix(scripts): helcim plan-create payload shape + empty-GET handling
Verified against the live Helcim v2 API during the deploy migration:
- POST /payment-plans requires { paymentPlans: [plan] } wrapper (mirrors
  the POST /subscriptions shape), and response is { data: [plan] }.
- taxType 'customer' rejects as ERR_VALIDATION_FAILED; must be 'no_tax'
  with taxCalculation 'country_province'.
- termLength:1 rejects when termType:'forever' — drop the field.
- GET /subscriptions returns an empty body (not JSON) when no subs exist;
  tolerate that instead of failing with 'Unexpected end of JSON input'.

Plans created in the Helcim account: Monthly=50302, Annual=50303.
2026-04-18 20:58:17 +01:00

388 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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',
}
// Shape mirrors the pre-migration legacy plans (verified via backup dump).
// taxType:'customer' + missing taxCalculation/termLength caused ERR_VALIDATION_FAILED.
const HELCIM_PLAN_PAYLOAD_BASE = {
businessName: '',
businessEmail: '',
description: '',
type: 'subscription',
status: 'active',
currency: 'CAD',
setupAmount: 0,
billingPeriodIncrements: 1,
dateBilling: 'Sign-up',
termType: 'forever',
freeTrialPeriod: 0,
taxType: 'no_tax',
taxCalculation: 'country_province',
paymentMethod: 'card',
isProrated: 'no',
}
// ---------------------------------------------------------------------------
// 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(),
})
const body = await res.text()
if (!res.ok) {
throw new Error(`GET ${path} failed (${res.status}): ${body}`)
}
if (!body.trim()) return []
try { return JSON.parse(body) } catch { return [] }
}
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 {
const wrapped = { paymentPlans: [monthlyPayload] }
console.log(` POSTing /payment-plans with:`)
console.log(' ', JSON.stringify(wrapped, null, 4).split('\n').join('\n '))
const result = await helcimPost('/payment-plans', wrapped)
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.data?.[0]?.id ?? result.body.paymentPlans?.[0]?.id ?? 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 {
const wrapped = { paymentPlans: [annualPayload] }
console.log(` POSTing /payment-plans with:`)
console.log(' ', JSON.stringify(wrapped, null, 4).split('\n').join('\n '))
const result = await helcimPost('/payment-plans', wrapped)
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.data?.[0]?.id ?? result.body.paymentPlans?.[0]?.id ?? 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)
})