Add comp memberships and harden cash payments
This commit is contained in:
18
public/sw.js
18
public/sw.js
@@ -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()),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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') {
|
||||
|
||||
60
src/App.vue
60
src/App.vue
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user