@aglyn/plugins-bookings 1.0.0-beta.231 → 1.0.0-beta.232

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +3 -1
  2. package/package.json +11 -11
  3. package/src/lib/components/bookings-console-page.js +30 -4
  4. package/src/lib/components/bookings-console-page.js.map +1 -1
  5. package/src/lib/declarations.console-server.d.ts +3 -0
  6. package/src/lib/declarations.console-server.js +20 -0
  7. package/src/lib/declarations.console-server.js.map +1 -1
  8. package/src/lib/model/booking-events.d.ts +52 -0
  9. package/src/lib/model/booking-events.js +53 -0
  10. package/src/lib/model/booking-events.js.map +1 -0
  11. package/src/lib/model/booking-manage.d.ts +7 -0
  12. package/src/lib/model/booking-manage.js +14 -0
  13. package/src/lib/model/booking-manage.js.map +1 -1
  14. package/src/lib/model/booking-view.d.ts +63 -0
  15. package/src/lib/model/booking-view.js +70 -0
  16. package/src/lib/model/booking-view.js.map +1 -0
  17. package/src/lib/server/api-v1/bookings.d.ts +33 -0
  18. package/src/lib/server/api-v1/bookings.js +103 -0
  19. package/src/lib/server/api-v1/bookings.js.map +1 -0
  20. package/src/lib/server/api-v1/openapi.d.ts +22 -0
  21. package/src/lib/server/api-v1/openapi.js +107 -0
  22. package/src/lib/server/api-v1/openapi.js.map +1 -0
  23. package/src/lib/server/billing-webhook.js +17 -0
  24. package/src/lib/server/billing-webhook.js.map +1 -1
  25. package/src/lib/server/booking-events.d.ts +40 -0
  26. package/src/lib/server/booking-events.js +50 -0
  27. package/src/lib/server/booking-events.js.map +1 -0
  28. package/src/lib/server/booking-manage.d.ts +16 -0
  29. package/src/lib/server/booking-manage.js +76 -2
  30. package/src/lib/server/booking-manage.js.map +1 -1
  31. package/src/lib/server/refund.js +17 -1
  32. package/src/lib/server/refund.js.map +1 -1
  33. package/src/lib/server.js +22 -3
  34. package/src/lib/server.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/server/booking-manage.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { hostRoleCanWrite } from '@aglyn/aglyn/app-utils/organizations'\nimport type { PluginApiHandler, PluginApiRequest } from '@aglyn/aglyn/app-utils/api-plugins'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n renderHostEmailWithTokens,\n} from '@aglyn/tenant-data-admin'\nimport { getSiteLockdown } from '@aglyn/tenant-data-admin/server/tenant-write-lockdown'\nimport { resolveBrandingProfile } from '@aglyn/aglyn/server'\nimport {\n bookingDurationMs,\n checkInRefusal,\n type ManagedBooking,\n rescheduleRefusal,\n} from '../model/booking-manage'\nimport { type BookedInterval, type HostBookingService, isSlotOpen } from '../model/bookings'\nimport { bookingTimeZone, formatBookingWhen, storedBookingTimeZone } from '../model/booking-time'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\n\n/*\n * Checking a guest in and moving a booking (AGL-3621), for the site's team.\n *\n * The native Aglyn app's bookings calendar calls these; any console surface\n * may too. Each is a member route: the caller's ID token names them, the\n * host's `memberRoles` must give them a content-writing role (the same roles the rules let write\n * a booking directly), and a locked-down site refuses. Each decides inside\n * ONE transaction, re-reading the booking, so two phones acting at once\n * cannot both move it or check in a guest whose booking was just canceled.\n *\n * Moving a booking re-checks the new slot against the service's hours and\n * every other live booking of that service in the same transaction the\n * public booking route uses, so a move can never double-book. It keeps the\n * booking's own length, clears the reminder stamp so the 24-hour reminder\n * goes out for the new time, and tells the guest by email.\n */\n\n/** The Firestore surface these routes use: the Admin SDK's, narrowed so a spec can hand in a double. */\nexport interface ManageDocSnapshot {\n readonly exists: boolean\n readonly id?: string\n get(field: string): unknown\n data(): Record<string, unknown> | undefined\n}\nexport interface ManageDocRef {\n readonly id: string\n}\nexport interface ManageQuery {\n where(field: string, op: '==' | '>=' | '<', value: unknown): ManageQuery\n limit(count: number): ManageQuery\n}\nexport interface ManageTransaction {\n get(ref: ManageDocRef): Promise<ManageDocSnapshot>\n get(query: ManageQuery): Promise<{ docs: ManageDocSnapshot[] }>\n update(ref: ManageDocRef, data: Record<string, unknown>): unknown\n}\nexport interface ManageFirestore {\n doc(path: string): ManageDocRef & { get(): Promise<ManageDocSnapshot> }\n collection(path: string): ManageQuery\n runTransaction<T>(fn: (transaction: ManageTransaction) => Promise<T>): Promise<T>\n}\n\nexport interface RescheduleNotice {\n hostId: string\n bookingId: string\n to: string\n name: string\n serviceName: string\n when: string\n previousWhen: string\n timezone: string\n}\n\nexport interface BookingManageDeps {\n firestore: () => ManageFirestore\n verifyIdToken: (token: string) => Promise<{ uid: string }>\n /** True when the site is locked down and must not take writes. */\n siteLocked: (hostId: string) => Promise<boolean>\n /** The zone a booking is told in when the document carries none. */\n timeZoneFor: (hostId: string, service: HostBookingService | undefined) => Promise<string>\n notifyRescheduled: (notice: RescheduleNotice) => Promise<void>\n now: () => number\n}\n\n/** Field the booking stamps when the guest arrives. */\nexport const CHECKED_IN_AT = 'checkedInAtMs'\nexport const CHECKED_IN_BY = 'checkedInBy'\n\nclass Refusal extends Error {\n constructor(\n readonly status: number,\n message: string,\n ) {\n super(message)\n }\n}\n\nconst bearerOf = (req: PluginApiRequest): string => {\n const header = String(req.headers['authorization'] ?? req.headers['Authorization'] ?? '')\n return header.startsWith('Bearer ') ? header.slice('Bearer '.length) : ''\n}\n\nconst bodyOf = (req: PluginApiRequest): Record<string, unknown> => {\n if (typeof req.body === 'string') {\n try {\n return JSON.parse(req.body) as Record<string, unknown>\n } catch {\n return {}\n }\n }\n return (req.body ?? {}) as Record<string, unknown>\n}\n\n/** The member, their right to write this site's bookings, and the site being open. */\nasync function authorize(\n deps: BookingManageDeps,\n req: PluginApiRequest,\n hostId: string,\n): Promise<{ uid: string }> {\n const token = bearerOf(req)\n if (!token) throw new Refusal(401, 'Unauthenticated')\n let uid: string\n try {\n uid = (await deps.verifyIdToken(token)).uid\n } catch (error) {\n if (!isRefusedIdToken(error)) throw error\n throw new Refusal(401, 'Unauthenticated')\n }\n const host = await deps.firestore().doc(`hosts/${hostId}`).get()\n if (!host.exists) throw new Refusal(404, 'Unknown site')\n const role = ((host.get('memberRoles') ?? {}) as Record<string, unknown>)[uid]\n if (!hostRoleCanWrite(role)) throw new Refusal(403, 'Your role on this site cannot change bookings')\n if (await deps.siteLocked(hostId)) throw new Refusal(423, 'This site is locked and cannot take changes right now')\n return { uid }\n}\n\nfunction answer(res: Parameters<PluginApiHandler>[1], error: unknown): void {\n if (error instanceof Refusal) {\n res.status(error.status).json({ error: error.message })\n return\n }\n console.error('[bookings/manage]', error)\n res.status(500).json({ error: 'That did not go through. Try again.' })\n}\n\nexport function createBookingCheckInHandler(deps: BookingManageDeps): PluginApiHandler {\n return async (req, res) => {\n if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })\n const body = bodyOf(req)\n const hostId = String(body['hostId'] ?? '')\n const bookingId = String(body['bookingId'] ?? '')\n const checkedIn = body['checkedIn'] !== false\n if (!hostId || !bookingId) return res.status(400).json({ error: 'Missing hostId or bookingId' })\n try {\n const { uid } = await authorize(deps, req, hostId)\n const firestore = deps.firestore()\n const ref = firestore.doc(`hosts/${hostId}/bookings/${bookingId}`)\n const result = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n if (!snapshot.exists) throw new Refusal(404, 'Unknown booking')\n const booking = (snapshot.data() ?? {}) as ManagedBooking\n const nowMs = deps.now()\n const refusal = checkInRefusal(booking, checkedIn, nowMs)\n if (refusal) throw new Refusal(409, refusal)\n const already = Number(booking.checkedInAtMs ?? 0) > 0\n // Already where it was asked to be: a retried tap changes nothing.\n if (already === checkedIn) return { checkedInAtMs: already ? Number(booking.checkedInAtMs) : null }\n if (checkedIn) {\n transaction.update(ref, { [CHECKED_IN_AT]: nowMs, [CHECKED_IN_BY]: uid })\n return { checkedInAtMs: nowMs }\n }\n transaction.update(ref, { [CHECKED_IN_AT]: null, [CHECKED_IN_BY]: null })\n return { checkedInAtMs: null }\n })\n return res.status(200).json({ ok: true, ...result })\n } catch (error) {\n return answer(res, error)\n }\n }\n}\n\nexport function createBookingRescheduleHandler(deps: BookingManageDeps): PluginApiHandler {\n return async (req, res) => {\n if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })\n const body = bodyOf(req)\n const hostId = String(body['hostId'] ?? '')\n const bookingId = String(body['bookingId'] ?? '')\n const startsAtMs = Number(body['startsAtMs'])\n if (!hostId || !bookingId) return res.status(400).json({ error: 'Missing hostId or bookingId' })\n if (!Number.isFinite(startsAtMs) || startsAtMs <= 0) return res.status(400).json({ error: 'Pick a new time' })\n try {\n const { uid } = await authorize(deps, req, hostId)\n const firestore = deps.firestore()\n const ref = firestore.doc(`hosts/${hostId}/bookings/${bookingId}`)\n const first = await ref.get()\n if (!first.exists) throw new Refusal(404, 'Unknown booking')\n const serviceId = String(first.get('serviceId') ?? '')\n const serviceSnapshot = serviceId ? await firestore.doc(`hosts/${hostId}/services/${serviceId}`).get() : null\n const service = serviceSnapshot?.exists ? (serviceSnapshot.data() as unknown as HostBookingService) : undefined\n if (!service || serviceSnapshot?.get('deletedAt')) {\n throw new Refusal(409, 'This booking’s service no longer exists, so it cannot move')\n }\n\n const moved = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n if (!snapshot.exists) throw new Refusal(404, 'Unknown booking')\n const booking = (snapshot.data() ?? {}) as ManagedBooking & Record<string, unknown>\n const nowMs = deps.now()\n const refusal = rescheduleRefusal(booking, startsAtMs, nowMs)\n if (refusal) throw new Refusal(409, refusal)\n const previousStartsAtMs = Number(booking.startsAtMs ?? 0)\n // Already at that time: a retried move writes nothing and tells no one twice.\n if (previousStartsAtMs === startsAtMs) return null\n const durationMs = bookingDurationMs(booking, service.durationMinutes)\n // The public booking route's own read: every booking of this service\n // from a day before the new time, so one running into it counts.\n const nearby = await transaction.get(\n firestore\n .collection(`hosts/${hostId}/bookings`)\n .where('serviceId', '==', serviceId)\n .where('startsAtMs', '>=', startsAtMs - 24 * 60 * 60_000)\n .limit(500),\n )\n const booked: BookedInterval[] = nearby.docs\n .filter((doc) => doc.id !== bookingId)\n .filter((doc) => {\n const status = doc.get('status')\n if (status === 'canceled') return false\n return !(status === 'pendingPayment' && Number(doc.get('expiresAtMs') ?? 0) < nowMs)\n })\n .map((doc) => ({ startsAtMs: Number(doc.get('startsAtMs') ?? 0), endsAtMs: Number(doc.get('endsAtMs') ?? 0) }))\n if (!isSlotOpen(service, startsAtMs, booked)) throw new Refusal(409, 'That time is not open. Pick another.')\n const endsAtMs = startsAtMs + durationMs\n transaction.update(ref, {\n startsAtMs,\n endsAtMs,\n rescheduledAtMs: nowMs,\n rescheduledBy: uid,\n rescheduledFromMs: previousStartsAtMs,\n // The reminder is owed again, for the new time.\n reminderSentAt: null,\n })\n return { booking, previousStartsAtMs, endsAtMs }\n })\n\n if (!moved) {\n return res.status(200).json({ ok: true, startsAtMs, endsAtMs: Number(first.get('endsAtMs') ?? 0), notified: false })\n }\n const to = String(moved.booking['email'] ?? '').trim()\n let notified = false\n if (to) {\n const timezone = storedBookingTimeZone({ timezone: moved.booking['timezone'] }) ?? (await deps.timeZoneFor(hostId, service))\n try {\n await deps.notifyRescheduled({\n hostId,\n bookingId,\n to,\n name: String(moved.booking['name'] ?? ''),\n serviceName: String(moved.booking['serviceName'] ?? service.name ?? ''),\n when: formatBookingWhen(startsAtMs, timezone),\n previousWhen: formatBookingWhen(moved.previousStartsAtMs, timezone),\n timezone,\n })\n notified = true\n } catch (error) {\n // The booking has moved whatever the mail did; the screen says the guest was not told.\n console.error('[bookings/reschedule] notice failed', hostId, bookingId, error)\n }\n }\n return res.status(200).json({ ok: true, startsAtMs, endsAtMs: moved.endsAtMs, notified })\n } catch (error) {\n return answer(res, error)\n }\n }\n}\n\n/** The email that tells a guest their booking moved, as the confirmation is sent. */\nexport async function sendRescheduledNotice(notice: RescheduleNotice): Promise<void> {\n if (!isEmailConfigured()) throw new Error('Email is not configured')\n const firestore = firebaseAdmin.app().firestore()\n const fallbackText =\n `Hi ${notice.name},\\n\\nYour booking for \"${notice.serviceName}\" is now on ` +\n `${notice.when} (${notice.timezone}). It was on ${notice.previousWhen}.\\n\\n` +\n `Reference: ${notice.bookingId}`\n const designed = await renderHostEmailWithTokens(firestore, notice.hostId, 'booking-rescheduled', {\n name: notice.name,\n 'service.name': notice.serviceName,\n when: notice.when,\n previousWhen: notice.previousWhen,\n timezone: notice.timezone,\n 'booking.ref': notice.bookingId,\n })\n const branding = resolveBrandingProfile((await getOrgForHost(notice.hostId).catch(() => null))?.org as never)\n await sendEmail({\n to: notice.to,\n subject: designed?.subject ?? `New time for ${notice.serviceName}`,\n text: designed?.text || fallbackText,\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(notice.hostId),\n audience: 'tenant',\n context: 'booking rescheduled',\n owedFor: 'booking',\n })\n await meterHostEmail(notice.hostId)\n}\n\n/** The production wiring: the Admin SDK, the site lockdown verdict and the real send. */\nexport function bookingManageDeps(): BookingManageDeps {\n return {\n firestore: () => firebaseAdmin.app().firestore() as unknown as ManageFirestore,\n verifyIdToken: (token) => firebaseAdmin.app().auth().verifyIdToken(token),\n siteLocked: async (hostId) => Boolean(await getSiteLockdown(hostId)),\n timeZoneFor: async (hostId, service) => {\n const firestore = firebaseAdmin.app().firestore()\n const [host, owner] = await Promise.all([\n firestore.collection('hosts').doc(hostId).get(),\n getOrgForHost(hostId).catch(() => null),\n ])\n return bookingTimeZone({\n service: service ?? null,\n host: host.data() as { timeZone?: string } | undefined,\n org: owner?.org as { timeZone?: string } | null | undefined,\n })\n },\n notifyRescheduled: sendRescheduledNotice,\n now: () => Date.now(),\n }\n}\n"],"names":["hostRoleCanWrite","isEmailConfigured","sendEmail","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","renderHostEmailWithTokens","getSiteLockdown","resolveBrandingProfile","bookingDurationMs","checkInRefusal","rescheduleRefusal","isSlotOpen","bookingTimeZone","formatBookingWhen","storedBookingTimeZone","isRefusedIdToken","CHECKED_IN_AT","CHECKED_IN_BY","Refusal","Error","status","message","bearerOf","req","header","String","headers","startsWith","slice","length","bodyOf","body","JSON","parse","authorize","deps","hostId","host","token","uid","verifyIdToken","error","firestore","doc","get","exists","role","siteLocked","answer","res","json","console","createBookingCheckInHandler","method","bookingId","checkedIn","ref","result","runTransaction","transaction","snapshot","booking","data","nowMs","now","refusal","already","Number","checkedInAtMs","update","ok","createBookingRescheduleHandler","startsAtMs","isFinite","first","moved","serviceId","serviceSnapshot","service","undefined","previousStartsAtMs","durationMs","durationMinutes","nearby","collection","where","limit","booked","docs","filter","id","map","endsAtMs","rescheduledAtMs","rescheduledBy","rescheduledFromMs","reminderSentAt","notified","to","trim","timezone","timeZoneFor","notifyRescheduled","name","serviceName","when","previousWhen","sendRescheduledNotice","notice","app","fallbackText","designed","branding","catch","org","subject","text","html","fromName","sendingIdentity","audience","context","owedFor","bookingManageDeps","auth","Boolean","owner","Promise","all","Date"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/server/booking-manage.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { hostRoleCanWrite } from '@aglyn/aglyn/app-utils/organizations'\nimport type { PluginApiHandler, PluginApiRequest } from '@aglyn/aglyn/app-utils/api-plugins'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n renderHostEmailWithTokens,\n} from '@aglyn/tenant-data-admin'\nimport { getSiteLockdown } from '@aglyn/tenant-data-admin/server/tenant-write-lockdown'\nimport { resolveBrandingProfile } from '@aglyn/aglyn/server'\nimport {\n bookingDurationMs,\n bookingState,\n cancelRefusal,\n checkInRefusal,\n type ManagedBooking,\n rescheduleRefusal,\n} from '../model/booking-manage'\nimport { type BookedInterval, type HostBookingService, isSlotOpen } from '../model/bookings'\nimport { bookingTimeZone, formatBookingWhen, storedBookingTimeZone } from '../model/booking-time'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { type BookingEventInput, raiseBookingEvent } from './booking-events'\n\n/*\n * Checking a guest in and moving a booking (AGL-3621), for the site's team.\n *\n * The native Aglyn app's bookings calendar calls these; any console surface\n * may too. Each is a member route: the caller's ID token names them, the\n * host's `memberRoles` must give them a content-writing role (the same roles the rules let write\n * a booking directly), and a locked-down site refuses. Each decides inside\n * ONE transaction, re-reading the booking, so two phones acting at once\n * cannot both move it or check in a guest whose booking was just canceled.\n *\n * Moving a booking re-checks the new slot against the service's hours and\n * every other live booking of that service in the same transaction the\n * public booking route uses, so a move can never double-book. It keeps the\n * booking's own length, clears the reminder stamp so the 24-hour reminder\n * goes out for the new time, and tells the guest by email.\n */\n\n/** The Firestore surface these routes use: the Admin SDK's, narrowed so a spec can hand in a double. */\nexport interface ManageDocSnapshot {\n readonly exists: boolean\n readonly id?: string\n get(field: string): unknown\n data(): Record<string, unknown> | undefined\n}\nexport interface ManageDocRef {\n readonly id: string\n}\nexport interface ManageQuery {\n where(field: string, op: '==' | '>=' | '<', value: unknown): ManageQuery\n limit(count: number): ManageQuery\n}\nexport interface ManageTransaction {\n get(ref: ManageDocRef): Promise<ManageDocSnapshot>\n get(query: ManageQuery): Promise<{ docs: ManageDocSnapshot[] }>\n update(ref: ManageDocRef, data: Record<string, unknown>): unknown\n}\nexport interface ManageFirestore {\n doc(path: string): ManageDocRef & { get(): Promise<ManageDocSnapshot> }\n collection(path: string): ManageQuery\n runTransaction<T>(fn: (transaction: ManageTransaction) => Promise<T>): Promise<T>\n}\n\nexport interface RescheduleNotice {\n hostId: string\n bookingId: string\n to: string\n name: string\n serviceName: string\n when: string\n previousWhen: string\n timezone: string\n}\n\nexport interface BookingManageDeps {\n firestore: () => ManageFirestore\n verifyIdToken: (token: string) => Promise<{ uid: string }>\n /** True when the site is locked down and must not take writes. */\n siteLocked: (hostId: string) => Promise<boolean>\n /** The zone a booking is told in when the document carries none. */\n timeZoneFor: (hostId: string, service: HostBookingService | undefined) => Promise<string>\n notifyRescheduled: (notice: RescheduleNotice) => Promise<void>\n now: () => number\n /**\n * Raises a booking event once the change is written (AGL-3643): the move,\n * the cancel. Never throws. Absent, nothing is raised.\n */\n raiseEvent?: (input: BookingEventInput) => Promise<void>\n}\n\n/** Field the booking stamps when the guest arrives. */\nexport const CHECKED_IN_AT = 'checkedInAtMs'\nexport const CHECKED_IN_BY = 'checkedInBy'\n\nclass Refusal extends Error {\n constructor(\n readonly status: number,\n message: string,\n ) {\n super(message)\n }\n}\n\nconst bearerOf = (req: PluginApiRequest): string => {\n const header = String(req.headers['authorization'] ?? req.headers['Authorization'] ?? '')\n return header.startsWith('Bearer ') ? header.slice('Bearer '.length) : ''\n}\n\nconst bodyOf = (req: PluginApiRequest): Record<string, unknown> => {\n if (typeof req.body === 'string') {\n try {\n return JSON.parse(req.body) as Record<string, unknown>\n } catch {\n return {}\n }\n }\n return (req.body ?? {}) as Record<string, unknown>\n}\n\n/** The member, their right to write this site's bookings, and the site being open. */\nasync function authorize(\n deps: BookingManageDeps,\n req: PluginApiRequest,\n hostId: string,\n): Promise<{ uid: string }> {\n const token = bearerOf(req)\n if (!token) throw new Refusal(401, 'Unauthenticated')\n let uid: string\n try {\n uid = (await deps.verifyIdToken(token)).uid\n } catch (error) {\n if (!isRefusedIdToken(error)) throw error\n throw new Refusal(401, 'Unauthenticated')\n }\n const host = await deps.firestore().doc(`hosts/${hostId}`).get()\n if (!host.exists) throw new Refusal(404, 'Unknown site')\n const role = ((host.get('memberRoles') ?? {}) as Record<string, unknown>)[uid]\n if (!hostRoleCanWrite(role)) throw new Refusal(403, 'Your role on this site cannot change bookings')\n if (await deps.siteLocked(hostId)) throw new Refusal(423, 'This site is locked and cannot take changes right now')\n return { uid }\n}\n\nfunction answer(res: Parameters<PluginApiHandler>[1], error: unknown): void {\n if (error instanceof Refusal) {\n res.status(error.status).json({ error: error.message })\n return\n }\n console.error('[bookings/manage]', error)\n res.status(500).json({ error: 'That did not go through. Try again.' })\n}\n\nexport function createBookingCheckInHandler(deps: BookingManageDeps): PluginApiHandler {\n return async (req, res) => {\n if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })\n const body = bodyOf(req)\n const hostId = String(body['hostId'] ?? '')\n const bookingId = String(body['bookingId'] ?? '')\n const checkedIn = body['checkedIn'] !== false\n if (!hostId || !bookingId) return res.status(400).json({ error: 'Missing hostId or bookingId' })\n try {\n const { uid } = await authorize(deps, req, hostId)\n const firestore = deps.firestore()\n const ref = firestore.doc(`hosts/${hostId}/bookings/${bookingId}`)\n const result = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n if (!snapshot.exists) throw new Refusal(404, 'Unknown booking')\n const booking = (snapshot.data() ?? {}) as ManagedBooking\n const nowMs = deps.now()\n const refusal = checkInRefusal(booking, checkedIn, nowMs)\n if (refusal) throw new Refusal(409, refusal)\n const already = Number(booking.checkedInAtMs ?? 0) > 0\n // Already where it was asked to be: a retried tap changes nothing.\n if (already === checkedIn) return { checkedInAtMs: already ? Number(booking.checkedInAtMs) : null }\n if (checkedIn) {\n transaction.update(ref, { [CHECKED_IN_AT]: nowMs, [CHECKED_IN_BY]: uid })\n return { checkedInAtMs: nowMs }\n }\n transaction.update(ref, { [CHECKED_IN_AT]: null, [CHECKED_IN_BY]: null })\n return { checkedInAtMs: null }\n })\n return res.status(200).json({ ok: true, ...result })\n } catch (error) {\n return answer(res, error)\n }\n }\n}\n\nexport function createBookingRescheduleHandler(deps: BookingManageDeps): PluginApiHandler {\n return async (req, res) => {\n if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })\n const body = bodyOf(req)\n const hostId = String(body['hostId'] ?? '')\n const bookingId = String(body['bookingId'] ?? '')\n const startsAtMs = Number(body['startsAtMs'])\n if (!hostId || !bookingId) return res.status(400).json({ error: 'Missing hostId or bookingId' })\n if (!Number.isFinite(startsAtMs) || startsAtMs <= 0) return res.status(400).json({ error: 'Pick a new time' })\n try {\n const { uid } = await authorize(deps, req, hostId)\n const firestore = deps.firestore()\n const ref = firestore.doc(`hosts/${hostId}/bookings/${bookingId}`)\n const first = await ref.get()\n if (!first.exists) throw new Refusal(404, 'Unknown booking')\n const serviceId = String(first.get('serviceId') ?? '')\n const serviceSnapshot = serviceId ? await firestore.doc(`hosts/${hostId}/services/${serviceId}`).get() : null\n const service = serviceSnapshot?.exists ? (serviceSnapshot.data() as unknown as HostBookingService) : undefined\n if (!service || serviceSnapshot?.get('deletedAt')) {\n throw new Refusal(409, 'This booking’s service no longer exists, so it cannot move')\n }\n\n const moved = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n if (!snapshot.exists) throw new Refusal(404, 'Unknown booking')\n const booking = (snapshot.data() ?? {}) as ManagedBooking & Record<string, unknown>\n const nowMs = deps.now()\n const refusal = rescheduleRefusal(booking, startsAtMs, nowMs)\n if (refusal) throw new Refusal(409, refusal)\n const previousStartsAtMs = Number(booking.startsAtMs ?? 0)\n // Already at that time: a retried move writes nothing and tells no one twice.\n if (previousStartsAtMs === startsAtMs) return null\n const durationMs = bookingDurationMs(booking, service.durationMinutes)\n // The public booking route's own read: every booking of this service\n // from a day before the new time, so one running into it counts.\n const nearby = await transaction.get(\n firestore\n .collection(`hosts/${hostId}/bookings`)\n .where('serviceId', '==', serviceId)\n .where('startsAtMs', '>=', startsAtMs - 24 * 60 * 60_000)\n .limit(500),\n )\n const booked: BookedInterval[] = nearby.docs\n .filter((doc) => doc.id !== bookingId)\n .filter((doc) => {\n const status = doc.get('status')\n if (status === 'canceled') return false\n return !(status === 'pendingPayment' && Number(doc.get('expiresAtMs') ?? 0) < nowMs)\n })\n .map((doc) => ({ startsAtMs: Number(doc.get('startsAtMs') ?? 0), endsAtMs: Number(doc.get('endsAtMs') ?? 0) }))\n if (!isSlotOpen(service, startsAtMs, booked)) throw new Refusal(409, 'That time is not open. Pick another.')\n const endsAtMs = startsAtMs + durationMs\n transaction.update(ref, {\n startsAtMs,\n endsAtMs,\n rescheduledAtMs: nowMs,\n rescheduledBy: uid,\n rescheduledFromMs: previousStartsAtMs,\n // The reminder is owed again, for the new time.\n reminderSentAt: null,\n })\n return { booking, previousStartsAtMs, endsAtMs }\n })\n\n if (!moved) {\n return res.status(200).json({ ok: true, startsAtMs, endsAtMs: Number(first.get('endsAtMs') ?? 0), notified: false })\n }\n // Keyed by the booking and its new time: a retried move raises it once.\n await deps.raiseEvent?.({\n event: 'booking.rescheduled',\n hostId,\n bookingId,\n booking: {\n ...moved.booking,\n startsAtMs,\n endsAtMs: moved.endsAtMs,\n rescheduledFromMs: moved.previousStartsAtMs,\n },\n })\n const to = String(moved.booking['email'] ?? '').trim()\n let notified = false\n if (to) {\n const timezone = storedBookingTimeZone({ timezone: moved.booking['timezone'] }) ?? (await deps.timeZoneFor(hostId, service))\n try {\n await deps.notifyRescheduled({\n hostId,\n bookingId,\n to,\n name: String(moved.booking['name'] ?? ''),\n serviceName: String(moved.booking['serviceName'] ?? service.name ?? ''),\n when: formatBookingWhen(startsAtMs, timezone),\n previousWhen: formatBookingWhen(moved.previousStartsAtMs, timezone),\n timezone,\n })\n notified = true\n } catch (error) {\n // The booking has moved whatever the mail did; the screen says the guest was not told.\n console.error('[bookings/reschedule] notice failed', hostId, bookingId, error)\n }\n }\n return res.status(200).json({ ok: true, startsAtMs, endsAtMs: moved.endsAtMs, notified })\n } catch (error) {\n return answer(res, error)\n }\n }\n}\n\n/**\n * Canceling a booking that has no money on it (AGL-3643): a free booking, or\n * a paid one already refunded in full. The site's team used to write\n * `status: 'canceled'` straight from the console; a route is where the\n * cancel can be told to the plugins that listen for it (`booking.canceled`),\n * and where the rule that a paid booking cancels through its refund is held\n * rather than trusted to the screen. One transaction: a booking checked in\n * or paid a moment ago is refused, and canceling it twice changes nothing.\n */\nexport function createBookingCancelHandler(deps: BookingManageDeps): PluginApiHandler {\n return async (req, res) => {\n if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })\n const body = bodyOf(req)\n const hostId = String(body['hostId'] ?? '')\n const bookingId = String(body['bookingId'] ?? '')\n if (!hostId || !bookingId) return res.status(400).json({ error: 'Missing hostId or bookingId' })\n try {\n const { uid } = await authorize(deps, req, hostId)\n const firestore = deps.firestore()\n const ref = firestore.doc(`hosts/${hostId}/bookings/${bookingId}`)\n const canceled = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n if (!snapshot.exists) throw new Refusal(404, 'Unknown booking')\n const booking = (snapshot.data() ?? {}) as ManagedBooking & Record<string, unknown>\n const nowMs = deps.now()\n const refusal = cancelRefusal(booking, nowMs)\n if (refusal) throw new Refusal(409, refusal)\n // Already canceled: a retried tap writes nothing and tells no one twice.\n if (bookingState(booking, nowMs) === 'canceled') return null\n transaction.update(ref, { status: 'canceled', canceledAtMs: nowMs, canceledBy: uid })\n return { ...booking, status: 'canceled', canceledAtMs: nowMs }\n })\n if (canceled) {\n await deps.raiseEvent?.({ event: 'booking.canceled', hostId, bookingId, booking: canceled })\n }\n return res.status(200).json({ ok: true, canceled: true })\n } catch (error) {\n return answer(res, error)\n }\n }\n}\n\n/** The email that tells a guest their booking moved, as the confirmation is sent. */\nexport async function sendRescheduledNotice(notice: RescheduleNotice): Promise<void> {\n if (!isEmailConfigured()) throw new Error('Email is not configured')\n const firestore = firebaseAdmin.app().firestore()\n const fallbackText =\n `Hi ${notice.name},\\n\\nYour booking for \"${notice.serviceName}\" is now on ` +\n `${notice.when} (${notice.timezone}). It was on ${notice.previousWhen}.\\n\\n` +\n `Reference: ${notice.bookingId}`\n const designed = await renderHostEmailWithTokens(firestore, notice.hostId, 'booking-rescheduled', {\n name: notice.name,\n 'service.name': notice.serviceName,\n when: notice.when,\n previousWhen: notice.previousWhen,\n timezone: notice.timezone,\n 'booking.ref': notice.bookingId,\n })\n const branding = resolveBrandingProfile((await getOrgForHost(notice.hostId).catch(() => null))?.org as never)\n await sendEmail({\n to: notice.to,\n subject: designed?.subject ?? `New time for ${notice.serviceName}`,\n text: designed?.text || fallbackText,\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(notice.hostId),\n audience: 'tenant',\n context: 'booking rescheduled',\n owedFor: 'booking',\n })\n await meterHostEmail(notice.hostId)\n}\n\n/** The production wiring: the Admin SDK, the site lockdown verdict and the real send. */\nexport function bookingManageDeps(): BookingManageDeps {\n return {\n firestore: () => firebaseAdmin.app().firestore() as unknown as ManageFirestore,\n verifyIdToken: (token) => firebaseAdmin.app().auth().verifyIdToken(token),\n siteLocked: async (hostId) => Boolean(await getSiteLockdown(hostId)),\n timeZoneFor: async (hostId, service) => {\n const firestore = firebaseAdmin.app().firestore()\n const [host, owner] = await Promise.all([\n firestore.collection('hosts').doc(hostId).get(),\n getOrgForHost(hostId).catch(() => null),\n ])\n return bookingTimeZone({\n service: service ?? null,\n host: host.data() as { timeZone?: string } | undefined,\n org: owner?.org as { timeZone?: string } | null | undefined,\n })\n },\n notifyRescheduled: sendRescheduledNotice,\n now: () => Date.now(),\n raiseEvent: (input) => raiseBookingEvent(firebaseAdmin.app().firestore(), input),\n }\n}\n"],"names":["hostRoleCanWrite","isEmailConfigured","sendEmail","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","renderHostEmailWithTokens","getSiteLockdown","resolveBrandingProfile","bookingDurationMs","bookingState","cancelRefusal","checkInRefusal","rescheduleRefusal","isSlotOpen","bookingTimeZone","formatBookingWhen","storedBookingTimeZone","isRefusedIdToken","raiseBookingEvent","CHECKED_IN_AT","CHECKED_IN_BY","Refusal","Error","status","message","bearerOf","req","header","String","headers","startsWith","slice","length","bodyOf","body","JSON","parse","authorize","deps","hostId","host","token","uid","verifyIdToken","error","firestore","doc","get","exists","role","siteLocked","answer","res","json","console","createBookingCheckInHandler","method","bookingId","checkedIn","ref","result","runTransaction","transaction","snapshot","booking","data","nowMs","now","refusal","already","Number","checkedInAtMs","update","ok","createBookingRescheduleHandler","startsAtMs","isFinite","first","moved","serviceId","serviceSnapshot","service","undefined","previousStartsAtMs","durationMs","durationMinutes","nearby","collection","where","limit","booked","docs","filter","id","map","endsAtMs","rescheduledAtMs","rescheduledBy","rescheduledFromMs","reminderSentAt","notified","raiseEvent","event","to","trim","timezone","timeZoneFor","notifyRescheduled","name","serviceName","when","previousWhen","createBookingCancelHandler","canceled","canceledAtMs","canceledBy","sendRescheduledNotice","notice","app","fallbackText","designed","branding","catch","org","subject","text","html","fromName","sendingIdentity","audience","context","owedFor","bookingManageDeps","auth","Boolean","owner","Promise","all","Date","input"],"mappings":"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"}
@@ -21,6 +21,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
21
21
  import { reverseOrderConversion } from "@aglyn/aglyn/plugin-manager/plugin-conversion-credit";
22
22
  import { createHash } from "crypto";
23
23
  import { apiIdempotencyExpiry } from "@aglyn/aglyn/app-utils/api-idempotency";
24
+ import { raiseBookingEvent } from "./booking-events.js";
24
25
  /**
25
26
  * Refund a paid booking (AGL-2315), full or partial, site-admin only.
26
27
  *
@@ -103,7 +104,7 @@ import { apiIdempotencyExpiry } from "@aglyn/aglyn/app-utils/api-idempotency";
103
104
  });
104
105
  }
105
106
  try {
106
- var _hostSnapshot_get, _bookingSnapshot_get, _bookingSnapshot_get1;
107
+ var _hostSnapshot_get, _bookingSnapshot_data, _bookingSnapshot_get, _bookingSnapshot_get1;
107
108
  const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
108
109
  const firestore = firebaseAdmin.app().firestore();
109
110
  const hostRef = firestore.collection('hosts').doc(hostId);
@@ -128,6 +129,8 @@ import { apiIdempotencyExpiry } from "@aglyn/aglyn/app-utils/api-idempotency";
128
129
  error: 'Unknown booking'
129
130
  });
130
131
  }
132
+ // As it stood before this refund, for the event a full refund raises.
133
+ const bookingBefore = (_bookingSnapshot_data = bookingSnapshot.data()) != null ? _bookingSnapshot_data : {};
131
134
  const paymentIntentId = String((_bookingSnapshot_get = bookingSnapshot.get('paymentIntentId')) != null ? _bookingSnapshot_get : '');
132
135
  const paidCents = Math.max(0, Math.round(Number((_bookingSnapshot_get1 = bookingSnapshot.get('paidAmountCents')) != null ? _bookingSnapshot_get1 : 0)));
133
136
  if (!paidCents) {
@@ -258,6 +261,19 @@ import { apiIdempotencyExpiry } from "@aglyn/aglyn/app-utils/api-idempotency";
258
261
  } : {}), {
259
262
  merge: true
260
263
  }).catch(()=>undefined);
264
+ // The full refund is what cancels a paid booking (AGL-3643). Keyed by the
265
+ // booking, so a booking canceled before is not announced again.
266
+ if (fullyRefunded && bookingBefore['status'] !== 'canceled') {
267
+ await raiseBookingEvent(firestore, {
268
+ event: 'booking.canceled',
269
+ hostId,
270
+ bookingId,
271
+ booking: _extends({}, bookingBefore, {
272
+ refundedCents: totalRefunded,
273
+ status: 'canceled'
274
+ })
275
+ });
276
+ }
261
277
  const payload = {
262
278
  refundedCents: refundCents,
263
279
  totalRefundedCents: totalRefunded,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/server/refund.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\n// Leaf import, not the barrel: the specs in this library mock\n// `@aglyn/tenant-data-admin` wholesale, and a permissive stub would turn a\n// reversal that never happened green.\nimport { reverseOrderConversion } from '@aglyn/aglyn/plugin-manager/plugin-conversion-credit'\nimport { createHash } from 'crypto'\nimport { apiIdempotencyExpiry } from '@aglyn/aglyn/app-utils/api-idempotency'\n\n/**\n * Refund a paid booking (AGL-2315), full or partial, site-admin only.\n *\n * This route exists BECAUSE of the destination charge, not alongside it. While\n * a paid booking settled in Aglyn's own balance there was nothing here to get\n * wrong: cancelling a booking refunded the guest nothing at all, and any\n * refund issued by hand simply gave back money Aglyn was already holding. Once\n * the charge goes to the merchant's connected account that stops being true in\n * two directions at once, and both are money:\n *\n * - **The guest.** Cancelling a paid booking wrote `status: 'canceled'` from\n * the console and moved no money. The slot reopened, the appointment was\n * gone, and the guest had paid for it. That was survivable only because the\n * platform still held the funds and could be made to give them back; with\n * the money at the merchant it is a silent, unrecoverable overcharge.\n * - **Aglyn.** A refund on a destination charge that does NOT set\n * `reverse_transfer` is paid entirely out of the PLATFORM's balance while\n * the merchant keeps the transfer in full. The merchant cancels, the guest\n * is made whole, and Aglyn funds the whole concession out of a cut that was\n * at most 5% of it. This repo has shipped that exact bug once already, on a\n * marketplace partial refund.\n *\n * So both switches are sent, and they are two different decisions:\n *\n * - `reverse_transfer` pulls the refunded share back OUT of the merchant's\n * account. Stripe prorates it against a partial refund, so a $30 refund on\n * a $75 booking claws back $30 of the merchant's $71.25, not all of it.\n * - `refund_application_fee` returns Aglyn's commission on the refunded\n * share. A refunded appointment earned the platform nothing, and this\n * matches `commerce/refund.ts`, which has sent both since AGL-287. The\n * DISPUTE path deliberately sends only the reversal — that asymmetry is\n * AGL-1809's and is not copied here.\n *\n * Two SEPARATE controls guard the money, the AGL-1696 lesson from the order\n * refund, and conflating them is how that one originally went wrong:\n *\n * - The idempotency key stops a DUPLICATE refund — one attempt sent twice\n * because the response was lost or the admin double-clicked. It is minted\n * per attempt by the console and deliberately not derived from the booking\n * or the amount: two $10 refunds on a $75 booking are two real refunds.\n * - The cap stops an OVER-refund — several partials summing past what was\n * captured, including two admins refunding at once, where the attempts are\n * genuinely distinct and no key can help. That needs the counter read and\n * written inside one transaction, BEFORE Stripe is called, so a lost\n * response leaves the amount counted and the retry refunds less, never\n * more.\n */\nexport const bookingRefundHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n if (!process.env.STRIPE_SECRET_KEY) {\n return res.status(501).json({ error: 'Payments are not configured.' })\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const bookingId = String(body.bookingId ?? '')\n // `null`/absent means \"everything still refundable\"; a number is a partial.\n const amountCents = body.amountCents == null ? null : Number(body.amountCents)\n // Node lowercases incoming headers, but read both spellings — the plugin API\n // request type makes no promise about casing.\n const idempotencyKey = String(\n req.headers['idempotency-key'] ?? req.headers['Idempotency-Key'] ?? '',\n )\n .trim()\n .slice(0, 200)\n if (!hostId || !bookingId) {\n return res.status(400).json({ error: 'Missing hostId or bookingId' })\n }\n if (amountCents != null && !(Number.isFinite(amountCents) && amountCents > 0)) {\n return res.status(400).json({ error: 'Invalid refund amount' })\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n // Site ADMIN, not merely a member: this moves money. The same gate\n // `commerce/refund.ts` applies.\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin') {\n return res.status(403).json({ error: 'Refunds require a site admin' })\n }\n const bookingRef = hostRef.collection('bookings').doc(bookingId)\n const bookingSnapshot = await bookingRef.get()\n if (!bookingSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown booking' })\n }\n const paymentIntentId = String(bookingSnapshot.get('paymentIntentId') ?? '')\n const paidCents = Math.max(\n 0,\n Math.round(Number(bookingSnapshot.get('paidAmountCents') ?? 0)),\n )\n if (!paidCents) {\n return res.status(409).json({ error: 'This booking was never paid' })\n }\n if (!paymentIntentId) {\n // A booking paid BEFORE the webhook began recording the PaymentIntent\n // (AGL-2315). Refundable, but only by hand — and saying so beats a 500\n // an admin cannot act on.\n return res.status(409).json({\n error:\n 'This booking was paid before refunds were supported here. ' +\n 'Refund it in the Stripe dashboard, and tick “Reverse transfer” ' +\n 'so the amount comes back from the merchant account.',\n })\n }\n\n // Replay a settled attempt before anything else can reject it. This read\n // is only a short-circuit, never the dedupe primitive — the atomic\n // `create()` below is.\n const claimRef = idempotencyKey\n ? firestore.collection('apiIdempotency').doc(\n createHash('sha256')\n // Scoped by the BOOKING, so a client that reused one key across\n // two bookings cannot dedupe two legitimately distinct refunds.\n // NOT by the amount: that would swallow a real second partial.\n .update(`booking-refund:${hostId}:${bookingId}:${idempotencyKey}`)\n .digest('hex'),\n )\n : null\n if (claimRef) {\n const prior = await claimRef.get()\n const priorResponse = prior.get('response')\n if (priorResponse) {\n return res\n .status(Number(prior.get('responseStatus') ?? 200))\n .json(priorResponse)\n }\n try {\n // The claim's shape is every claim's (`claimApiIdempotency`): its\n // kind, scope and org name it on the staff claims list, `createdAtMs`\n // ages it there, and `expiresAt` lets the TTL policy reap it — a\n // claim stranded by a killed process otherwise holds its key forever.\n await claimRef.create({\n kind: 'booking-refund',\n scopeId: hostId,\n orgId: String(hostSnapshot.get('orgId') ?? '') || null,\n bookingId,\n status: 'pending',\n createdAtMs: Date.now(),\n expiresAt: apiIdempotencyExpiry(),\n })\n } catch {\n // Another attempt with this key is in flight.\n return res\n .status(409)\n .json({ error: 'This refund is already being processed' })\n }\n }\n\n // RESERVE. Read and write the counter in ONE transaction so two concurrent\n // refunds cannot both see the same `refundedCents` and each send a full\n // amount to Stripe — the failure the order refund shipped with.\n let refundCents = 0\n let alreadyRefunded = 0\n await firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(bookingRef)\n alreadyRefunded = Math.max(\n 0,\n Math.round(Number(fresh.get('refundedCents') ?? 0)),\n )\n const remaining = paidCents - alreadyRefunded\n refundCents =\n amountCents == null\n ? remaining\n : Math.min(Math.round(amountCents), remaining)\n if (!(refundCents > 0)) {\n refundCents = 0\n return\n }\n transaction.set(\n bookingRef,\n { refundedCents: alreadyRefunded + refundCents },\n { merge: true },\n )\n })\n if (!(refundCents > 0)) {\n await claimRef?.delete().catch(() => undefined)\n return res.status(400).json({ error: 'Nothing left to refund' })\n }\n\n const params = new URLSearchParams({\n payment_intent: paymentIntentId,\n amount: String(refundCents),\n // THE SELLER SHARE COMES BACK. Without this the merchant keeps their\n // transfer in full and Aglyn funds the entire refund.\n reverse_transfer: 'true',\n // ...and so does Aglyn's commission on the refunded share. A refunded\n // appointment earned the platform nothing.\n refund_application_fee: 'true',\n })\n const response = await fetch('https://api.stripe.com/v1/refunds', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n ...(claimRef ? { 'Idempotency-Key': claimRef.id } : {}),\n },\n body: params.toString(),\n })\n const refund = await response.json().catch(() => ({}) as any)\n if (!response.ok) {\n console.error('Stripe booking refund error', (refund as any)?.error)\n // Stripe said no, so we KNOW no money moved: give the reservation back\n // and let the same attempt be retried.\n await firestore\n .runTransaction(async (transaction) => {\n const current = Number(\n (await transaction.get(bookingRef)).get('refundedCents') ?? 0,\n )\n transaction.set(\n bookingRef,\n { refundedCents: Math.max(0, current - refundCents) },\n { merge: true },\n )\n })\n .catch(() => undefined)\n await claimRef?.delete().catch(() => undefined)\n return res.status(502).json({ error: 'Refund failed' })\n }\n\n // A FULL refund ends the appointment; a partial leaves it standing. The\n // guest who got half their money back still has the slot, and moving the\n // booking off `confirmed` would reopen a time the merchant is still\n // committed to.\n const totalRefunded = alreadyRefunded + refundCents\n const fullyRefunded = totalRefunded >= paidCents\n await bookingRef\n .set(\n {\n refundedCents: totalRefunded,\n ...(fullyRefunded ? { status: 'canceled' } : {}),\n },\n { merge: true },\n )\n .catch(() => undefined)\n\n const payload = {\n refundedCents: refundCents,\n totalRefundedCents: totalRefunded,\n fullyRefunded,\n }\n await claimRef\n ?.set(\n {\n status: 'done',\n responseStatus: 200,\n response: payload,\n settledAtMs: Date.now(),\n },\n { merge: true },\n )\n .catch(() => undefined)\n /*\n * The campaign's side of the ledger.\n *\n * A paid booking is credited to the campaign that led to it — the sale\n * announces itself through `upsertHostContact` with a `purchaseCents` and\n * this booking's id, exactly as a store order does — so a refunded one\n * has to stop counting. Without this, a booking site's campaign revenue\n * could only ever rise.\n *\n * Keyed by the BOOKING id, which is what the credit was filed under, so\n * this needs no email and works for a guest who never became a contact.\n * Recorded beside the credit rather than subtracted from it, and swallowed\n * whole: the money has already left the merchant's account and the booking\n * already records it, so nothing here may fail a refund.\n */\n await reverseOrderConversion({\n hostId,\n orderId: bookingId,\n amountCents: refundCents,\n closedTheOrder: fullyRefunded,\n })\n return res.status(200).json(payload)\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Refund failed' })\n }\n}\n"],"names":["firebaseAdmin","reverseOrderConversion","createHash","apiIdempotencyExpiry","bookingRefundHandler","req","res","body","method","status","json","error","process","env","STRIPE_SECRET_KEY","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","hostId","bookingId","amountCents","Number","idempotencyKey","trim","isFinite","hostSnapshot","bookingSnapshot","decoded","app","auth","verifyIdToken","firestore","hostRef","collection","doc","get","exists","memberRole","uid","bookingRef","paymentIntentId","paidCents","Math","max","round","claimRef","update","digest","prior","priorResponse","create","kind","scopeId","orgId","createdAtMs","Date","now","expiresAt","refundCents","alreadyRefunded","runTransaction","transaction","fresh","remaining","min","set","refundedCents","merge","delete","catch","params","URLSearchParams","payment_intent","amount","reverse_transfer","refund_application_fee","response","fetch","Authorization","id","toString","refund","ok","console","current","totalRefunded","fullyRefunded","payload","totalRefundedCents","responseStatus","settledAtMs","orderId","closedTheOrder"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/server/refund.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\n// Leaf import, not the barrel: the specs in this library mock\n// `@aglyn/tenant-data-admin` wholesale, and a permissive stub would turn a\n// reversal that never happened green.\nimport { reverseOrderConversion } from '@aglyn/aglyn/plugin-manager/plugin-conversion-credit'\nimport { createHash } from 'crypto'\nimport { apiIdempotencyExpiry } from '@aglyn/aglyn/app-utils/api-idempotency'\nimport { raiseBookingEvent } from './booking-events'\n\n/**\n * Refund a paid booking (AGL-2315), full or partial, site-admin only.\n *\n * This route exists BECAUSE of the destination charge, not alongside it. While\n * a paid booking settled in Aglyn's own balance there was nothing here to get\n * wrong: cancelling a booking refunded the guest nothing at all, and any\n * refund issued by hand simply gave back money Aglyn was already holding. Once\n * the charge goes to the merchant's connected account that stops being true in\n * two directions at once, and both are money:\n *\n * - **The guest.** Cancelling a paid booking wrote `status: 'canceled'` from\n * the console and moved no money. The slot reopened, the appointment was\n * gone, and the guest had paid for it. That was survivable only because the\n * platform still held the funds and could be made to give them back; with\n * the money at the merchant it is a silent, unrecoverable overcharge.\n * - **Aglyn.** A refund on a destination charge that does NOT set\n * `reverse_transfer` is paid entirely out of the PLATFORM's balance while\n * the merchant keeps the transfer in full. The merchant cancels, the guest\n * is made whole, and Aglyn funds the whole concession out of a cut that was\n * at most 5% of it. This repo has shipped that exact bug once already, on a\n * marketplace partial refund.\n *\n * So both switches are sent, and they are two different decisions:\n *\n * - `reverse_transfer` pulls the refunded share back OUT of the merchant's\n * account. Stripe prorates it against a partial refund, so a $30 refund on\n * a $75 booking claws back $30 of the merchant's $71.25, not all of it.\n * - `refund_application_fee` returns Aglyn's commission on the refunded\n * share. A refunded appointment earned the platform nothing, and this\n * matches `commerce/refund.ts`, which has sent both since AGL-287. The\n * DISPUTE path deliberately sends only the reversal — that asymmetry is\n * AGL-1809's and is not copied here.\n *\n * Two SEPARATE controls guard the money, the AGL-1696 lesson from the order\n * refund, and conflating them is how that one originally went wrong:\n *\n * - The idempotency key stops a DUPLICATE refund — one attempt sent twice\n * because the response was lost or the admin double-clicked. It is minted\n * per attempt by the console and deliberately not derived from the booking\n * or the amount: two $10 refunds on a $75 booking are two real refunds.\n * - The cap stops an OVER-refund — several partials summing past what was\n * captured, including two admins refunding at once, where the attempts are\n * genuinely distinct and no key can help. That needs the counter read and\n * written inside one transaction, BEFORE Stripe is called, so a lost\n * response leaves the amount counted and the retry refunds less, never\n * more.\n */\nexport const bookingRefundHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n if (!process.env.STRIPE_SECRET_KEY) {\n return res.status(501).json({ error: 'Payments are not configured.' })\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const bookingId = String(body.bookingId ?? '')\n // `null`/absent means \"everything still refundable\"; a number is a partial.\n const amountCents = body.amountCents == null ? null : Number(body.amountCents)\n // Node lowercases incoming headers, but read both spellings — the plugin API\n // request type makes no promise about casing.\n const idempotencyKey = String(\n req.headers['idempotency-key'] ?? req.headers['Idempotency-Key'] ?? '',\n )\n .trim()\n .slice(0, 200)\n if (!hostId || !bookingId) {\n return res.status(400).json({ error: 'Missing hostId or bookingId' })\n }\n if (amountCents != null && !(Number.isFinite(amountCents) && amountCents > 0)) {\n return res.status(400).json({ error: 'Invalid refund amount' })\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n // Site ADMIN, not merely a member: this moves money. The same gate\n // `commerce/refund.ts` applies.\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin') {\n return res.status(403).json({ error: 'Refunds require a site admin' })\n }\n const bookingRef = hostRef.collection('bookings').doc(bookingId)\n const bookingSnapshot = await bookingRef.get()\n if (!bookingSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown booking' })\n }\n // As it stood before this refund, for the event a full refund raises.\n const bookingBefore = (bookingSnapshot.data() ?? {}) as Record<string, unknown>\n const paymentIntentId = String(bookingSnapshot.get('paymentIntentId') ?? '')\n const paidCents = Math.max(\n 0,\n Math.round(Number(bookingSnapshot.get('paidAmountCents') ?? 0)),\n )\n if (!paidCents) {\n return res.status(409).json({ error: 'This booking was never paid' })\n }\n if (!paymentIntentId) {\n // A booking paid BEFORE the webhook began recording the PaymentIntent\n // (AGL-2315). Refundable, but only by hand — and saying so beats a 500\n // an admin cannot act on.\n return res.status(409).json({\n error:\n 'This booking was paid before refunds were supported here. ' +\n 'Refund it in the Stripe dashboard, and tick “Reverse transfer” ' +\n 'so the amount comes back from the merchant account.',\n })\n }\n\n // Replay a settled attempt before anything else can reject it. This read\n // is only a short-circuit, never the dedupe primitive — the atomic\n // `create()` below is.\n const claimRef = idempotencyKey\n ? firestore.collection('apiIdempotency').doc(\n createHash('sha256')\n // Scoped by the BOOKING, so a client that reused one key across\n // two bookings cannot dedupe two legitimately distinct refunds.\n // NOT by the amount: that would swallow a real second partial.\n .update(`booking-refund:${hostId}:${bookingId}:${idempotencyKey}`)\n .digest('hex'),\n )\n : null\n if (claimRef) {\n const prior = await claimRef.get()\n const priorResponse = prior.get('response')\n if (priorResponse) {\n return res\n .status(Number(prior.get('responseStatus') ?? 200))\n .json(priorResponse)\n }\n try {\n // The claim's shape is every claim's (`claimApiIdempotency`): its\n // kind, scope and org name it on the staff claims list, `createdAtMs`\n // ages it there, and `expiresAt` lets the TTL policy reap it — a\n // claim stranded by a killed process otherwise holds its key forever.\n await claimRef.create({\n kind: 'booking-refund',\n scopeId: hostId,\n orgId: String(hostSnapshot.get('orgId') ?? '') || null,\n bookingId,\n status: 'pending',\n createdAtMs: Date.now(),\n expiresAt: apiIdempotencyExpiry(),\n })\n } catch {\n // Another attempt with this key is in flight.\n return res\n .status(409)\n .json({ error: 'This refund is already being processed' })\n }\n }\n\n // RESERVE. Read and write the counter in ONE transaction so two concurrent\n // refunds cannot both see the same `refundedCents` and each send a full\n // amount to Stripe — the failure the order refund shipped with.\n let refundCents = 0\n let alreadyRefunded = 0\n await firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(bookingRef)\n alreadyRefunded = Math.max(\n 0,\n Math.round(Number(fresh.get('refundedCents') ?? 0)),\n )\n const remaining = paidCents - alreadyRefunded\n refundCents =\n amountCents == null\n ? remaining\n : Math.min(Math.round(amountCents), remaining)\n if (!(refundCents > 0)) {\n refundCents = 0\n return\n }\n transaction.set(\n bookingRef,\n { refundedCents: alreadyRefunded + refundCents },\n { merge: true },\n )\n })\n if (!(refundCents > 0)) {\n await claimRef?.delete().catch(() => undefined)\n return res.status(400).json({ error: 'Nothing left to refund' })\n }\n\n const params = new URLSearchParams({\n payment_intent: paymentIntentId,\n amount: String(refundCents),\n // THE SELLER SHARE COMES BACK. Without this the merchant keeps their\n // transfer in full and Aglyn funds the entire refund.\n reverse_transfer: 'true',\n // ...and so does Aglyn's commission on the refunded share. A refunded\n // appointment earned the platform nothing.\n refund_application_fee: 'true',\n })\n const response = await fetch('https://api.stripe.com/v1/refunds', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n ...(claimRef ? { 'Idempotency-Key': claimRef.id } : {}),\n },\n body: params.toString(),\n })\n const refund = await response.json().catch(() => ({}) as any)\n if (!response.ok) {\n console.error('Stripe booking refund error', (refund as any)?.error)\n // Stripe said no, so we KNOW no money moved: give the reservation back\n // and let the same attempt be retried.\n await firestore\n .runTransaction(async (transaction) => {\n const current = Number(\n (await transaction.get(bookingRef)).get('refundedCents') ?? 0,\n )\n transaction.set(\n bookingRef,\n { refundedCents: Math.max(0, current - refundCents) },\n { merge: true },\n )\n })\n .catch(() => undefined)\n await claimRef?.delete().catch(() => undefined)\n return res.status(502).json({ error: 'Refund failed' })\n }\n\n // A FULL refund ends the appointment; a partial leaves it standing. The\n // guest who got half their money back still has the slot, and moving the\n // booking off `confirmed` would reopen a time the merchant is still\n // committed to.\n const totalRefunded = alreadyRefunded + refundCents\n const fullyRefunded = totalRefunded >= paidCents\n await bookingRef\n .set(\n {\n refundedCents: totalRefunded,\n ...(fullyRefunded ? { status: 'canceled' } : {}),\n },\n { merge: true },\n )\n .catch(() => undefined)\n // The full refund is what cancels a paid booking (AGL-3643). Keyed by the\n // booking, so a booking canceled before is not announced again.\n if (fullyRefunded && bookingBefore['status'] !== 'canceled') {\n await raiseBookingEvent(firestore, {\n event: 'booking.canceled',\n hostId,\n bookingId,\n booking: {\n ...bookingBefore,\n refundedCents: totalRefunded,\n status: 'canceled',\n },\n })\n }\n\n const payload = {\n refundedCents: refundCents,\n totalRefundedCents: totalRefunded,\n fullyRefunded,\n }\n await claimRef\n ?.set(\n {\n status: 'done',\n responseStatus: 200,\n response: payload,\n settledAtMs: Date.now(),\n },\n { merge: true },\n )\n .catch(() => undefined)\n /*\n * The campaign's side of the ledger.\n *\n * A paid booking is credited to the campaign that led to it — the sale\n * announces itself through `upsertHostContact` with a `purchaseCents` and\n * this booking's id, exactly as a store order does — so a refunded one\n * has to stop counting. Without this, a booking site's campaign revenue\n * could only ever rise.\n *\n * Keyed by the BOOKING id, which is what the credit was filed under, so\n * this needs no email and works for a guest who never became a contact.\n * Recorded beside the credit rather than subtracted from it, and swallowed\n * whole: the money has already left the merchant's account and the booking\n * already records it, so nothing here may fail a refund.\n */\n await reverseOrderConversion({\n hostId,\n orderId: bookingId,\n amountCents: refundCents,\n closedTheOrder: fullyRefunded,\n })\n return res.status(200).json(payload)\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Refund failed' })\n }\n}\n"],"names":["firebaseAdmin","reverseOrderConversion","createHash","apiIdempotencyExpiry","raiseBookingEvent","bookingRefundHandler","req","res","body","method","status","json","error","process","env","STRIPE_SECRET_KEY","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","hostId","bookingId","amountCents","Number","idempotencyKey","trim","isFinite","hostSnapshot","bookingSnapshot","decoded","app","auth","verifyIdToken","firestore","hostRef","collection","doc","get","exists","memberRole","uid","bookingRef","bookingBefore","data","paymentIntentId","paidCents","Math","max","round","claimRef","update","digest","prior","priorResponse","create","kind","scopeId","orgId","createdAtMs","Date","now","expiresAt","refundCents","alreadyRefunded","runTransaction","transaction","fresh","remaining","min","set","refundedCents","merge","delete","catch","params","URLSearchParams","payment_intent","amount","reverse_transfer","refund_application_fee","response","fetch","Authorization","id","toString","refund","ok","console","current","totalRefunded","fullyRefunded","event","booking","payload","totalRefundedCents","responseStatus","settledAtMs","orderId","closedTheOrder"],"mappings":"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"}
package/src/lib/server.js CHANGED
@@ -34,8 +34,9 @@ import { registerBookingFigureReader } from "./server/booking-figures.js";
34
34
  import { registerBookingServiceAiCapability } from "./server/booking-service-ai-capability.js";
35
35
  import { registerBookingServiceDraftWriter } from "./server/booking-service-drafts.js";
36
36
  import { bookingRefundHandler } from "./server/refund.js";
37
+ import { stageBookingEvent } from "./server/booking-events.js";
37
38
  import { bookingInPersonPaymentHandler } from "./server/in-person-payment.js";
38
- import { bookingManageDeps, createBookingCheckInHandler, createBookingRescheduleHandler } from "./server/booking-manage.js";
39
+ import { bookingManageDeps, createBookingCheckInHandler, createBookingCancelHandler, createBookingRescheduleHandler } from "./server/booking-manage.js";
39
40
  // The booking's way back to the record it was booked from (AGL-2660): the
40
41
  // reference a booking link carried, and the meeting a free booking files on
41
42
  // landing.
@@ -526,7 +527,7 @@ export const slotsHandler = async (req, res)=>{
526
527
  });
527
528
  }
