Add comp memberships and harden cash payments

This commit is contained in:
Dorian
2026-05-19 12:05:15 -05:00
parent 51ec0a10e6
commit 5c4ce583c5
4 changed files with 77 additions and 25 deletions

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = 'l484-pwa-v12'
const CACHE_NAME = 'l484-pwa-v13'
const APP_SHELL = [
'/',
'/manifest.webmanifest',
@@ -6,15 +6,17 @@ const APP_SHELL = [
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)))
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))),
),
(async () => {
const keys = await caches.keys()
await Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))
await self.clients.claim()
})(),
)
self.clients.claim()
})
self.addEventListener('fetch', (event) => {
@@ -30,11 +32,13 @@ self.addEventListener('fetch', (event) => {
caches.open(CACHE_NAME).then((cache) => cache.put('/', clone))
return response
})
.catch(() => caches.match('/') || caches.match(event.request)),
.catch(async () => (await caches.match('/')) || (await caches.match(event.request)) || Response.error()),
)
return
}
if (event.request.method !== 'GET') return
event.respondWith(
caches.match(event.request).then((cached) =>
cached || fetch(event.request).then((response) => {
@@ -43,7 +47,7 @@ self.addEventListener('fetch', (event) => {
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))
}
return response
}),
}).catch(() => Response.error()),
),
)
})

View File

