@aglyn/plugins-bookings 1.0.0-beta.230 → 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.
- package/README.md +3 -1
- package/package.json +11 -11
- package/src/lib/components/bookings-console-page.js +30 -4
- package/src/lib/components/bookings-console-page.js.map +1 -1
- package/src/lib/declarations.console-server.d.ts +3 -0
- package/src/lib/declarations.console-server.js +20 -0
- package/src/lib/declarations.console-server.js.map +1 -1
- package/src/lib/model/booking-events.d.ts +52 -0
- package/src/lib/model/booking-events.js +53 -0
- package/src/lib/model/booking-events.js.map +1 -0
- package/src/lib/model/booking-in-person.d.ts +72 -0
- package/src/lib/model/booking-in-person.js +71 -0
- package/src/lib/model/booking-in-person.js.map +1 -0
- package/src/lib/model/booking-manage.d.ts +73 -0
- package/src/lib/model/booking-manage.js +104 -0
- package/src/lib/model/booking-manage.js.map +1 -0
- package/src/lib/model/booking-view.d.ts +63 -0
- package/src/lib/model/booking-view.js +70 -0
- package/src/lib/model/booking-view.js.map +1 -0
- package/src/lib/server/api-v1/bookings.d.ts +33 -0
- package/src/lib/server/api-v1/bookings.js +103 -0
- package/src/lib/server/api-v1/bookings.js.map +1 -0
- package/src/lib/server/api-v1/openapi.d.ts +22 -0
- package/src/lib/server/api-v1/openapi.js +107 -0
- package/src/lib/server/api-v1/openapi.js.map +1 -0
- package/src/lib/server/billing-webhook.js +17 -0
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/booking-events.d.ts +40 -0
- package/src/lib/server/booking-events.js +50 -0
- package/src/lib/server/booking-events.js.map +1 -0
- package/src/lib/server/booking-manage.d.ts +93 -0
- package/src/lib/server/booking-manage.js +380 -0
- package/src/lib/server/booking-manage.js.map +1 -0
- package/src/lib/server/in-person-payment.d.ts +20 -0
- package/src/lib/server/in-person-payment.js +469 -0
- package/src/lib/server/in-person-payment.js.map +1 -0
- package/src/lib/server/refund.js +17 -1
- package/src/lib/server/refund.js.map +1 -1
- package/src/lib/server.js +30 -2
- package/src/lib/server.js.map +1 -1
- package/src/lib/tenant-emails.js +59 -0
- package/src/lib/tenant-emails.js.map +1 -1
|
@@ -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,6 +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";
|
|
38
|
+
import { bookingInPersonPaymentHandler } from "./server/in-person-payment.js";
|
|
39
|
+
import { bookingManageDeps, createBookingCheckInHandler, createBookingCancelHandler, createBookingRescheduleHandler } from "./server/booking-manage.js";
|
|
37
40
|
// The booking's way back to the record it was booked from (AGL-2660): the
|
|
38
41
|
// reference a booking link carried, and the meeting a free booking files on
|
|
39
42
|
// landing.
|
|
@@ -524,7 +527,7 @@ export const slotsHandler = async (req, res)=>{
|
|
|
524
527
|
});
|
|
525
528
|
}
|
|
526
529
|
const bookingRef = bookingsRef.doc();
|
|
527
|
-
|
|
530
|
+
const record = _extends({
|
|
528
531
|
serviceId,
|
|
529
532
|
serviceName: (_service_name = service.name) != null ? _service_name : '',
|
|
530
533
|
name,
|
|
@@ -542,9 +545,24 @@ export const slotsHandler = async (req, res)=>{
|
|
|
542
545
|
expiresAtMs: Date.now() + 15 * 60000
|
|
543
546
|
}, crmRef ? {
|
|
544
547
|
crmRef: formatBookingRecordRef(crmRef)
|
|
545
|
-
} : {}
|
|
548
|
+
} : {});
|
|
549
|
+
transaction.set(bookingRef, _extends({}, record, {
|
|
546
550
|
createdAt: FieldValue.serverTimestamp()
|
|
547
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
|
+
}
|
|
548
566
|
return bookingRef.id;
|
|
549
567
|
});
|
|
550
568
|
/*
|
|
@@ -1055,6 +1073,16 @@ export const slotsHandler = async (req, res)=>{
|
|
|
1055
1073
|
// Refunding a paid booking (AGL-2315). Console-side, because it is
|
|
1056
1074
|
// site-admin-gated and moves money — never a site-facing route.
|
|
1057
1075
|
registerPluginApiRoute('bookings/refund', bookingRefundHandler);
|
|
1076
|
+
// Paying for a booking at the counter, on the native Aglyn POS app's reader
|
|
1077
|
+
// (AGL-3618). Staff-gated like a refund, and it moves money the same way.
|
|
1078
|
+
registerPluginApiRoute('bookings/in-person-payment', bookingInPersonPaymentHandler);
|
|
1079
|
+
// Checking a guest in and moving a booking (AGL-3621), for the site's
|
|
1080
|
+
// team in the native Aglyn app: member routes, one transaction each.
|
|
1081
|
+
registerPluginApiRoute('bookings/check-in', createBookingCheckInHandler(bookingManageDeps()));
|
|
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()));
|
|
1058
1086
|
// The merchant-side `purchase` lookup (AGL-2481). A public, unauthenticated
|
|
1059
1087
|
// read like `bookings/slots`, authorised by the unguessable Stripe session
|
|
1060
1088
|
// id and answering with a projection that carries no guest identity.
|