151 lines
4.8 KiB
JavaScript
151 lines
4.8 KiB
JavaScript
/**
|
|
* Reconciliation cron route — invoked by `netlify/functions/reconcile-payments.mjs`.
|
|
*
|
|
* Auth: shared-secret header `X-Reconcile-Token` matched against
|
|
* `runtimeConfig.reconcileToken` (env: NUXT_RECONCILE_TOKEN). Machine-to-machine
|
|
* only — never passes `sendConfirmation: true` so back-fills don't re-send
|
|
* confirmation emails.
|
|
*/
|
|
import Member from '../../models/member.js'
|
|
import Payment from '../../models/payment.js'
|
|
import { getHelcimCustomer, listHelcimCustomerTransactions } from '../../utils/helcim.js'
|
|
import { connectDB } from '../../utils/mongoose.js'
|
|
import { upsertPaymentFromHelcim } from '../../utils/payments.js'
|
|
|
|
// Same filter upsertPaymentFromHelcim applies — keep dry-run summary in sync.
|
|
const RECONCILABLE_STATUSES = new Set(['paid', 'refunded', 'failed'])
|
|
|
|
const RETRY_ATTEMPTS = 3
|
|
const BASE_DELAY_MS = 250
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|
|
|
|
async function listTransactionsWithRetry(customerCode) {
|
|
let lastErr
|
|
for (let attempt = 1; attempt <= RETRY_ATTEMPTS; attempt++) {
|
|
try {
|
|
return await listHelcimCustomerTransactions(customerCode)
|
|
} catch (err) {
|
|
// Permanent failures (auth, missing) — don't burn retries on broken config.
|
|
if (err?.statusCode === 401 || err?.statusCode === 403 || err?.statusCode === 404) {
|
|
throw err
|
|
}
|
|
lastErr = err
|
|
// Backoff after attempts 1 and 2: 250ms, then 500ms (no sleep after attempt 3).
|
|
if (attempt < RETRY_ATTEMPTS) {
|
|
await sleep(BASE_DELAY_MS * 2 ** (attempt - 1))
|
|
}
|
|
}
|
|
}
|
|
throw lastErr
|
|
}
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const config = useRuntimeConfig()
|
|
const expected = config.reconcileToken
|
|
const provided = getHeader(event, 'x-reconcile-token')
|
|
if (!expected || provided !== expected) {
|
|
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
|
|
}
|
|
|
|
const apply = getQuery(event).apply !== 'false'
|
|
|
|
await connectDB()
|
|
|
|
const members = await Member.find(
|
|
{ helcimCustomerId: { $exists: true, $ne: null } },
|
|
{ _id: 1, email: 1, name: 1, helcimCustomerId: 1, helcimCustomerCode: 1, helcimSubscriptionId: 1, billingCadence: 1 }
|
|
).lean()
|
|
|
|
let txExamined = 0
|
|
let created = 0
|
|
let existed = 0
|
|
let skipped = 0
|
|
let memberErrors = 0
|
|
|
|
async function processMember(member) {
|
|
// Opportunistic backfill: members predating the helcimCustomerCode field
|
|
// get it filled in here so the daily cron acts as the migration. Only on
|
|
// the missing path — no overwrite, no extra API call once populated.
|
|
if (!member.helcimCustomerCode) {
|
|
try {
|
|
const customer = await getHelcimCustomer(member.helcimCustomerId)
|
|
if (customer?.customerCode) {
|
|
await Member.findByIdAndUpdate(
|
|
member._id,
|
|
{ $set: { helcimCustomerCode: customer.customerCode } },
|
|
{ runValidators: false }
|
|
)
|
|
}
|
|
} catch (err) {
|
|
// Backfill is best-effort — never fail the reconcile run on it.
|
|
console.warn(`[reconcile] customerCode backfill failed for member=${member._id}: ${err?.message || err}`)
|
|
}
|
|
}
|
|
|
|
let txs
|
|
try {
|
|
txs = await listTransactionsWithRetry(member.helcimCustomerId)
|
|
} catch (err) {
|
|
console.error(`[reconcile] member=${member._id}: ${err?.message || err}`)
|
|
return { error: true }
|
|
}
|
|
|
|
const result = { error: false, txExamined: 0, created: 0, existed: 0, skipped: 0 }
|
|
|
|
for (const tx of txs) {
|
|
result.txExamined++
|
|
if (!RECONCILABLE_STATUSES.has(tx?.status)) {
|
|
result.skipped++
|
|
continue
|
|
}
|
|
|
|
if (!apply) {
|
|
const existing = await Payment.findOne({ helcimTransactionId: tx.id })
|
|
if (existing) result.existed++
|
|
else result.created++
|
|
continue
|
|
}
|
|
|
|
// Note: deliberately NOT passing sendConfirmation — cron back-fills must
|
|
// not re-send confirmation emails for transactions the member has already
|
|
// been notified about (or that pre-date Mongo Payment tracking entirely).
|
|
const upsertResult = await upsertPaymentFromHelcim(member, tx)
|
|
if (upsertResult.created) result.created++
|
|
else if (upsertResult.payment) result.existed++
|
|
else result.skipped++
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
const CHUNK_SIZE = 8
|
|
for (let i = 0; i < members.length; i += CHUNK_SIZE) {
|
|
const chunk = members.slice(i, i + CHUNK_SIZE)
|
|
const results = await Promise.all(chunk.map((m) => processMember(m)))
|
|
for (const r of results) {
|
|
if (r.error) {
|
|
memberErrors++
|
|
continue
|
|
}
|
|
txExamined += r.txExamined
|
|
created += r.created
|
|
existed += r.existed
|
|
skipped += r.skipped
|
|
}
|
|
}
|
|
|
|
const summary = {
|
|
membersScanned: members.length,
|
|
txExamined,
|
|
created,
|
|
existed,
|
|
skipped,
|
|
memberErrors,
|
|
apply
|
|
}
|
|
console.log('[reconcile] done', summary)
|
|
return summary
|
|
})
|