@@ -534,7 +534,7 @@ const applyPaidMembershipPeriod = async (payment) => {
const paidThrough = addMembershipMonths(coversFrom, months)
payment.months = months
payment.periodLabel = months === 1 ? '1 month' : `${months} months`
payment.amountUsd = membershipMonthlyUsd * months
payment.amountUsd = payment.provider === 'comp' ? 0 : membershipMonthlyUsd * months
payment.coversFrom = coversFrom.toISOString()
payment.paidThrough = paidThrough.toISOString()
const updated = await updateMemberRecord(payment.membershipId, (current) => ({
@@ -698,6 +698,8 @@ const updateMemberStatus = async (membershipId, status) => {
return updateMemberRecord(membershipId, (member) => ({ ...member, status }))
}
const serializeAdminMember = (member) => ({ ...member, accessStatus: memberAccessStatus(member) })
const serializeAdmin = (pubkey = '') => {
const allMembers = members()
return {
@@ -705,7 +707,7 @@ const serializeAdmin = (pubkey = '') => {
admin: {
isMaster: isMasterAdminPubkey(pubkey),
},
memberships: allMembers.map((member) => ({ ...member, accessStatus: memberAccessStatus(member) })),
memberships: allMembers.map(serializeAdminMember),
payments: state.payments,
membershipMonthlyUsd,
membershipPeriodOptions,
@@ -1356,13 +1358,14 @@ const handleApi = async (req, res) => {
const member = findMember((item) => item.membershipId === membershipId)
if (!member) return json(res, 404, { error: 'Member not found.' })
const months = normalizePaymentMonths(body.months)
const provider = cleanText(body.provider, 24) === 'comp' ? 'comp' : 'manual'
const payment = {
id: createId('payment'),
membershipId,
provider: cleanText(body.provider, 24) || 'manual',
provider,
months,
periodLabel: months === 1 ? '1 month' : `${months} months`,
amountUsd: membershipMonthlyUsd * months,
amountUsd: provider === 'comp' ? 0 : membershipMonthlyUsd * months,
amountSats: Number(body.amountSats || 0),
status: 'paid',
btcpayInvoiceId: cleanText(body.btcpayInvoiceId, 120),
@@ -1370,8 +1373,12 @@ const handleApi = async (req, res) => {
paidAt: new Date().toISOString(),
}
state.payments.unshift(payment)
await applyPaidMembershipPeriod(payment)
return json(res, 201, { success: true, payment })
const membership = await applyPaidMembershipPeriod(payment)
return json(res, 201, {
success: true,
payment,
membership: membership ? serializeAdminMember(membership) : null,
})
}
if (req.method === 'POST' && url.pathname === '/api/payment/btcpay') {

View File

@@ -315,14 +315,16 @@ const pendingPayments = computed(() => payments.value.filter((payment) => paymen
const paymentTotals = computed(() => {
const paid = paidPayments.value.reduce((total, payment) => total + Number(payment.amountUsd || 0), 0)
const pending = pendingPayments.value.reduce((total, payment) => total + Number(payment.amountUsd || 0), 0)
const cash = paidPayments.value.filter((payment) => payment.provider !== 'btcpay').reduce((total, payment) => total + Number(payment.amountUsd || 0), 0)
const cash = paidPayments.value.filter((payment) => ['manual', 'cash'].includes(payment.provider)).reduce((total, payment) => total + Number(payment.amountUsd || 0), 0)
const bitcoin = paidPayments.value.filter((payment) => payment.provider === 'btcpay').reduce((total, payment) => total + Number(payment.amountUsd || 0), 0)
return { paid, pending, cash, bitcoin }
const comp = paidPayments.value.filter((payment) => payment.provider === 'comp').reduce((total, payment) => total + Number(payment.amountUsd || 0), 0)
return { paid, pending, cash, bitcoin, comp }
})
const paymentMethodRows = computed(() => {
const rows = [
{ label: 'Cash', value: paymentTotals.value.cash, count: paidPayments.value.filter((payment) => payment.provider !== 'btcpay').length },
{ label: 'Cash', value: paymentTotals.value.cash, count: paidPayments.value.filter((payment) => ['manual', 'cash'].includes(payment.provider)).length },
{ label: 'Bitcoin', value: paymentTotals.value.bitcoin, count: paidPayments.value.filter((payment) => payment.provider === 'btcpay').length },
{ label: 'Comp', value: paymentTotals.value.comp, count: paidPayments.value.filter((payment) => payment.provider === 'comp').length },
]
const max = Math.max(...rows.map((row) => row.value), 1)
return rows.map((row) => ({ ...row, percentage: Math.max(4, Math.round((row.value / max) * 100)) }))
@@ -1503,19 +1505,37 @@ const submitEventEnquiry = () => {
eventEnquiry.message = ''
}
const markManualPayment = async (membershipId, months = 1) => {
const paymentProviderLabel = (provider) => {
if (provider === 'btcpay') return 'Bitcoin'
if (provider === 'comp') return 'Comp'
return 'Cash'
}
const markManualPayment = async (membershipId, months = 1, provider = 'manual') => {
adminActionError.value = ''
adminActionMessage.value = ''
const isComp = provider === 'comp'
try {
await fetchJson('/api/payment/manual', {
const result = await fetchJson('/api/payment/manual', {
method: 'POST',
headers: adminHeaders(),
body: JSON.stringify({ membershipId, provider: 'manual', months }),
body: JSON.stringify({ membershipId, provider, months }),
})
if (result.payment) {
payments.value = [result.payment, ...payments.value.filter((payment) => payment.id !== result.payment.id)]
}
if (result.membership) {
members.value = members.value.map((member) =>
member.membershipId === result.membership.membershipId ? normalizeMember(result.membership) : member,
)
saveMembers()
}
adminActionMessage.value = isComp ? 'Comp membership recorded.' : 'Cash payment recorded.'
await refreshAdminState().catch((error) => {
console.warn(`Could not refresh admin state after ${isComp ? 'comp' : 'cash'} payment:`, error)
})
adminActionMessage.value = 'Cash payment recorded.'
await refreshAdminState()
} catch (error) {
adminActionError.value = error instanceof Error ? error.message : 'Could not record cash payment.'
adminActionError.value = error instanceof Error ? error.message : `Could not record ${isComp ? 'comp membership' : 'cash payment'}.`
}
}
@@ -1628,6 +1648,17 @@ const takeCashPayment = async () => {
if (!adminActionError.value) closePayment()
}
const compMembership = async () => {
if (!selectedPaymentMember.value) return
isPaymentModalLoading.value = true
paymentModalLoadingMethod.value = 'comp'
paymentModalError.value = ''
await markManualPayment(selectedPaymentMember.value.membershipId, paymentMonths.value, 'comp')
isPaymentModalLoading.value = false
paymentModalLoadingMethod.value = ''
if (!adminActionError.value) closePayment()
}
const createBitcoinPayment = async () => {
if (!selectedPaymentMember.value) return
isPaymentModalLoading.value = true
@@ -3235,7 +3266,7 @@ watch(mobileMenuOpen, (open) => {
<section class="payment-chart-panel">
<div class="payment-panel-heading">
<p class="section-kicker">Method Split</p>
<h3>Cash / Bitcoin</h3>
<h3>Payment methods</h3>
</div>
<div class="payment-method-list">
<article v-for="method in paymentMethodRows" :key="method.label">
@@ -3259,9 +3290,9 @@ watch(mobileMenuOpen, (open) => {
</div>
<div v-if="payments.length" class="payment-table">
<article v-for="payment in payments.slice(0, 12)" :key="payment.id">
<span>{{ payment.provider === 'btcpay' ? 'Bitcoin' : 'Cash' }}</span>
<span>{{ paymentProviderLabel(payment.provider) }}</span>
<p>{{ payment.membershipId }}</p>
<strong>{{ formatUsd(payment.amountUsd || MEMBERSHIP_MONTHLY_USD) }}</strong>
<strong>{{ formatUsd(payment.amountUsd ?? MEMBERSHIP_MONTHLY_USD) }}</strong>
<small>{{ payment.status }} · {{ payment.periodLabel || `${payment.months || 1} month` }}</small>
</article>
</div>
@@ -3556,6 +3587,11 @@ watch(mobileMenuOpen, (open) => {
<strong>Paid in cash</strong>
<small>Record {{ paymentPeriodLabel }} paid in person.</small>
</button>
<button class="payment-choice-card comp" type="button" :disabled="isPaymentModalLoading" @click="compMembership">
<span>Comp</span>
<strong>Complimentary</strong>
<small>Grant {{ paymentPeriodLabel }} at no charge.</small>
</button>
<button class="payment-choice-card bitcoin" type="button" :disabled="isPaymentModalLoading || !appConfig.btcpayEnabled" @click="createBitcoinPayment">
<span> Bitcoin</span>
<strong>BTCPay invoice</strong>

View File

@@ -2133,6 +2133,11 @@ body.menu-open {
background: rgba(242, 169, 0, 0.1);
}
.payment-choice-card.comp {
border-color: rgba(72, 187, 120, 0.4);
background: rgba(72, 187, 120, 0.1);
}
.payment-choice-card:disabled {
cursor: not-allowed;
opacity: 0.48;