528
529
  const bookingRef = bookingsRef.doc();
529
- transaction.set(bookingRef, _extends({
530
+ const record = _extends({
530
531
  serviceId,
531
532
  serviceName: (_service_name = service.name) != null ? _service_name : '',
532
533
  name,
@@ -544,9 +545,24 @@ export const slotsHandler = async (req, res)=>{
544
545
  expiresAtMs: Date.now() + 15 * 60000
545
546
  }, crmRef ? {
546
547
  crmRef: formatBookingRecordRef(crmRef)
547
- } : {}, {
548
+ } : {});
549
+ transaction.set(bookingRef, _extends({}, record, {
548
550
  createdAt: FieldValue.serverTimestamp()
549
551
  }));
552
+ // A free booking is confirmed as it is made (AGL-3643); a paid one
553
+ // raises `booking.created` from the payment webhook instead.
554
+ if (!paid) {
555
+ const nowMs = Date.now();
556
+ stageBookingEvent(transaction, firestore, {
557
+ event: 'booking.created',
558
+ hostId,
559
+ bookingId: bookingRef.id,
560
+ booking: _extends({}, record, {
561
+ createdAt: nowMs
562
+ }),
563
+ nowMs
564
+ });
565
+ }
550
566
  return bookingRef.id;
551
567
  });
552
568
  /*
@@ -1064,6 +1080,9 @@ export const slotsHandler = async (req, res)=>{
1064
1080
  // team in the native Aglyn app: member routes, one transaction each.
1065
1081
  registerPluginApiRoute('bookings/check-in', createBookingCheckInHandler(bookingManageDeps()));
1066
1082
  registerPluginApiRoute('bookings/reschedule', createBookingRescheduleHandler(bookingManageDeps()));
1083
+ // Canceling a booking with no money on it (AGL-3643): the console's cancel
1084
+ // for a free booking, so the cancel reaches the plugins listening for it.
1085
+ registerPluginApiRoute('bookings/cancel', createBookingCancelHandler(bookingManageDeps()));
1067
1086
  // The merchant-side `purchase` lookup (AGL-2481). A public, unauthenticated
1068
1087
  // read like `bookings/slots`, authorised by the unguessable Stripe session
1069
1088
  // id and answering with a projection that carries no guest identity.