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.
388 lines
14 KiB
JavaScript
388 lines
14 KiB
JavaScript
/**
|
||
* 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)
|
||
})
|