diff --git a/__tests__/dues-payments-logic.test.ts b/__tests__/dues-payments-logic.test.ts index b6284e4..2660933 100644 --- a/__tests__/dues-payments-logic.test.ts +++ b/__tests__/dues-payments-logic.test.ts @@ -4,6 +4,7 @@ import { DUES_PLATFORM_LABEL, isDuesLinkPlatform, isSafeDuesUrl, + resolveDuesAdvance, } from '@/lib/dues-payments-logic' describe('DUES_LINK_PLATFORMS', () => { @@ -57,3 +58,47 @@ describe('isSafeDuesUrl', () => { expect(isSafeDuesUrl('https://')).toBe(false) }) }) + +describe('resolveDuesAdvance', () => { + const older = '2026-01-01T00:00:00.000Z' + const newer = '2026-06-01T00:00:00.000Z' + + it('advances when there is no prior payment and marks current', () => { + expect(resolveDuesAdvance(null, newer)).toEqual({ + last_paid_at: newer, + payment_status: 'current', + }) + }) + + it('advances (and marks current) when the new payment is more recent', () => { + expect(resolveDuesAdvance(older, newer)).toEqual({ + last_paid_at: newer, + payment_status: 'current', + }) + }) + + it('advances (and marks current) when dates are equal — the common re-pay case', () => { + expect(resolveDuesAdvance(newer, newer)).toEqual({ + last_paid_at: newer, + payment_status: 'current', + }) + }) + + it('does NOT regress: a backdated payment keeps the newer date and omits payment_status', () => { + const result = resolveDuesAdvance(newer, older) + expect(result.last_paid_at).toBe(newer) + expect(result).not.toHaveProperty('payment_status') + }) + + it('compares by instant, so a +00:00 offset and a Z suffix for the same time still advance', () => { + // existing came back from PostgREST with an offset; incoming is toISOString() + expect(resolveDuesAdvance('2026-06-01T00:00:00+00:00', newer)).toEqual({ + last_paid_at: newer, + payment_status: 'current', + }) + // and a backdated one expressed with an offset still does not regress + const back = resolveDuesAdvance(newer, '2026-01-01T00:00:00+00:00') + expect(back.last_paid_at).toBe(newer) + expect(back).not.toHaveProperty('payment_status') + }) +}) diff --git a/lib/actions/payments.ts b/lib/actions/payments.ts index dcced2f..c5191f0 100644 --- a/lib/actions/payments.ts +++ b/lib/actions/payments.ts @@ -15,6 +15,7 @@ import { linkPaymentSchema, importPaymentsCsvSchema, } from '@/lib/validations' +import { resolveDuesAdvance } from '@/lib/dues-payments-logic' import type { Enums, TablesInsert } from '@/types/database' type PaymentPlatform = Enums<'payment_platform'> @@ -53,12 +54,17 @@ export async function logCashPayment(formData: { if (error) return { error: error.message } if (v.data.member_id) { + // Advance-only: a backdated cash payment must not move dues state backward. + const { data: existing } = await supabase + .from('space_members') + .select('last_paid_at') + .eq('id', v.data.member_id) + .eq('space_id', member.space_id) + .single() + await supabase .from('space_members') - .update({ - last_paid_at: transactionDate, - payment_status: 'current', - }) + .update(resolveDuesAdvance(existing?.last_paid_at ?? null, transactionDate)) .eq('id', v.data.member_id) .eq('space_id', member.space_id) } @@ -100,13 +106,19 @@ export async function linkPaymentToMember(paymentId: string, memberId: string) { if (error) return { error: error.message } - if (payment) { + if (payment?.transaction_date) { + // Advance-only: linking a historical payment must not move dues state + // backward if the member has a more recent payment on file. + const { data: existing } = await supabase + .from('space_members') + .select('last_paid_at') + .eq('id', v.data.memberId) + .eq('space_id', self.space_id) + .single() + await supabase .from('space_members') - .update({ - last_paid_at: payment.transaction_date, - payment_status: 'current', - }) + .update(resolveDuesAdvance(existing?.last_paid_at ?? null, payment.transaction_date)) .eq('id', v.data.memberId) .eq('space_id', self.space_id) } diff --git a/lib/dues-payments-logic.ts b/lib/dues-payments-logic.ts index ec6907c..be2b737 100644 --- a/lib/dues-payments-logic.ts +++ b/lib/dues-payments-logic.ts @@ -21,6 +21,29 @@ export function isDuesLinkPlatform(p: string): p is DuesLinkPlatform { return (DUES_LINK_PLATFORMS as readonly string[]).includes(p) } +// Advance-only dues state for a logged/linked payment. A payment — especially a +// backdated or historical one being reconciled — must never move a member's +// dues state backward. Given the member's current last_paid_at and the incoming +// payment's transaction_date, keep whichever date is later, and only mark the +// member 'current' when the incoming payment is actually the most recent one on +// file. A payment older than what's already recorded changes nothing: we return +// the existing date and omit payment_status so the caller leaves it untouched. +// Both inputs are full ISO timestamps; comparison is by instant, not string, +// so PostgREST offsets (`+00:00`) and toISOString `Z` never disagree. +export function resolveDuesAdvance( + existing: string | null, + incoming: string, +): { last_paid_at: string; payment_status?: 'current' } { + if (existing !== null) { + const existingT = new Date(existing).getTime() + const incomingT = new Date(incoming).getTime() + if (Number.isFinite(existingT) && Number.isFinite(incomingT) && incomingT < existingT) { + return { last_paid_at: existing } + } + } + return { last_paid_at: incoming, payment_status: 'current' } +} + // A payment link is only safe to render as a member-clickable anchor if it is // an absolute https URL. http (plaintext) and any non-web scheme // (javascript:, data:, mailto:, etc.) are rejected so an admin-entered value