diff --git a/src/modules/events/lib/promoCodes.spec.ts b/src/modules/events/lib/promoCodes.spec.ts new file mode 100644 index 0000000..8369c3f --- /dev/null +++ b/src/modules/events/lib/promoCodes.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import { + applyPercent, + fromWirePromoCodes, + hasPromoErrors, + isUnsupportedEndpointError, + normalizePromoCode, + toWirePromoCodes, + validatePromoRows, + type PromoCodeRow, +} from './promoCodes' + +function row(overrides: Partial = {}): PromoCodeRow { + return { code: 'HALF', discount_percent: 50, max_uses: null, active: true, ...overrides } +} + +describe('normalizePromoCode', () => { + it('trims and upper-cases, and tolerates null', () => { + expect(normalizePromoCode(' summer24 ')).toBe('SUMMER24') + expect(normalizePromoCode(null)).toBe('') + expect(normalizePromoCode(undefined)).toBe('') + }) +}) + +describe('validatePromoRows', () => { + it('accepts a well-formed row', () => { + const errors = validatePromoRows([row()]) + expect(errors).toEqual([{}]) + expect(hasPromoErrors(errors)).toBe(false) + }) + + it('flags an empty code', () => { + expect(validatePromoRows([row({ code: ' ' })])[0].code).toBe('events.create.promo.errors.required') + }) + + it('flags duplicates case-insensitively on both rows', () => { + const errors = validatePromoRows([row({ code: 'half' }), row({ code: ' HALF ' })]) + expect(errors[0].code).toBe('events.create.promo.errors.duplicate') + expect(errors[1].code).toBe('events.create.promo.errors.duplicate') + expect(hasPromoErrors(errors)).toBe(true) + }) + + it('requires the percentage to be within 1..100', () => { + for (const pct of [null, 0, 101, Number.NaN]) { + expect(validatePromoRows([row({ discount_percent: pct })])[0].discount_percent) + .toBe('events.create.promo.errors.percentRange') + } + expect(validatePromoRows([row({ discount_percent: 100 })])[0].discount_percent).toBeUndefined() + }) + + it('allows blank max uses and rejects zero / fractions', () => { + expect(validatePromoRows([row({ max_uses: null })])[0].max_uses).toBeUndefined() + expect(validatePromoRows([row({ max_uses: 3 })])[0].max_uses).toBeUndefined() + expect(validatePromoRows([row({ max_uses: 0 })])[0].max_uses).toBe('events.create.promo.errors.maxUsesMin') + expect(validatePromoRows([row({ max_uses: 1.5 })])[0].max_uses).toBe('events.create.promo.errors.maxUsesMin') + }) +}) + +describe('toWirePromoCodes / fromWirePromoCodes', () => { + it('normalises codes, drops used_count and maps blank max uses to null', () => { + expect(toWirePromoCodes([row({ code: ' half ', used_count: 4 }), row({ code: 'CAP', max_uses: 2 })])).toEqual([ + { code: 'HALF', discount_percent: 50, active: true, max_uses: null }, + { code: 'CAP', discount_percent: 50, active: true, max_uses: 2 }, + ]) + }) + + it('round-trips the wire shape and tolerates the older three-field shape', () => { + expect(fromWirePromoCodes([{ code: 'OLD', discount_percent: 20, active: false }])).toEqual([ + { code: 'OLD', discount_percent: 20, max_uses: null, active: false, used_count: 0 }, + ]) + expect(fromWirePromoCodes(undefined)).toEqual([]) + expect(fromWirePromoCodes([{ code: 'CAP', discount_percent: 10, active: true, max_uses: 0, used_count: 2 }])) + .toEqual([{ code: 'CAP', discount_percent: 10, max_uses: null, active: true, used_count: 2 }]) + }) +}) + +describe('applyPercent', () => { + it('rounds sats down to whole units and fiat to two decimals', () => { + expect(applyPercent(333, 15, 'sat')).toBe(283) + expect(applyPercent(59.97, 33, 'EUR')).toBe(40.18) + expect(applyPercent(1000, 100, 'sats')).toBe(0) + }) +}) + +describe('isUnsupportedEndpointError', () => { + it('recognises 404/405 and nothing else', () => { + expect(isUnsupportedEndpointError(Object.assign(new Error('x'), { status: 404 }))).toBe(true) + expect(isUnsupportedEndpointError(Object.assign(new Error('x'), { status: 405 }))).toBe(true) + expect(isUnsupportedEndpointError(Object.assign(new Error('x'), { status: 400 }))).toBe(false) + expect(isUnsupportedEndpointError(new Error('x'))).toBe(false) + expect(isUnsupportedEndpointError(null)).toBe(false) + }) +}) diff --git a/src/modules/events/lib/promoCodes.ts b/src/modules/events/lib/promoCodes.ts new file mode 100644 index 0000000..812b62e --- /dev/null +++ b/src/modules/events/lib/promoCodes.ts @@ -0,0 +1,125 @@ +import type { PromoCode } from '../types/ticket' + +/** + * Promo-code helpers shared by the organizer editor and the buyer + * dialog. Pure functions, no Vue — mirror `promo.py` in the events + * extension by name so the two sides stay recognisably in step: + * + * normalizePromoCode ↔ promo.normalize_code + * applyPercent ↔ promo.round_amount / basket_totals (display fallback only) + * + * The server's `POST /events/api/v1/promo/validate/{event_id}` is the + * source of truth for the discounted total; `applyPercent` exists so + * the UI can still show *something* against an older extension. + */ + +/** One editor row. `discount_percent` / `max_uses` are `null` while the + * organizer is still typing (empty inputs), never `0`-as-empty. */ +export interface PromoCodeRow { + code: string + discount_percent: number | null + /** `null` = unlimited (backend `max_uses: None`). */ + max_uses: number | null + active: boolean + /** Derived by the backend from paid tickets; read-only in the editor. */ + used_count?: number +} + +/** Per-row validation messages as i18n keys (the component runs them + * through `t()`); a missing field means the field is fine. */ +export interface PromoRowErrors { + code?: string + discount_percent?: string + max_uses?: string +} + +const ERROR_KEYS = { + required: 'events.create.promo.errors.required', + duplicate: 'events.create.promo.errors.duplicate', + percentRange: 'events.create.promo.errors.percentRange', + maxUsesMin: 'events.create.promo.errors.maxUsesMin', +} as const + +/** Trim + upper-case, the same normalisation the backend applies before + * matching, so what the buyer sees in the input is what gets matched. */ +export function normalizePromoCode(raw: string | null | undefined): string { + return (raw ?? '').trim().toUpperCase() +} + +/** + * Editor rules — stricter than the backend on purpose: the backend + * accepts 0 % (a no-op the buyer would see as "code not valid"), the + * editor requires 1–100 so an organizer cannot create a dud by accident. + */ +export function validatePromoRows(rows: PromoCodeRow[]): PromoRowErrors[] { + const seen = new Map() + for (const row of rows) { + const code = normalizePromoCode(row.code) + if (code) seen.set(code, (seen.get(code) ?? 0) + 1) + } + return rows.map((row) => { + const errors: PromoRowErrors = {} + const code = normalizePromoCode(row.code) + if (!code) { + errors.code = ERROR_KEYS.required + } else if ((seen.get(code) ?? 0) > 1) { + errors.code = ERROR_KEYS.duplicate + } + const pct = row.discount_percent + if (pct === null || !Number.isFinite(pct) || pct < 1 || pct > 100) { + errors.discount_percent = ERROR_KEYS.percentRange + } + const max = row.max_uses + if (max !== null && (!Number.isInteger(max) || max < 1)) { + errors.max_uses = ERROR_KEYS.maxUsesMin + } + return errors + }) +} + +export function hasPromoErrors(errors: PromoRowErrors[]): boolean { + return errors.some((e) => Object.keys(e).length > 0) +} + +/** Editor rows → wire `extra.promo_codes`. Drops the read-only + * `used_count` (the backend recomputes it) and blank max-uses → `null`. */ +export function toWirePromoCodes(rows: PromoCodeRow[]): PromoCode[] { + return rows.map((row) => ({ + code: normalizePromoCode(row.code), + discount_percent: row.discount_percent ?? 0, + active: row.active, + max_uses: row.max_uses && row.max_uses > 0 ? Math.trunc(row.max_uses) : null, + })) +} + +/** Wire `extra.promo_codes` → editor rows. Tolerates the pre-`max_uses` + * shape (older extension) and a missing list (public records). */ +export function fromWirePromoCodes(codes: PromoCode[] | null | undefined): PromoCodeRow[] { + return (codes ?? []).map((c) => ({ + code: c.code, + discount_percent: c.discount_percent ?? 0, + max_uses: c.max_uses && c.max_uses > 0 ? c.max_uses : null, + active: c.active ?? true, + used_count: c.used_count ?? 0, + })) +} + +/** + * Display-only fallback for the discounted total when the validate + * endpoint is unavailable. Rounds like the backend: whole sats, two + * decimals for fiat. + */ +export function applyPercent(subtotal: number, percent: number, currency: string): number { + const raw = subtotal * (1 - percent / 100) + const unit = currency.toLowerCase() + if (unit === 'sat' || unit === 'sats') return Math.max(0, Math.trunc(raw)) + return Math.max(0, Math.round(raw * 100) / 100) +} + +/** An older extension without the validate route answers 404 (or 405 + * when the path exists for another method). Anything else is a real + * error. */ +export function isUnsupportedEndpointError(err: unknown): boolean { + const status = (err as { status?: unknown } | null)?.status + return status === 404 || status === 405 +} diff --git a/src/modules/events/services/TicketApiService.spec.ts b/src/modules/events/services/TicketApiService.spec.ts index 999ac80..3559f03 100644 --- a/src/modules/events/services/TicketApiService.spec.ts +++ b/src/modules/events/services/TicketApiService.spec.ts @@ -90,3 +90,57 @@ describe('TicketApiService.getTicket / resendTicketEmail', () => { expect(legacy.email).toEqual({ attempted: false, sent: false, error: null }) }) }) + +describe('TicketApiService promo codes', () => { + const fetchMock = vi.fn() + let api: TicketApiService + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + api = new TicketApiService({ baseUrl: BASE, apiKey: 'inst-key' }) + }) + afterEach(() => vi.unstubAllGlobals()) + + function lastCall() { + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + return { url, init, body: JSON.parse(init.body as string), headers: init.headers as Record } + } + + it('validatePromoCode posts the v2 shape with an upper-cased code', async () => { + const totals = { + subtotal: 2000, discount: 1000, total: 1000, currency: 'sat', + discounts_applied: [{ code: 'HALF', discount_percent: 50, discount_fixed: null, amount_saved: 1000 }], + } + fetchMock.mockResolvedValue(okJson(totals)) + const res = await api.validatePromoCode('evt', ' half ', 2) + const { url, init, body } = lastCall() + expect(url).toBe(`${BASE}/events/api/v1/promo/validate/evt`) + expect(init.method).toBe('POST') + expect(body).toEqual({ codes: ['HALF'], quantity: 2 }) + expect(res).toEqual(totals) + }) + + it('carries the HTTP status on errors so a missing route (older extension) is recognisable', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({ detail: 'Not Found' }) } as Response) + await expect(api.validatePromoCode('evt', 'HALF')).rejects.toMatchObject({ message: 'Not Found', status: 404 }) + }) + + it('requestTicket forwards promo_code verbatim', async () => { + fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', payment_request: 'lnbc1', is_fiat: false })) + await api.requestTicket('evt', { userId: 'u1', promoCode: 'HALF', quantity: 2 }) + expect(lastCall().body).toEqual({ user_id: 'u1', promo_code: 'HALF', quantity: 2 }) + }) + + it('createEvent sends extra.promo_codes verbatim', async () => { + fetchMock.mockResolvedValue(okJson({ id: 'evt' })) + const promo_codes = [{ code: 'HALF', discount_percent: 50, active: true, max_uses: 2 }] + await api.createEvent( + { name: 'E', event_start_date: '2030-01-01', extra: { promo_codes } }, + 'inkey', + ) + const { body, headers } = lastCall() + expect(body.extra.promo_codes).toEqual(promo_codes) + expect(headers['X-API-KEY']).toBe('inkey') + }) +}) diff --git a/src/modules/events/services/TicketApiService.ts b/src/modules/events/services/TicketApiService.ts index 9a2da22..2f8b291 100644 --- a/src/modules/events/services/TicketApiService.ts +++ b/src/modules/events/services/TicketApiService.ts @@ -1,4 +1,5 @@ import type { + BasketTotals, EventTicket, EventTicketExtra, CreateTicketRequest, @@ -9,7 +10,9 @@ import type { TicketResendResult, TicketedEvent, CreateEventRequest, + PromoValidateRequest, } from '../types/ticket' +import { normalizePromoCode } from '../lib/promoCodes' export interface TicketApiConfig { baseUrl: string @@ -335,6 +338,8 @@ export class TicketApiService { name?: string | null registered: boolean registered_at: string | null + /** Promo code the buyer redeemed (events ext >= v1.6.1-aio.12). */ + applied_promo_code?: string | null }> }> { return this.request(`/events/api/v1/tickets/event/${eventId}/stats`, { @@ -343,6 +348,22 @@ export class TicketApiService { }) } + /** + * Preview what a promo code does to a purchase of `quantity` tickets + * (`POST /events/api/v1/promo/validate/{event_id}`, anonymous). The + * backend applies the first valid code; an empty `discounts_applied` + * means "not valid". Older extensions 404 — the thrown error carries + * `status` so callers can fall back to "applied at checkout". + */ + async validatePromoCode(eventId: string, code: string, quantity = 1): Promise { + const body: PromoValidateRequest = { codes: [normalizePromoCode(code)], quantity } + return this.request(`/events/api/v1/promo/validate/${eventId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + /** * Mark a paid ticket as registered at the door. Organizer-only — * requires the event-owning wallet's admin_key. Backend rejects @@ -441,7 +462,9 @@ export class TicketApiService { : Array.isArray(error.detail) ? error.detail[0]?.msg ?? 'Request failed' : 'Request failed' - throw new Error(errorMessage) + // Carry the HTTP status so callers can tell "route missing on an + // older extension" (404) apart from "bad input" (400). + throw Object.assign(new Error(errorMessage), { status: response.status }) } return response.json() diff --git a/src/modules/events/types/ticket.ts b/src/modules/events/types/ticket.ts index d42b3c2c..67ee90f 100644 --- a/src/modules/events/types/ticket.ts +++ b/src/modules/events/types/ticket.ts @@ -10,6 +10,38 @@ export interface PromoCode { code: string discount_percent: number active: boolean + /** Redemption cap; `null` / absent = unlimited (events ext >= v1.6.1-aio.12, + * same name as upstream v2). */ + max_uses?: number | null + /** Paid tickets that used this code — derived by the backend on read, + * never sent back. */ + used_count?: number +} + +/** Body for `POST /events/api/v1/promo/validate/{event_id}` (upstream + * v2 shape; `quantity` stands in for v2's `items` since this fork has + * no ticket types). */ +export interface PromoValidateRequest { + codes: string[] + quantity: number +} + +export interface BasketDiscount { + code: string + discount_percent: number | null + /** Always null on this fork (percent-only); kept for the v2 shape. */ + discount_fixed: number | null + amount_saved: number +} + +/** Response of the promo validate endpoint. An empty `discounts_applied` + * means none of the submitted codes is valid for this event/quantity. */ +export interface BasketTotals { + subtotal: number + discount: number + total: number + discounts_applied: BasketDiscount[] + currency: string } /** @@ -19,7 +51,10 @@ export interface PromoCode { * in upstream v1.4.0 (PR #50) and v1.6.0. */ export interface EventExtra { - promo_codes: PromoCode[] + /** Present on organizer-authenticated records only: public event + * responses (`/events/public`, `/events/{id}`) no longer carry the + * code list (events ext >= v1.6.1-aio.12). */ + promo_codes?: PromoCode[] conditional: boolean min_tickets: number email_notifications: boolean