Add membership renewals and door unlock

This commit is contained in:
Dorian
2026-05-17 19:17:49 -05:00
parent a28a5756c5
commit e022ac1faa
9 changed files with 410 additions and 79 deletions

View File

@@ -61,6 +61,7 @@ const files = {
siteContent: path.join(dataDir, 'site-content.json'),
adminRequests: path.join(dataDir, 'admin-requests.json'),
notificationSubscriptions: path.join(dataDir, 'notification-subscriptions.json'),
membershipNotifications: path.join(dataDir, 'membership-notifications.json'),
}
const state = {
@@ -71,6 +72,7 @@ const state = {
siteContent: null,
adminRequests: [],
notificationSubscriptions: [],
membershipNotifications: [],
}
let bitcoinPriceCache = null
const adminEventClients = new Set()
@@ -140,6 +142,7 @@ const loadStore = async () => {
state.siteContent = normalizeSiteContent(await loadJson(files.siteContent).catch(() => defaultSiteContent))
state.adminRequests = await loadJson(files.adminRequests)
state.notificationSubscriptions = await loadJson(files.notificationSubscriptions)
state.membershipNotifications = await loadJson(files.membershipNotifications)
}
const saveMemberships = () => saveJson(files.memberships, state.memberships)
@@ -149,6 +152,7 @@ const saveAccessLogs = () => saveJson(files.accessLogs, state.accessLogs)
const saveSiteContent = () => saveJson(files.siteContent, state.siteContent)
const saveAdminRequests = () => saveJson(files.adminRequests, state.adminRequests)
const saveNotificationSubscriptions = () => saveJson(files.notificationSubscriptions, state.notificationSubscriptions)
const saveMembershipNotifications = () => saveJson(files.membershipNotifications, state.membershipNotifications)
const readBody = async (req) => {
const raw = await readRawBody(req)
@@ -289,8 +293,34 @@ const decryptMember = (member) => decryptMembership(member)
const encryptedMembers = () => state.memberships
const members = () => state.memberships.map(decryptMember)
const findMember = (predicate) => members().find(predicate)
const activePaymentFor = (membershipId) =>
state.payments.find((payment) => payment.membershipId === membershipId && payment.status === 'paid')
const membershipPeriodOptions = [1, 2, 3, 6, 12]
const dayMs = 24 * 60 * 60 * 1000
const normalizePaymentMonths = (value) => {
const months = Number.parseInt(value, 10)
if (!Number.isFinite(months)) return 1
return Math.max(1, Math.min(24, months))
}
const addMembershipMonths = (date, months) => {
const start = new Date(date)
const day = start.getDate()
const result = new Date(start)
result.setDate(1)
result.setMonth(result.getMonth() + normalizePaymentMonths(months))
const lastDay = new Date(result.getFullYear(), result.getMonth() + 1, 0).getDate()
result.setDate(Math.min(day, lastDay))
return result
}
const activePaymentFor = (membershipId) => {
const now = new Date()
return state.payments.find((payment) =>
payment.membershipId === membershipId &&
payment.status === 'paid' &&
(!payment.paidThrough || new Date(payment.paidThrough) > now)
)
}
const activeCardFor = (membershipId) =>
state.cards.find((card) => card.membershipId === membershipId && card.status === 'active')
@@ -303,6 +333,16 @@ const memberAccessStatus = (member) => {
return member.status || 'requested'
}
const updateMemberRecord = async (membershipId, updater) => {
const index = members().findIndex((member) => member.membershipId === membershipId)
if (index < 0) return null
const current = decryptMember(state.memberships[index])
const updated = updater({ ...current }) || current
state.memberships[index] = encryptMembership(updated)
await saveMemberships()
return updated
}
const validateMember = (data) => {
const signerNpubs = Array.isArray(data.signerNpubs)
? data.signerNpubs.map((item) => cleanText(item, 80)).filter(Boolean)
@@ -339,7 +379,7 @@ const validateMember = (data) => {
if (!member.expiresAt) {
const expiresAt = new Date(member.createdAt)
expiresAt.setFullYear(expiresAt.getFullYear() + 1)
expiresAt.setMonth(expiresAt.getMonth() + 1)
member.expiresAt = expiresAt.toISOString()
}
@@ -438,6 +478,111 @@ const sendPushNotification = async ({ title, message, url = '/edit', icon = '/im
return { success: true, sent, failed }
}
const sendPushNotificationToMember = async (member, payload) => {
const targets = state.notificationSubscriptions.filter((subscription) =>
subscription.membershipId === member.membershipId ||
subscription.npub === member.npub ||
member.signerNpubs?.includes(subscription.npub)
)
if (!targets.length) return { success: false, reason: 'no_member_subscriptions', sent: 0, failed: 0 }
if (!isValidVapidPublicKey(vapidPublicKey) || !vapidPrivateKey) {
return { success: false, reason: 'vapid_not_configured', sent: 0, failed: 0 }
}
const body = JSON.stringify({
title: cleanText(payload.title, 80) || 'L484',
message: cleanText(payload.message, 220) || 'New L484 update.',
icon: cleanText(payload.icon, 240) || '/images/small-logo.svg',
tag: cleanText(payload.tag, 80) || 'l484-update',
data: { url: cleanText(payload.url, 240) || '/' },
})
const targetEndpoints = new Set(targets.map((subscription) => subscription.endpoint))
const results = await Promise.allSettled(targets.map((subscription) => webpush.sendNotification(subscription, body)))
const invalidEndpoints = new Set()
let sent = 0
let failed = 0
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
sent += 1
return
}
failed += 1
if ([404, 410].includes(result.reason?.statusCode)) invalidEndpoints.add(targets[index].endpoint)
})
if (invalidEndpoints.size) {
state.notificationSubscriptions = state.notificationSubscriptions.filter((subscription) =>
!targetEndpoints.has(subscription.endpoint) || !invalidEndpoints.has(subscription.endpoint)
)
await saveNotificationSubscriptions()
}
return { success: true, sent, failed }
}
const applyPaidMembershipPeriod = async (payment) => {
const member = findMember((item) => item.membershipId === payment.membershipId)
if (!member) return null
const now = new Date()
const existingExpiry = new Date(member.expiresAt)
const coversFrom = Number.isNaN(existingExpiry.getTime()) || existingExpiry < now ? now : existingExpiry
const months = normalizePaymentMonths(payment.months)
const paidThrough = addMembershipMonths(coversFrom, months)
payment.months = months
payment.periodLabel = months === 1 ? '1 month' : `${months} months`
payment.amountUsd = membershipMonthlyUsd * months
payment.coversFrom = coversFrom.toISOString()
payment.paidThrough = paidThrough.toISOString()
const updated = await updateMemberRecord(payment.membershipId, (current) => ({
...current,
expiresAt: paidThrough.toISOString(),
status: activeCardFor(payment.membershipId) ? 'active' : 'pending_payment',
}))
await savePayments()
return updated
}
const reconcileMembershipCycles = async ({ notify = false } = {}) => {
const now = new Date()
const allMembers = members()
let changed = false
for (const member of allMembers) {
const expiry = new Date(member.expiresAt)
if (Number.isNaN(expiry.getTime())) continue
if (['active', 'pending_card', 'pending_payment'].includes(member.status) && expiry <= now) {
await updateMemberRecord(member.membershipId, (current) => ({ ...current, status: 'suspended' }))
changed = true
continue
}
if (!notify || !['active', 'pending_card', 'pending_payment'].includes(member.status)) continue
const daysUntilExpiry = Math.ceil((expiry.getTime() - now.getTime()) / dayMs)
if (daysUntilExpiry < 1 || daysUntilExpiry > 3) continue
const dayKey = now.toISOString().slice(0, 10)
const alreadySent = state.membershipNotifications.some((entry) =>
entry.membershipId === member.membershipId &&
entry.type === 'expiry_reminder' &&
entry.dayKey === dayKey
)
if (alreadySent) continue
const result = await sendPushNotificationToMember(member, {
title: 'Membership renewal due',
message: `Your L484 membership expires in ${daysUntilExpiry} day${daysUntilExpiry === 1 ? '' : 's'}.`,
url: '/',
tag: `l484-renewal-${member.membershipId}-${dayKey}`,
})
state.membershipNotifications.unshift({
id: createId('membership-notice'),
membershipId: member.membershipId,
type: 'expiry_reminder',
dayKey,
expiresAt: expiry.toISOString(),
sent: result.sent || 0,
failed: result.failed || 0,
createdAt: now.toISOString(),
})
state.membershipNotifications = state.membershipNotifications.slice(0, 2000)
await saveMembershipNotifications()
}
return { changed }
}
const broadcastAdminEvent = (event, payload = {}) => {
const message = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`
for (const client of adminEventClients) {
@@ -543,12 +688,7 @@ const upsertMember = async (member) => {
}
const updateMemberStatus = async (membershipId, status) => {
const index = members().findIndex((member) => member.membershipId === membershipId)
if (index < 0) return null
const member = { ...decryptMember(state.memberships[index]), status }
state.memberships[index] = encryptMembership(member)
await saveMemberships()
return member
return updateMemberRecord(membershipId, (member) => ({ ...member, status }))
}
const serializeAdmin = (pubkey = '') => {
@@ -560,6 +700,8 @@ const serializeAdmin = (pubkey = '') => {
},
memberships: allMembers.map((member) => ({ ...member, accessStatus: memberAccessStatus(member) })),
payments: state.payments,
membershipMonthlyUsd,
membershipPeriodOptions,
cards: state.cards.map(({ cardSecretHash, uidHash, ...card }) => card),
accessLogs: state.accessLogs.slice(0, 200),
}
@@ -688,6 +830,8 @@ const seedDevelopmentStore = async () => {
id: `payment-dev-paid-${member.membershipId.slice(-6).toLowerCase()}`,
membershipId: member.membershipId,
provider: index % 2 ? 'btcpay' : 'cash',
months: 1,
periodLabel: '1 month',
amountUsd: membershipMonthlyUsd,
amountSats: index % 2 ? 420000 : 0,
status: 'paid',
@@ -695,11 +839,15 @@ const seedDevelopmentStore = async () => {
checkoutLink: index % 2 ? `${btcpayServerUrl || 'https://shop.tx1138.com'}/i/dev-paid-${index + 1}` : '',
createdAt: member.createdAt,
paidAt: member.createdAt,
coversFrom: member.createdAt,
paidThrough: member.expiresAt,
})),
...pendingMembers.map((member, index) => ({
id: `payment-dev-pending-${member.membershipId.slice(-6).toLowerCase()}`,
membershipId: member.membershipId,
provider: 'btcpay',
months: 1,
periodLabel: '1 month',
amountUsd: membershipMonthlyUsd,
amountSats: 420000,
status: 'pending',
@@ -812,8 +960,10 @@ const fetchBtcpayPaymentMethods = async (invoiceId) => {
return Array.isArray(data) ? data : []
}
const createBtcpayInvoice = async (member) => {
const createBtcpayInvoice = async (member, months = 1) => {
if (!btcpayConfigured()) throw new Error('BTCPay credentials are not configured.')
const normalizedMonths = normalizePaymentMonths(months)
const amountUsd = membershipMonthlyUsd * normalizedMonths
const response = await fetch(`${btcpayServerUrl}/api/v1/stores/${btcpayStoreId}/invoices`, {
method: 'POST',
headers: {
@@ -821,11 +971,12 @@ const createBtcpayInvoice = async (member) => {
Authorization: `token ${btcpayApiKey}`,
},
body: JSON.stringify({
amount: membershipMonthlyUsd,
amount: amountUsd,
currency: 'USD',
metadata: {
orderId: member.membershipId,
itemDesc: 'L484 monthly membership',
membershipMonths: normalizedMonths,
itemDesc: normalizedMonths === 1 ? 'L484 monthly membership' : `L484 ${normalizedMonths} month membership`,
},
}),
})
@@ -859,15 +1010,16 @@ const verifyBtcpayWebhook = (req, rawBody) => {
const markInvoicePaid = async (invoiceId) => {
const payment = state.payments.find((item) => item.btcpayInvoiceId === invoiceId)
if (!payment) return null
if (payment.status === 'paid') return payment
payment.status = 'paid'
payment.paidAt = new Date().toISOString()
await savePayments()
await updateMemberStatus(payment.membershipId, activeCardFor(payment.membershipId) ? 'active' : 'pending_payment')
await applyPaidMembershipPeriod(payment)
broadcastAdminEvent('payment-paid', {
invoiceId,
membershipId: payment.membershipId,
paymentId: payment.id,
paidAt: payment.paidAt,
paidThrough: payment.paidThrough,
})
return payment
}
@@ -895,6 +1047,8 @@ const paymentStatusPayload = async (payment) => {
invoice: {
id: payment.btcpayInvoiceId || payment.id,
amount: payment.amountUsd,
months: normalizePaymentMonths(payment.months),
periodLabel: payment.periodLabel || (normalizePaymentMonths(payment.months) === 1 ? '1 month' : `${normalizePaymentMonths(payment.months)} months`),
currency: 'USD',
status: invoice?.status || (payment.status === 'paid' ? 'Settled' : 'New'),
paymentUrl: checkoutLink,
@@ -905,6 +1059,7 @@ const paymentStatusPayload = async (payment) => {
lightningPaymentLink: lightning?.paymentLink || '',
bitcoinPaymentLink: bitcoin?.paymentLink || '',
createdAt: payment.createdAt,
paidThrough: payment.paidThrough || '',
expiresAt: invoice?.expirationTime ? new Date(Number(invoice.expirationTime) * 1000).toISOString() : '',
},
}
@@ -958,6 +1113,8 @@ const handleApi = async (req, res) => {
if (!endpoint || !subscription?.keys?.p256dh || !subscription?.keys?.auth) {
return json(res, 400, { error: 'Invalid push subscription.' })
}
const existingIndex = state.notificationSubscriptions.findIndex((item) => item.endpoint === endpoint)
const existingSubscription = existingIndex >= 0 ? state.notificationSubscriptions[existingIndex] : null
const normalized = {
endpoint,
expirationTime: subscription.expirationTime || null,
@@ -965,10 +1122,12 @@ const handleApi = async (req, res) => {
p256dh: cleanText(subscription.keys.p256dh, 500),
auth: cleanText(subscription.keys.auth, 200),
},
membershipId: cleanText(body.membershipId || existingSubscription?.membershipId, 32).toUpperCase(),
npub: cleanText(body.npub || existingSubscription?.npub, 90),
userAgent: cleanText(req.headers['user-agent'], 240),
createdAt: new Date().toISOString(),
createdAt: existingSubscription?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
const existingIndex = state.notificationSubscriptions.findIndex((item) => item.endpoint === endpoint)
if (existingIndex >= 0) state.notificationSubscriptions[existingIndex] = normalized
else state.notificationSubscriptions.unshift(normalized)
await saveNotificationSubscriptions()
@@ -1040,6 +1199,36 @@ const handleApi = async (req, res) => {
return json(res, found ? 200 : 404, found ? { success: true, membership: found } : { error: 'No membership found for that nsec.' })
}
if (req.method === 'POST' && url.pathname === '/api/member/door/unlock') {
if (!publicApiEnabled()) return json(res, 404, { error: 'Public membership access is disabled on this deployment.' })
const body = await readBody(req)
const membershipId = cleanText(body.membershipId, 32).toUpperCase()
const nsecHash = cleanText(body.nsecHash, 64).toLowerCase()
const doorId = cleanText(body.doorId, 80) || 'front-door'
const member = findMember((item) => item.membershipId === membershipId && item.nsecHash === nsecHash)
const card = activeCardFor(membershipId)
const status = memberAccessStatus(member)
const allow = Boolean(member && card && status === 'active')
const unlock = allow
? await triggerDoorUnlock({ doorId, member, card })
: { attempted: false, ok: false, reason: member ? `member_${status}` : 'member_not_found' }
await recordAccess({
membershipId: member?.membershipId || membershipId,
cardId: card?.id || '',
cardPublicId: card?.cardPublicId || '',
doorId,
decision: allow && unlock.ok ? 'allow' : 'deny',
reason: allow ? unlock.reason : unlock.reason,
})
return json(res, allow && unlock.ok ? 200 : 403, {
success: allow && unlock.ok,
allow,
reason: allow ? unlock.reason : unlock.reason,
unlock,
member: member ? { membershipId: member.membershipId, fullName: member.fullName, status } : null,
})
}
if (req.method === 'GET' && url.pathname === '/api/admin/state') {
if (!requireAdmin(req, res)) return
return json(res, 200, serializeAdmin(getAuthPubkey(req)))
@@ -1143,11 +1332,14 @@ const handleApi = async (req, res) => {
const membershipId = cleanText(body.membershipId, 32).toUpperCase()
const member = findMember((item) => item.membershipId === membershipId)
if (!member) return json(res, 404, { error: 'Member not found.' })
const months = normalizePaymentMonths(body.months)
const payment = {
id: createId('payment'),
membershipId,
provider: cleanText(body.provider, 24) || 'manual',
amountUsd: Number(body.amountUsd || membershipMonthlyUsd),
months,
periodLabel: months === 1 ? '1 month' : `${months} months`,
amountUsd: membershipMonthlyUsd * months,
amountSats: Number(body.amountSats || 0),
status: 'paid',
btcpayInvoiceId: cleanText(body.btcpayInvoiceId, 120),
@@ -1155,8 +1347,7 @@ const handleApi = async (req, res) => {
paidAt: new Date().toISOString(),
}
state.payments.unshift(payment)
await savePayments()
await updateMemberStatus(membershipId, activeCardFor(membershipId) ? 'active' : 'pending_payment')
await applyPaidMembershipPeriod(payment)
return json(res, 201, { success: true, payment })
}
@@ -1167,12 +1358,15 @@ const handleApi = async (req, res) => {
const membershipId = cleanText(body.membershipId, 32).toUpperCase()
const member = findMember((item) => item.membershipId === membershipId)
if (!member) return json(res, 404, { error: 'Member not found.' })
const invoice = await createBtcpayInvoice(member)
const months = normalizePaymentMonths(body.months)
const invoice = await createBtcpayInvoice(member, months)
const payment = {
id: createId('payment'),
membershipId,
provider: 'btcpay',
amountUsd: membershipMonthlyUsd,
months,
periodLabel: months === 1 ? '1 month' : `${months} months`,
amountUsd: membershipMonthlyUsd * months,
amountSats: 0,
status: 'pending',
btcpayInvoiceId: cleanText(invoice.id, 120),
@@ -1182,7 +1376,7 @@ const handleApi = async (req, res) => {
}
state.payments.unshift(payment)
await savePayments()
await updateMemberStatus(membershipId, 'pending_payment')
if (!['active', 'pending_card'].includes(memberAccessStatus(member))) await updateMemberStatus(membershipId, 'pending_payment')
return json(res, 201, await paymentStatusPayload(payment))
}
@@ -1275,6 +1469,10 @@ const handleApi = async (req, res) => {
await loadStore()
await seedDevelopmentStore()
await reconcileMembershipCycles({ notify: true })
setInterval(() => {
reconcileMembershipCycles({ notify: true }).catch((error) => console.warn('Membership cycle reconciliation failed:', error.message))
}, 60 * 60 * 1000)
http.createServer(async (req, res) => {
try {