@aglyn/plugins-bookings 1.0.0-beta.221 → 1.0.0-beta.222

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 (52) hide show
  1. package/README.md +3 -2
  2. package/package.json +11 -11
  3. package/src/lib/components/book-meeting-action.js +5 -3
  4. package/src/lib/components/book-meeting-action.js.map +1 -1
  5. package/src/lib/components/booking.d.ts +33 -0
  6. package/src/lib/components/booking.js +260 -54
  7. package/src/lib/components/booking.js.map +1 -1
  8. package/src/lib/components/bookings-console-page.js +139 -15
  9. package/src/lib/components/bookings-console-page.js.map +1 -1
  10. package/src/lib/declarations.console-server.d.ts +34 -0
  11. package/src/lib/declarations.console-server.js +84 -0
  12. package/src/lib/declarations.console-server.js.map +1 -0
  13. package/src/lib/model/booking-contact-fields.d.ts +88 -0
  14. package/src/lib/model/booking-contact-fields.js +130 -0
  15. package/src/lib/model/booking-contact-fields.js.map +1 -0
  16. package/src/lib/model/booking-price.d.ts +51 -0
  17. package/src/lib/model/booking-price.js +61 -0
  18. package/src/lib/model/booking-price.js.map +1 -0
  19. package/src/lib/model/booking-record.d.ts +7 -0
  20. package/src/lib/model/booking-record.js +11 -2
  21. package/src/lib/model/booking-record.js.map +1 -1
  22. package/src/lib/model/bookings-record-counts.d.ts +25 -0
  23. package/src/lib/model/bookings-record-counts.js +34 -0
  24. package/src/lib/model/bookings-record-counts.js.map +1 -0
  25. package/src/lib/model/bookings.d.ts +61 -4
  26. package/src/lib/model/bookings.js +81 -15
  27. package/src/lib/model/bookings.js.map +1 -1
  28. package/src/lib/model/index.d.ts +2 -0
  29. package/src/lib/model/index.js +2 -0
  30. package/src/lib/model/index.js.map +1 -1
  31. package/src/lib/plugin.js +14 -0
  32. package/src/lib/plugin.js.map +1 -1
  33. package/src/lib/server/billing-webhook.js +33 -15
  34. package/src/lib/server/billing-webhook.js.map +1 -1
  35. package/src/lib/server/booking-crm.js +4 -1
  36. package/src/lib/server/booking-crm.js.map +1 -1
  37. package/src/lib/server/person-eraser.d.ts +36 -0
  38. package/src/lib/server/person-eraser.js +48 -0
  39. package/src/lib/server/person-eraser.js.map +1 -0
  40. package/src/lib/server/refund.js +2 -2
  41. package/src/lib/server/refund.js.map +1 -1
  42. package/src/lib/server.js +97 -36
  43. package/src/lib/server.js.map +1 -1
  44. package/src/lib/transfer/bookings-transfer-common.d.ts +68 -0
  45. package/src/lib/transfer/bookings-transfer-common.js +130 -0
  46. package/src/lib/transfer/bookings-transfer-common.js.map +1 -0
  47. package/src/lib/transfer/bookings-transfer.d.ts +86 -0
  48. package/src/lib/transfer/bookings-transfer.js +613 -0
  49. package/src/lib/transfer/bookings-transfer.js.map +1 -0
  50. package/src/lib/transfer/bookings-transfer.server.d.ts +26 -0
  51. package/src/lib/transfer/bookings-transfer.server.js +31 -0
  52. package/src/lib/transfer/bookings-transfer.server.js.map +1 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/server/billing-webhook.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\n// `after()`, never a bare `void promise` (AGL-2327). This handler runs inside\n// the console's `/api/billing/webhook` invocation, which AGL-1133 measured on\n// production is frozen the moment the response is sent — so fire-and-forget\n// work scheduled any other way simply does not run.\nimport { after } from 'next/server'\n\nimport type { BillingWebhookHandler } from '@aglyn/aglyn/server'\nimport { resolveBrandingProfile } from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n notifyHostManagers,\n notifyRiskEvent,\n renderHostEmailWithTokens,\n sendGa4Purchase,\n} from '@aglyn/tenant-data-admin'\nimport { paymentRiskEventFrom } from '@aglyn/aglyn/app-utils/payment-risk'\nimport {\n recordPaymentRiskOnRecord,\n settlePaymentRiskDisputeOnRecord,\n} from '@aglyn/tenant-data-admin/server/payment-risk-record'\nimport {\n reverseDestinationTransfer,\n type TransferReversalFailure,\n} from '@aglyn/tenant-data-admin/server/stripe-transfer-reversal'\nimport recordCapturedContact from '@aglyn/aglyn/plugin-manager/record-captured-contact'\nimport {\n bookingPlatformNetCents,\n shouldSendBookingPlatformPurchase,\n} from '../model/booking-purchase-analytics'\nimport { sendEmail } from '@aglyn/shared-util-email'\nimport { pluginTaxProfile } from '@aglyn/aglyn/plugin-manager/plugin-tax-profile'\nimport { fileBookingOnCrm } from './booking-crm'\nimport { bookingTimeZoneFor } from './booking-time-zone'\nimport { formatBookingWhen } from '../model/booking-time'\n\n/**\n * Paid-booking section of the platform Stripe webhook (AGL-170/418):\n * payment confirms the pendingPayment hold — relocated verbatim from the\n * console route; registered via registerBookingsConsoleApi.\n */\n/** gRPC `Status.FAILED_PRECONDITION` — Firestore's \"this query needs an index\". */\nconst GRPC_FAILED_PRECONDITION = 9\n\n/**\n * The booking a charge paid for, found by the `paymentIntentId` the paid\n * handler below records (AGL-2315), or null. A missing collection-group\n * index is logged and answered null rather than thrown: no redelivery can\n * fix it, and the dispute then reaches staff through the route's\n * unattributed-dispute alert instead of vanishing.\n */\nasync function findBookingForPayment(\n paymentIntentId: string,\n): Promise<FirebaseFirestore.QueryDocumentSnapshot | null> {\n if (!paymentIntentId) return null\n const matches = await firebaseAdmin\n .app()\n .firestore()\n .collectionGroup('bookings')\n .where('paymentIntentId', '==', paymentIntentId)\n .limit(2)\n .get()\n .catch((error: { code?: number }) => {\n if (error?.code !== GRPC_FAILED_PRECONDITION) throw error\n console.error(\n 'Booking payment lookup needs the collection-group index on ' +\n 'bookings.paymentIntentId (AGL-3360)',\n error,\n )\n return null\n })\n // One payment intent pays one booking; two is corrupt data, and stamping\n // an arbitrary one would tell the wrong merchant.\n return matches && matches.docs.length === 1 ? matches.docs[0] : null\n}\n\n/** What a booking records once its lost dispute's reversal is settled. */\ninterface BookingDisputeReversal {\n disputeId: string\n reversedTransferCents: number\n transferReversalId?: string\n /** Why nothing was pulled back, when nothing was. */\n failedReason?: TransferReversalFailure\n owedCents?: number\n atMs: number\n}\n\n/**\n * The merchant's share of a LOST booking dispute, pulled back from the\n * connected account (AGL-3363), by the AGL-1794 policy a store order follows:\n * the Stripe half is the shared `reverseDestinationTransfer`, this is only\n * the booking's own record.\n *\n * IDEMPOTENT BY THE BOOKING: `disputeReversal` for this dispute, present,\n * means settled and a redelivery returns before any Stripe call. A delivery\n * that died after the POST is healed by the seam adopting the reversal the\n * transfer already carries. A transient Stripe failure THROWS (the seam\n * does), leaving the marker unset so Stripe's redelivery retries it. A final\n * failure is settled with its reason and the owed amount, so it is findable.\n *\n * `cents` is non-zero only for the delivery that wrote the reversal, which\n * is the one that tells the merchant.\n */\nasync function reverseBookingSellerShare(\n bookingRef: FirebaseFirestore.DocumentReference,\n dispute: any,\n): Promise<{ cents: number; settled: 'reversed' | 'failed' | 'pending' }> {\n const disputeId = String(dispute?.id ?? '')\n const amountCents = Math.max(0, Math.round(Number(dispute?.amount ?? 0)))\n if (!disputeId || !(amountCents > 0)) return { cents: 0, settled: 'failed' }\n const firestore = firebaseAdmin.app().firestore()\n const prior = (await bookingRef.get()).get('disputeReversal') as\n | BookingDisputeReversal\n | undefined\n if (prior?.disputeId === disputeId) {\n return { cents: 0, settled: prior.failedReason ? 'failed' : 'reversed' }\n }\n const outcome = await reverseDestinationTransfer({\n kind: 'dispute',\n id: disputeId,\n amountCents,\n chargeId:\n typeof dispute?.charge === 'string' ? dispute.charge : String(dispute?.charge?.id ?? ''),\n metadata: { bookingId: bookingRef.id },\n })\n if (outcome.kind === 'skipped') {\n console.error('Booking transfer reversal skipped: STRIPE_SECRET_KEY is not set (AGL-3363)')\n return { cents: 0, settled: 'pending' }\n }\n const record: BookingDisputeReversal =\n outcome.kind === 'reversed'\n ? {\n disputeId,\n reversedTransferCents: outcome.cents,\n ...(outcome.reversalId ? { transferReversalId: outcome.reversalId } : {}),\n atMs: Date.now(),\n }\n : {\n disputeId,\n reversedTransferCents: 0,\n failedReason: outcome.reason,\n ...(outcome.owedCents != null ? { owedCents: outcome.owedCents } : {}),\n atMs: Date.now(),\n }\n if (outcome.kind === 'not-reversed') {\n console.error('Booking seller share NOT reversed — recorded on the booking', {\n bookingId: bookingRef.id,\n disputeId,\n reason: outcome.reason,\n })\n }\n const wrote = await firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(bookingRef)\n if (!fresh.exists) return false\n const current = fresh.get('disputeReversal') as BookingDisputeReversal | undefined\n if (current?.disputeId === disputeId) return false\n transaction.set(bookingRef, { disputeReversal: record }, { merge: true })\n return true\n })\n return {\n cents: wrote && outcome.kind === 'reversed' ? outcome.cents : 0,\n settled: outcome.kind === 'reversed' ? 'reversed' : 'failed',\n }\n}\n\nexport const bookingsBillingWebhookHandler: BillingWebhookHandler = async ({\n type,\n object,\n}) => {\n // Fraud signals on a paid booking (AGL-3360): an issuer's early fraud\n // warning, a Radar review, or a dispute. Booking deposits never heard\n // about any of them, so the merchant learned of a chargeback from Stripe\n // alone. The signal now lands on the BOOKING, through the shared\n // `paymentRisk` shape every plugin stamps, and the site's managers are\n // told once. Nothing is refunded or canceled: the merchant decides.\n const risk = paymentRiskEventFrom(type, object)\n if (risk) {\n const booking = await findBookingForPayment(risk.paymentIntentId)\n if (!booking) return\n const hostId = String(booking.ref.parent.parent?.id ?? '')\n await recordPaymentRiskOnRecord(\n {\n ref: booking.ref,\n signal: risk.signal,\n hostId,\n subjectLabel: `the booking for ${String(booking.get('serviceName') ?? booking.id)}`,\n link: `/${hostId}/bookings`,\n notificationType: 'content.booking',\n amount: Number.isFinite(Number(booking.get('paidAmountCents')))\n ? `$${(Number(booking.get('paidAmountCents')) / 100).toFixed(2)}`\n : null,\n evidenceDueByMs:\n Number((object as { evidence_details?: { due_by?: unknown } })?.evidence_details?.due_by ?? 0) * 1000 || null,\n },\n { firestore: firebaseAdmin.app().firestore(), notifyRisk: notifyRiskEvent },\n )\n return { claimed: true, hostId }\n }\n // A dispute's outcome, on the signal `created` stamped. A LOST one pulls\n // the merchant's share back from the connected account (AGL-3363), through\n // the same seam a lost store order uses, so the platform no longer absorbs\n // a booking chargeback. Claimed when the share came back; a reversal that\n // could not be made stays unclaimed, so the route's unattributed-dispute\n // alert still tells staff about money nothing recovered.\n if (type === 'charge.dispute.closed') {\n const paymentIntentId =\n typeof object?.payment_intent === 'string'\n ? object.payment_intent\n : String(object?.payment_intent?.id ?? '')\n const booking = await findBookingForPayment(paymentIntentId)\n if (!booking) return\n const outcome = String(object?.status ?? '')\n await settlePaymentRiskDisputeOnRecord(\n booking.ref,\n String(object?.id ?? ''),\n outcome,\n { firestore: firebaseAdmin.app().firestore() },\n )\n const hostId = String(booking.ref.parent.parent?.id ?? '')\n if (outcome !== 'lost') return { claimed: true, hostId }\n const recovered = await reverseBookingSellerShare(booking.ref, object)\n if (recovered.cents > 0 && hostId) {\n const recoveredText = `$${(recovered.cents / 100).toFixed(2)}`\n const guest = String(booking.get('name') ?? '').trim()\n const service = String(booking.get('serviceName') ?? '').trim() || 'a service'\n await notifyHostManagers(hostId, {\n type: 'content.booking',\n title: `Payout adjusted — ${recoveredText} recovered for a lost chargeback`,\n body:\n `${guest ? `${guest}'s payment` : 'A payment'} for ${service} on ` +\n `{site} was charged back and the dispute was lost, so ` +\n `the ${recoveredText} transferred to you for it has been taken back. ` +\n `If your balance does not cover it, Stripe recovers the rest from ` +\n `your future payouts.`,\n link: `/${hostId}/bookings`,\n })\n }\n return recovered.settled === 'reversed' ? { claimed: true, hostId } : undefined\n }\n // Paid bookings (AGL-170): payment confirms the pendingPayment hold.\n if (\n type === 'checkout.session.completed' &&\n object?.metadata?.type === 'booking-payment' &&\n object?.payment_status === 'paid'\n ) {\n const { hostId, bookingId } = object.metadata ?? {}\n if (hostId && bookingId) {\n const firestore = firebaseAdmin.app().firestore()\n const bookingRef = firestore\n .collection('hosts')\n .doc(String(hostId))\n .collection('bookings')\n .doc(String(bookingId))\n // WHAT THE CLIENT HANDED OVER, ALL OF IT. Tax included: this is the\n // ceiling `refund.ts` reverses against, and the client paid the tax\n // too — a net figure here would strand part of a refund.\n const paidAmountCents = Math.max(\n 0,\n Math.round(Number(object?.amount_total ?? 0)),\n )\n // THE MERCHANT'S SERVICE TAX, SEPARATED OUT (AGL-2028).\n //\n // `server.ts` charges the merchant's own service rate as an ordinary\n // `line_items[1]` Stripe is never told is tax (the AGL-1711\n // construction, which is what keeps the figure the MERCHANT's rather\n // than something computed against Aglyn's registrations). So\n // `amount_total` is service-plus-tax while Stripe's own tax fields\n // read zero, and the session's metadata is the only witness — exactly\n // as it is for a buy-now sale.\n const taxCents = Math.max(\n 0,\n Math.round(Number(object?.metadata?.taxCents ?? 0)),\n )\n // The merchant's REVENUE, which is the charge less the tax. Used for\n // the contact's lifetime value below: tax is collected on behalf of\n // an authority and is not money the business earned, so counting it\n // would over-state every service client's worth.\n const serviceCents = Math.max(0, paidAmountCents - taxCents)\n // WHAT A REFUND WILL NEED (AGL-2315). A paid booking is now a\n // destination charge to the merchant's connected account, and\n // reversing one requires the PaymentIntent — which lives only on this\n // event. The booking document recorded `paidAmountCents` and nothing\n // that identifies the charge, so a refund had no handle to pull and\n // the information was gone the moment the webhook returned. Unlike\n // most gaps this one cannot be backfilled from our own data later:\n // every booking taken before this line landed can only be refunded by\n // hand in the Stripe dashboard.\n //\n // `feeCents` is Aglyn's cut as CHARGED, read back off the session's\n // own metadata rather than re-derived from the org at refund time. The\n // rate follows the plan (AGL-2289's rule) and the plan moves, so\n // re-resolving it during a refund would reverse a share that was never\n // taken. `'0'` is a real recorded answer on the 0%-rate tiers.\n const paymentIntentId =\n typeof object?.payment_intent === 'string'\n ? object.payment_intent\n : String(object?.payment_intent?.id ?? '')\n const chargedFeeCents = Math.max(\n 0,\n Math.round(Number(object?.metadata?.feeCents ?? 0)),\n )\n // Redelivery guard (AGL-1755), the AGL-1748 shape. This was an\n // unconditional merge-set with no status check at all — idempotent\n // only by accident, because every value it wrote was fixed. A\n // redelivery re-sent the guest's confirmation and re-metered it, and\n // the moment this handler carries money to a contact the accident stops\n // holding: `purchaseCents` is a `FieldValue.increment`, so every retry\n // would inflate the customer's lifetime value.\n //\n // Keyed on the STATUS, not on the document existing: `server.ts` writes\n // the booking as `pendingPayment` BEFORE it opens the Stripe session,\n // so existence is guaranteed by the time any event arrives and proves\n // nothing — the AGL-1748 reasoning, not AGL-1732's `checkoutSessionId`\n // key, which existed because a sibling event wrote the same path.\n //\n // The key is \"not yet confirmed\" rather than \"is pendingPayment\" on\n // purpose. The lapsed-hold sweeper in `server.ts` cancels a\n // `pendingPayment` booking after 24h, and refusing a payment that\n // landed anyway would take the money and record nothing; `confirmed` is\n // reachable for a PAID booking only by this handler having already run,\n // so it is an exact \"already processed\" test. The existence check is\n // new and deliberate: the old merge-set would CREATE a stub booking\n // from a metadata `bookingId` that pointed at nothing.\n let booking: Record<string, any> = {}\n const confirmedNow = await firestore.runTransaction(\n async (transaction) => {\n const snapshot = await transaction.get(bookingRef)\n if (!snapshot.exists) return false\n if (snapshot.get('status') === 'confirmed') return false\n // \"Already processed\" is WIDER than `confirmed` once money can\n // flow back out (AGL-2315). A refunded booking is moved off\n // `confirmed`, so a redelivery arriving afterwards would otherwise\n // read as unprocessed and re-confirm an appointment the merchant\n // has already cancelled and refunded — re-sending the guest a\n // confirmation for it and re-metering the email. Any evidence this\n // handler has run before is the real test, and a refund counter or\n // a recorded PaymentIntent is exactly that evidence.\n if (\n Number(snapshot.get('refundedCents') ?? 0) > 0 ||\n snapshot.get('paymentIntentId')\n ) {\n return false\n }\n booking = (snapshot.data() as any) ?? {}\n transaction.set(\n bookingRef,\n {\n status: 'confirmed',\n paidAmountCents,\n // WHICH TAX REGIME THIS BOOKING CARRIED (AGL-2028), on the\n // document the merchant reads — the stamp every other\n // storefront money door already carries (AGL-2451).\n //\n // DERIVED from the one shared derivation, never a constant.\n // A hand-written \"manual when the metadata says so\" would be\n // a second rule that can drift from the `storefrontTaxCollected`\n // row filed for the same Stripe id, which is the exact\n // disagreement AGL-2451 exists to prevent.\n //\n // TWO-ARGUMENT, because the manual line makes Stripe report\n // `total_details.amount_tax: 0` on a booking that really did\n // charge the client tax; the one-argument form would stamp\n // `none` on precisely those. `absent` remains a fourth state\n // meaning \"recorded before this shipped\".\n taxMode: pluginTaxProfile().taxModeOf(object, taxCents),\n // The figure itself. Absent rather than a defaulted `0` — a\n // zero written through `merge` is the AGL-1758 shape, and\n // this handler can re-enter (see the widened guard above).\n ...(taxCents > 0 ? { taxCents } : {}),\n // The refund handles (AGL-2315). Written inside the same\n // transaction as the status, so a booking can never read\n // `confirmed` while the means to refund it are missing.\n ...(paymentIntentId ? { paymentIntentId } : {}),\n feeCents: chargedFeeCents,\n // WHICH CHECKOUT PAID FOR THIS (AGL-2481). The guest's browser\n // comes back from Stripe holding a session id and nothing\n // else — a booking id was never in the return URL — so this is\n // what lets `booking-analytics.ts` answer \"what did I just\n // buy?\" for the merchant-side `purchase`, and it is written\n // here because the session id first exists on this event.\n //\n // Inside the transaction with the status on purpose: the\n // lookup refuses anything that is not `confirmed`, so a\n // booking can never be findable by session id while reading as\n // unpaid.\n ...(object?.id ? { checkoutSessionId: String(object.id) } : {}),\n // NOTE the absent `refundedCents: 0`. Seeding it here would be\n // the AGL-1758 shape: a defaulted field written through\n // `merge` destroying the real one. This handler can re-enter\n // after a refund has already accumulated a counter (see the\n // widened guard above), and a zero written back over it would\n // re-open the full amount for refunding a second time. Absent\n // IS zero — every reader coerces with `?? 0`.\n expiresAtMs: firebaseAdmin.firestore.FieldValue.delete(),\n confirmedAt:\n firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n return true\n },\n )\n if (!confirmedNow) return\n // THE RECORD'S MEETING (AGL-2660). Payment is what confirms a paid\n // booking, so this is where it reaches the CRM: the meeting on the\n // record's timeline and, when the service asks, the follow-up task.\n // Inside the idempotency gate for the same reason the GA4 purchase\n // below is — a redelivery must not file a second meeting — and\n // through `after()` for the same reason too: this invocation is\n // frozen the moment the response is sent (AGL-2327). Never thrown:\n // the writer swallows its own failures, and the catch is for the\n // scheduling itself.\n after(() =>\n fileBookingOnCrm(firestore, {\n hostId: String(hostId),\n bookingId: String(bookingId),\n booking,\n }).catch(() => undefined),\n )\n // AGLYN'S REVENUE ON THIS BOOKING (AGL-2481) — into OUR GA4 property.\n //\n // ## Why it is inside the idempotency gate rather than beside it\n //\n // This sits AFTER `if (!confirmedNow) return` and that placement is the\n // whole guard. Stripe redelivers `checkout.session.completed` for up to\n // three days after any 500, and this endpoint 500s on purpose; a\n // redelivery re-enters this handler, the transaction above sees a\n // `confirmed` status (or a refund counter, or a recorded PaymentIntent)\n // and answers false, and everything below — the contact, the email, and\n // now this — is skipped. A `purchase` fired before that gate, or beside\n // it, would inflate our own reported revenue by the redelivery rate.\n //\n // `transaction_id` is the checkout session id, which is the same key\n // the guard turns on, so GA4's own de-duplication is a second\n // independent line of defence: even if the Firestore guard were ever\n // bypassed, GA would collapse the repeats.\n //\n // ## Platform NET, and the fee is the measured one\n //\n // `value` is our fee and not the $95 the guest paid — the AGL-1639\n // settlement. This is our property, the subscription and marketplace\n // `purchase` events in it already mean \"what Aglyn was paid\", and a\n // gross booking figure sitting beside them would make every combined\n // total, ARPA and revenue audience wrong. The merchant's own gross goes\n // to the MERCHANT's property, client-side, and the two never meet.\n //\n // The figure comes off the session's `metadata.feeCents` — the fee\n // Stripe actually charged as `application_fee_amount` — never from the\n // plan's rate re-applied here. See `bookingPlatformNetCents`.\n //\n // ## Scheduling and failure posture\n //\n // `after()`, never a bare `void` (AGL-2327/AGL-2346): this runs inside\n // the console's `/api/billing/webhook` invocation, which is frozen the\n // moment the response is sent, so fire-and-forget work scheduled any\n // other way simply does not run — the exact way marketplace revenue\n // reported to nothing. And `.catch()`, because a throw here would\n // un-claim the Stripe event and turn a missed analytics hit into a\n // repeated billing side effect.\n if (shouldSendBookingPlatformPurchase(object)) {\n const netCents = bookingPlatformNetCents(object)\n after(() =>\n sendGa4Purchase({\n transactionId: String(object?.id ?? ''),\n value: netCents / 100,\n currency: String(object?.currency ?? 'usd'),\n items: [\n {\n // Ids only, no merchant free text. A service name is\n // merchant-authored and one edit away from carrying a\n // person's name into a dimension in OUR property; the id\n // already identifies it. The merchant's own property gets\n // the name, because there it is their own content.\n item_id: String(booking['serviceId'] ?? bookingId),\n item_name: String(booking['serviceId'] ?? bookingId),\n item_category: 'booking',\n // GA expects the items to sum to `value`; one item, one\n // price, and that price is our fee — not the service's.\n price: netCents / 100,\n quantity: 1,\n },\n ],\n // Booking sessions carry no `ga_client_id` today — nothing\n // captures one on a tenant site — so this is read for the day\n // one is stamped and the seed below is what actually resolves.\n clientId: object?.metadata?.ga_client_id,\n // ONE PAYING GUEST, ONE SYNTHETIC USER. Seeded from Stripe's\n // customer where there is one, and otherwise from the guest's\n // email, which `server.ts` passes as `customer_email` and Stripe\n // echoes on `customer_details`. The seed is hashed inside\n // `synthesizeClientId` and never transmitted.\n //\n // The booking id is the LAST resort deliberately: it is unique\n // per appointment, so seeding from it would turn a regular\n // client into a crowd of one-purchase strangers and make ARPA\n // nonsense — precisely what that helper's determinism exists to\n // prevent. Without any seed at all `sendGa4Purchase` returns\n // `no-client-id` and the event is silently never sent, which is\n // the void this whole change exists to close.\n stripeCustomerId:\n String(object?.customer ?? '') ||\n String(object?.customer_details?.email ?? '') ||\n String(bookingId),\n }).catch(() => undefined),\n )\n }\n // Contacts ingestion (AGL-1755). `server.ts` captures the contact at\n // REQUEST time, which is the right place — the booking is written\n // `pendingPayment` and at that moment no money has moved, so passing an\n // amount there would be a lie. But payment completes HERE, and this\n // handler never returned to the contact, so a paid booking's money\n // reached the booking document and nothing else. The same structural\n // shape as AGL-1748's draft branch: the completion handler does\n // everything except the contact.\n //\n // `source` stays `'booking'` — the same source the request-time capture\n // used — so the `sources` map still says where this person came from\n // and no schema change is needed to keep service revenue\n // distinguishable from product revenue. The second interaction is\n // deliberate rather than a duplicate: \"Booked X\" and \"Paid for X\" are\n // two real events, and `mergeContactInteraction` does not dedupe on\n // `refId`.\n //\n // The amount is `amount_total`, the money that moved, per AGL-1698 and\n // AGL-1711 — the booking document stores no price to re-derive it from.\n if (booking['email']) {\n void recordCapturedContact({\n orgId: '',\n hostId: String(hostId),\n identity: {\n email: booking['email'],\n ...(booking['name'] ? { name: String(booking['name']) } : {}),\n },\n // Paid, so a relationship (AGL-3232): the lead the request-time\n // capture filed for the address is closed onto the contact.\n surface: 'relationship',\n // Paid, so a customer (AGL-2612) — the request-time capture\n // said `lead`, and this is the door that has seen the money.\n lifecycleFloor: 'customer',\n // The SERVICE, not the charge (AGL-2028). `amount_total` now\n // includes the merchant's service tax where they set one, and\n // tax is collected for an authority rather than earned — booking\n // it as lifetime value would over-state what every service\n // client is worth. Identical to `paidAmountCents` on the default\n // store, which sets no rate.\n ...(serviceCents > 0 ? { purchaseCents: serviceCents } : {}),\n interaction: {\n source: 'booking',\n refId: String(bookingId),\n summary: `Paid for \"${String(\n booking['serviceName'] ?? 'a service',\n ).slice(0, 60)}\" ($${(serviceCents / 100).toFixed(2)})`,\n },\n })\n }\n // Confirmation email now that payment cleared (env-gated inside\n // sendEmail, which no-ops when Resend isn't configured).\n if (booking['email']) {\n // In the booking's own zone, and named (AGL-3432): a formatter with\n // no zone reads in the server's — UTC — and tells a paying guest the\n // wrong hour, and an empty zone prints \"()\".\n const timezone = await bookingTimeZoneFor(\n firestore,\n String(hostId),\n booking,\n )\n const when = formatBookingWhen(Number(booking['startsAtMs']), timezone)\n const paid = `$${(paidAmountCents / 100).toFixed(2)}`\n const fallbackText =\n `Hi ${booking['name'] ?? ''},\\n\\nPayment received — ` +\n `\"${booking['serviceName'] ?? 'your booking'}\" is ` +\n `confirmed for ${when} (${timezone}). You paid ${paid}.` +\n `\\n\\nReference: ${bookingId}`\n // Site-owner-designed template when published (AGL-770); null keeps\n // the built-in copy.\n const designed = await renderHostEmailWithTokens(\n firebaseAdmin.app().firestore(),\n String(hostId),\n 'booking-confirmed',\n {\n name: String(booking['name'] ?? ''),\n 'service.name': String(booking['serviceName'] ?? ''),\n when,\n timezone,\n 'booking.payment': `You paid ${paid}.`,\n 'booking.ref': String(bookingId),\n },\n )\n // White-label sender identity (White-Label Phase 3), matching the\n // free-booking path — the store's brand via the one shared resolver.\n const branding = resolveBrandingProfile(\n (await getOrgForHost(String(hostId)).catch(() => null))?.org as never,\n )\n await sendEmail({\n to: String(booking['email']),\n subject:\n designed?.subject ??\n `Booking confirmed: ${booking['serviceName'] ?? ''}`,\n text: designed?.text || fallbackText,\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(String(hostId)),\n audience: 'tenant',\n context: 'paid booking confirmation',\n // Owed to the recipient by their own booking: the phishing\n // screen's soft rules never hold it (AGL-3356).\n owedFor: 'booking',\n })\n // Cost meter (AGL-1438), matching the free-booking path.\n // Transactional: the guest has already paid.\n await meterHostEmail(String(hostId))\n }\n }\n }\n}\n"],"names":["after","resolveBrandingProfile","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","notifyHostManagers","notifyRiskEvent","renderHostEmailWithTokens","sendGa4Purchase","paymentRiskEventFrom","recordPaymentRiskOnRecord","settlePaymentRiskDisputeOnRecord","reverseDestinationTransfer","recordCapturedContact","bookingPlatformNetCents","shouldSendBookingPlatformPurchase","sendEmail","pluginTaxProfile","fileBookingOnCrm","bookingTimeZoneFor","formatBookingWhen","GRPC_FAILED_PRECONDITION","findBookingForPayment","paymentIntentId","matches","app","firestore","collectionGroup","where","limit","get","catch","error","code","console","docs","length","reverseBookingSellerShare","bookingRef","dispute","disputeId","String","id","amountCents","Math","max","round","Number","amount","cents","settled","prior","failedReason","outcome","kind","chargeId","charge","metadata","bookingId","record","reversedTransferCents","reversalId","transferReversalId","atMs","Date","now","reason","owedCents","wrote","runTransaction","transaction","fresh","exists","current","set","disputeReversal","merge","bookingsBillingWebhookHandler","type","object","risk","booking","hostId","ref","parent","signal","subjectLabel","link","notificationType","isFinite","toFixed","evidenceDueByMs","evidence_details","due_by","notifyRisk","claimed","payment_intent","status","recovered","recoveredText","guest","trim","service","title","body","undefined","payment_status","collection","doc","paidAmountCents","amount_total","taxCents","serviceCents","chargedFeeCents","feeCents","confirmedNow","snapshot","data","taxMode","taxModeOf","checkoutSessionId","expiresAtMs","FieldValue","delete","confirmedAt","serverTimestamp","netCents","transactionId","value","currency","items","item_id","item_name","item_category","price","quantity","clientId","ga_client_id","stripeCustomerId","customer","customer_details","email","orgId","identity","name","surface","lifecycleFloor","purchaseCents","interaction","source","refId","summary","slice","timezone","when","paid","fallbackText","designed","branding","org","to","subject","text","html","fromName","sendingIdentity","audience","context","owedFor"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/server/billing-webhook.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\n// `after()`, never a bare `void promise` (AGL-2327). This handler runs inside\n// the console's `/api/billing/webhook` invocation, which AGL-1133 measured on\n// production is frozen the moment the response is sent — so fire-and-forget\n// work scheduled any other way simply does not run.\nimport { after } from 'next/server'\n\nimport type { BillingWebhookHandler } from '@aglyn/aglyn/server'\nimport { resolveBrandingProfile } from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n notifyHostManagers,\n notifyRiskEvent,\n renderHostEmailWithTokens,\n sendGa4Purchase,\n} from '@aglyn/tenant-data-admin'\nimport { paymentRiskEventFrom } from '@aglyn/aglyn/app-utils/payment-risk'\nimport {\n recordPaymentRiskOnRecord,\n settlePaymentRiskDisputeOnRecord,\n} from '@aglyn/tenant-data-admin/server/payment-risk-record'\nimport {\n reverseDestinationTransfer,\n type TransferReversalFailure,\n} from '@aglyn/tenant-data-admin/server/stripe-transfer-reversal'\nimport recordCapturedContact from '@aglyn/aglyn/plugin-manager/record-captured-contact'\nimport {\n bookingPlatformNetCents,\n shouldSendBookingPlatformPurchase,\n} from '../model/booking-purchase-analytics'\nimport { sendEmail } from '@aglyn/shared-util-email'\nimport { pluginTaxProfile } from '@aglyn/aglyn/plugin-manager/plugin-tax-profile'\nimport { fileBookingOnCrm } from './booking-crm'\nimport { bookingTimeZoneFor } from './booking-time-zone'\nimport { formatBookingWhen } from '../model/booking-time'\nimport {\n bookingContactLines,\n readBookingPhone,\n} from '../model/booking-contact-fields'\n\n/**\n * Paid-booking section of the platform Stripe webhook (AGL-170/418):\n * payment confirms the pendingPayment hold — relocated verbatim from the\n * console route; registered via registerBookingsConsoleApi.\n */\n/** gRPC `Status.FAILED_PRECONDITION` — Firestore's \"this query needs an index\". */\nconst GRPC_FAILED_PRECONDITION = 9\n\n/**\n * The booking a charge paid for, found by the `paymentIntentId` the paid\n * handler below records (AGL-2315), or null. A missing collection-group\n * index is logged and answered null rather than thrown: no redelivery can\n * fix it, and the dispute then reaches staff through the route's\n * unattributed-dispute alert instead of vanishing.\n */\nasync function findBookingForPayment(\n paymentIntentId: string,\n): Promise<FirebaseFirestore.QueryDocumentSnapshot | null> {\n if (!paymentIntentId) return null\n const matches = await firebaseAdmin\n .app()\n .firestore()\n .collectionGroup('bookings')\n .where('paymentIntentId', '==', paymentIntentId)\n .limit(2)\n .get()\n .catch((error: { code?: number }) => {\n if (error?.code !== GRPC_FAILED_PRECONDITION) throw error\n console.error(\n 'Booking payment lookup needs the collection-group index on ' +\n 'bookings.paymentIntentId (AGL-3360)',\n error,\n )\n return null\n })\n // One payment intent pays one booking; two is corrupt data, and stamping\n // an arbitrary one would tell the wrong merchant.\n return matches && matches.docs.length === 1 ? matches.docs[0] : null\n}\n\n/** What a booking records once its lost dispute's reversal is settled. */\ninterface BookingDisputeReversal {\n disputeId: string\n reversedTransferCents: number\n transferReversalId?: string\n /** Why nothing was pulled back, when nothing was. */\n failedReason?: TransferReversalFailure\n owedCents?: number\n atMs: number\n}\n\n/**\n * The merchant's share of a LOST booking dispute, pulled back from the\n * connected account (AGL-3363), by the AGL-1794 policy a store order follows:\n * the Stripe half is the shared `reverseDestinationTransfer`, this is only\n * the booking's own record.\n *\n * IDEMPOTENT BY THE BOOKING: `disputeReversal` for this dispute, present,\n * means settled and a redelivery returns before any Stripe call. A delivery\n * that died after the POST is healed by the seam adopting the reversal the\n * transfer already carries. A transient Stripe failure THROWS (the seam\n * does), leaving the marker unset so Stripe's redelivery retries it. A final\n * failure is settled with its reason and the owed amount, so it is findable.\n *\n * `cents` is non-zero only for the delivery that wrote the reversal, which\n * is the one that tells the merchant.\n */\nasync function reverseBookingSellerShare(\n bookingRef: FirebaseFirestore.DocumentReference,\n dispute: any,\n): Promise<{ cents: number; settled: 'reversed' | 'failed' | 'pending' }> {\n const disputeId = String(dispute?.id ?? '')\n const amountCents = Math.max(0, Math.round(Number(dispute?.amount ?? 0)))\n if (!disputeId || !(amountCents > 0)) return { cents: 0, settled: 'failed' }\n const firestore = firebaseAdmin.app().firestore()\n const prior = (await bookingRef.get()).get('disputeReversal') as\n | BookingDisputeReversal\n | undefined\n if (prior?.disputeId === disputeId) {\n return { cents: 0, settled: prior.failedReason ? 'failed' : 'reversed' }\n }\n const outcome = await reverseDestinationTransfer({\n kind: 'dispute',\n id: disputeId,\n amountCents,\n chargeId:\n typeof dispute?.charge === 'string' ? dispute.charge : String(dispute?.charge?.id ?? ''),\n metadata: { bookingId: bookingRef.id },\n })\n if (outcome.kind === 'skipped') {\n console.error('Booking transfer reversal skipped: STRIPE_SECRET_KEY is not set (AGL-3363)')\n return { cents: 0, settled: 'pending' }\n }\n const record: BookingDisputeReversal =\n outcome.kind === 'reversed'\n ? {\n disputeId,\n reversedTransferCents: outcome.cents,\n ...(outcome.reversalId ? { transferReversalId: outcome.reversalId } : {}),\n atMs: Date.now(),\n }\n : {\n disputeId,\n reversedTransferCents: 0,\n failedReason: outcome.reason,\n ...(outcome.owedCents != null ? { owedCents: outcome.owedCents } : {}),\n atMs: Date.now(),\n }\n if (outcome.kind === 'not-reversed') {\n console.error('Booking seller share NOT reversed — recorded on the booking', {\n bookingId: bookingRef.id,\n disputeId,\n reason: outcome.reason,\n })\n }\n const wrote = await firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(bookingRef)\n if (!fresh.exists) return false\n const current = fresh.get('disputeReversal') as BookingDisputeReversal | undefined\n if (current?.disputeId === disputeId) return false\n transaction.set(bookingRef, { disputeReversal: record }, { merge: true })\n return true\n })\n return {\n cents: wrote && outcome.kind === 'reversed' ? outcome.cents : 0,\n settled: outcome.kind === 'reversed' ? 'reversed' : 'failed',\n }\n}\n\nexport const bookingsBillingWebhookHandler: BillingWebhookHandler = async ({\n type,\n object,\n}) => {\n // Fraud signals on a paid booking (AGL-3360): an issuer's early fraud\n // warning, a Radar review, or a dispute. Booking deposits never heard\n // about any of them, so the merchant learned of a chargeback from Stripe\n // alone. The signal now lands on the BOOKING, through the shared\n // `paymentRisk` shape every plugin stamps, and the site's managers are\n // told once. Nothing is refunded or canceled: the merchant decides.\n const risk = paymentRiskEventFrom(type, object)\n if (risk) {\n const booking = await findBookingForPayment(risk.paymentIntentId)\n if (!booking) return\n const hostId = String(booking.ref.parent.parent?.id ?? '')\n await recordPaymentRiskOnRecord(\n {\n ref: booking.ref,\n signal: risk.signal,\n hostId,\n subjectLabel: `the booking for ${String(booking.get('serviceName') ?? booking.id)}`,\n link: `/${hostId}/bookings`,\n notificationType: 'content.booking',\n amount: Number.isFinite(Number(booking.get('paidAmountCents')))\n ? `$${(Number(booking.get('paidAmountCents')) / 100).toFixed(2)}`\n : null,\n evidenceDueByMs:\n Number((object as { evidence_details?: { due_by?: unknown } })?.evidence_details?.due_by ?? 0) * 1000 || null,\n },\n { firestore: firebaseAdmin.app().firestore(), notifyRisk: notifyRiskEvent },\n )\n return { claimed: true, hostId }\n }\n // A dispute's outcome, on the signal `created` stamped. A LOST one pulls\n // the merchant's share back from the connected account (AGL-3363), through\n // the same seam a lost store order uses, so the platform no longer absorbs\n // a booking chargeback. Claimed when the share came back; a reversal that\n // could not be made stays unclaimed, so the route's unattributed-dispute\n // alert still tells staff about money nothing recovered.\n if (type === 'charge.dispute.closed') {\n const paymentIntentId =\n typeof object?.payment_intent === 'string'\n ? object.payment_intent\n : String(object?.payment_intent?.id ?? '')\n const booking = await findBookingForPayment(paymentIntentId)\n if (!booking) return\n const outcome = String(object?.status ?? '')\n await settlePaymentRiskDisputeOnRecord(\n booking.ref,\n String(object?.id ?? ''),\n outcome,\n { firestore: firebaseAdmin.app().firestore() },\n )\n const hostId = String(booking.ref.parent.parent?.id ?? '')\n if (outcome !== 'lost') return { claimed: true, hostId }\n const recovered = await reverseBookingSellerShare(booking.ref, object)\n if (recovered.cents > 0 && hostId) {\n const recoveredText = `$${(recovered.cents / 100).toFixed(2)}`\n const guest = String(booking.get('name') ?? '').trim()\n const service = String(booking.get('serviceName') ?? '').trim() || 'a service'\n await notifyHostManagers(hostId, {\n type: 'content.booking',\n title: `Payout adjusted — ${recoveredText} recovered for a lost chargeback`,\n body:\n `${guest ? `${guest}'s payment` : 'A payment'} for ${service} on ` +\n `{site} was charged back and the dispute was lost, so ` +\n `the ${recoveredText} transferred to you for it has been taken back. ` +\n `If your balance does not cover it, Stripe recovers the rest from ` +\n `your future payouts.`,\n link: `/${hostId}/bookings`,\n })\n }\n return recovered.settled === 'reversed' ? { claimed: true, hostId } : undefined\n }\n // Paid bookings (AGL-170): payment confirms the pendingPayment hold.\n if (\n type === 'checkout.session.completed' &&\n object?.metadata?.type === 'booking-payment' &&\n object?.payment_status === 'paid'\n ) {\n const { hostId, bookingId } = object.metadata ?? {}\n if (hostId && bookingId) {\n const firestore = firebaseAdmin.app().firestore()\n const bookingRef = firestore\n .collection('hosts')\n .doc(String(hostId))\n .collection('bookings')\n .doc(String(bookingId))\n // WHAT THE CLIENT HANDED OVER, ALL OF IT. Tax included: this is the\n // ceiling `refund.ts` reverses against, and the client paid the tax\n // too — a net figure here would strand part of a refund.\n const paidAmountCents = Math.max(\n 0,\n Math.round(Number(object?.amount_total ?? 0)),\n )\n // THE MERCHANT'S SERVICE TAX, SEPARATED OUT (AGL-2028).\n //\n // `server.ts` charges the merchant's own service rate as an ordinary\n // `line_items[1]` Stripe is never told is tax (the AGL-1711\n // construction, which is what keeps the figure the MERCHANT's rather\n // than something computed against Aglyn's registrations). So\n // `amount_total` is service-plus-tax while Stripe's own tax fields\n // read zero, and the session's metadata is the only witness — exactly\n // as it is for a buy-now sale.\n const taxCents = Math.max(\n 0,\n Math.round(Number(object?.metadata?.taxCents ?? 0)),\n )\n // The merchant's REVENUE, which is the charge less the tax. Used for\n // the contact's lifetime value below: tax is collected on behalf of\n // an authority and is not money the business earned, so counting it\n // would over-state every service client's worth.\n const serviceCents = Math.max(0, paidAmountCents - taxCents)\n // WHAT A REFUND WILL NEED (AGL-2315). A paid booking is now a\n // destination charge to the merchant's connected account, and\n // reversing one requires the PaymentIntent — which lives only on this\n // event. The booking document recorded `paidAmountCents` and nothing\n // that identifies the charge, so a refund had no handle to pull and\n // the information was gone the moment the webhook returned. Unlike\n // most gaps this one cannot be backfilled from our own data later:\n // every booking taken before this line landed can only be refunded by\n // hand in the Stripe dashboard.\n //\n // `feeCents` is Aglyn's cut as CHARGED, read back off the session's\n // own metadata rather than re-derived from the org at refund time. The\n // rate follows the plan (AGL-2289's rule) and the plan moves, so\n // re-resolving it during a refund would reverse a share that was never\n // taken. `'0'` is a real recorded answer on the 0%-rate tiers.\n const paymentIntentId =\n typeof object?.payment_intent === 'string'\n ? object.payment_intent\n : String(object?.payment_intent?.id ?? '')\n const chargedFeeCents = Math.max(\n 0,\n Math.round(Number(object?.metadata?.feeCents ?? 0)),\n )\n // Redelivery guard (AGL-1755), the AGL-1748 shape. This was an\n // unconditional merge-set with no status check at all — idempotent\n // only by accident, because every value it wrote was fixed. A\n // redelivery re-sent the guest's confirmation and re-metered it, and\n // the moment this handler carries money to a contact the accident stops\n // holding: `purchaseCents` is a `FieldValue.increment`, so every retry\n // would inflate the customer's lifetime value.\n //\n // Keyed on the STATUS, not on the document existing: `server.ts` writes\n // the booking as `pendingPayment` BEFORE it opens the Stripe session,\n // so existence is guaranteed by the time any event arrives and proves\n // nothing — the AGL-1748 reasoning, not AGL-1732's `checkoutSessionId`\n // key, which existed because a sibling event wrote the same path.\n //\n // The key is \"not yet confirmed\" rather than \"is pendingPayment\" on\n // purpose. The lapsed-hold sweeper in `server.ts` cancels a\n // `pendingPayment` booking after 24h, and refusing a payment that\n // landed anyway would take the money and record nothing; `confirmed` is\n // reachable for a PAID booking only by this handler having already run,\n // so it is an exact \"already processed\" test. The existence check is\n // new and deliberate: the old merge-set would CREATE a stub booking\n // from a metadata `bookingId` that pointed at nothing.\n let booking: Record<string, any> = {}\n const confirmedNow = await firestore.runTransaction(\n async (transaction) => {\n const snapshot = await transaction.get(bookingRef)\n if (!snapshot.exists) return false\n if (snapshot.get('status') === 'confirmed') return false\n // \"Already processed\" is WIDER than `confirmed` once money can\n // flow back out (AGL-2315). A refunded booking is moved off\n // `confirmed`, so a redelivery arriving afterwards would otherwise\n // read as unprocessed and re-confirm an appointment the merchant\n // has already cancelled and refunded — re-sending the guest a\n // confirmation for it and re-metering the email. Any evidence this\n // handler has run before is the real test, and a refund counter or\n // a recorded PaymentIntent is exactly that evidence.\n if (\n Number(snapshot.get('refundedCents') ?? 0) > 0 ||\n snapshot.get('paymentIntentId')\n ) {\n return false\n }\n booking = (snapshot.data() as any) ?? {}\n transaction.set(\n bookingRef,\n {\n status: 'confirmed',\n paidAmountCents,\n // WHICH TAX REGIME THIS BOOKING CARRIED (AGL-2028), on the\n // document the merchant reads — the stamp every other\n // storefront money door already carries (AGL-2451).\n //\n // DERIVED from the one shared derivation, never a constant.\n // A hand-written \"manual when the metadata says so\" would be\n // a second rule that can drift from the `storefrontTaxCollected`\n // row filed for the same Stripe id, which is the exact\n // disagreement AGL-2451 exists to prevent.\n //\n // TWO-ARGUMENT, because the manual line makes Stripe report\n // `total_details.amount_tax: 0` on a booking that really did\n // charge the client tax; the one-argument form would stamp\n // `none` on precisely those. `absent` remains a fourth state\n // meaning \"recorded before this shipped\".\n taxMode: pluginTaxProfile().taxModeOf(object, taxCents),\n // The figure itself. Absent rather than a defaulted `0` — a\n // zero written through `merge` is the AGL-1758 shape, and\n // this handler can re-enter (see the widened guard above).\n ...(taxCents > 0 ? { taxCents } : {}),\n // The refund handles (AGL-2315). Written inside the same\n // transaction as the status, so a booking can never read\n // `confirmed` while the means to refund it are missing.\n ...(paymentIntentId ? { paymentIntentId } : {}),\n feeCents: chargedFeeCents,\n // WHICH CHECKOUT PAID FOR THIS (AGL-2481). The guest's browser\n // comes back from Stripe holding a session id and nothing\n // else — a booking id was never in the return URL — so this is\n // what lets `booking-analytics.ts` answer \"what did I just\n // buy?\" for the merchant-side `purchase`, and it is written\n // here because the session id first exists on this event.\n //\n // Inside the transaction with the status on purpose: the\n // lookup refuses anything that is not `confirmed`, so a\n // booking can never be findable by session id while reading as\n // unpaid.\n ...(object?.id ? { checkoutSessionId: String(object.id) } : {}),\n // NOTE the absent `refundedCents: 0`. Seeding it here would be\n // the AGL-1758 shape: a defaulted field written through\n // `merge` destroying the real one. This handler can re-enter\n // after a refund has already accumulated a counter (see the\n // widened guard above), and a zero written back over it would\n // re-open the full amount for refunding a second time. Absent\n // IS zero — every reader coerces with `?? 0`.\n expiresAtMs: firebaseAdmin.firestore.FieldValue.delete(),\n confirmedAt:\n firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n return true\n },\n )\n if (!confirmedNow) return\n // THE RECORD'S MEETING (AGL-2660). Payment is what confirms a paid\n // booking, so this is where it reaches the CRM: the meeting on the\n // record's timeline and, when the service asks, the follow-up task.\n // Inside the idempotency gate for the same reason the GA4 purchase\n // below is — a redelivery must not file a second meeting — and\n // through `after()` for the same reason too: this invocation is\n // frozen the moment the response is sent (AGL-2327). Never thrown:\n // the writer swallows its own failures, and the catch is for the\n // scheduling itself.\n after(() =>\n fileBookingOnCrm(firestore, {\n hostId: String(hostId),\n bookingId: String(bookingId),\n booking,\n }).catch(() => undefined),\n )\n // AGLYN'S REVENUE ON THIS BOOKING (AGL-2481) — into OUR GA4 property.\n //\n // ## Why it is inside the idempotency gate rather than beside it\n //\n // This sits AFTER `if (!confirmedNow) return` and that placement is the\n // whole guard. Stripe redelivers `checkout.session.completed` for up to\n // three days after any 500, and this endpoint 500s on purpose; a\n // redelivery re-enters this handler, the transaction above sees a\n // `confirmed` status (or a refund counter, or a recorded PaymentIntent)\n // and answers false, and everything below — the contact, the email, and\n // now this — is skipped. A `purchase` fired before that gate, or beside\n // it, would inflate our own reported revenue by the redelivery rate.\n //\n // `transaction_id` is the checkout session id, which is the same key\n // the guard turns on, so GA4's own de-duplication is a second\n // independent line of defence: even if the Firestore guard were ever\n // bypassed, GA would collapse the repeats.\n //\n // ## Platform NET, and the fee is the measured one\n //\n // `value` is our fee and not the $95 the guest paid — the AGL-1639\n // settlement. This is our property, the subscription and marketplace\n // `purchase` events in it already mean \"what Aglyn was paid\", and a\n // gross booking figure sitting beside them would make every combined\n // total, ARPA and revenue audience wrong. The merchant's own gross goes\n // to the MERCHANT's property, client-side, and the two never meet.\n //\n // The figure comes off the session's `metadata.feeCents` — the fee\n // Stripe actually charged as `application_fee_amount` — never from the\n // plan's rate re-applied here. See `bookingPlatformNetCents`.\n //\n // ## Scheduling and failure posture\n //\n // `after()`, never a bare `void` (AGL-2327/AGL-2346): this runs inside\n // the console's `/api/billing/webhook` invocation, which is frozen the\n // moment the response is sent, so fire-and-forget work scheduled any\n // other way simply does not run — the exact way marketplace revenue\n // reported to nothing. And `.catch()`, because a throw here would\n // un-claim the Stripe event and turn a missed analytics hit into a\n // repeated billing side effect.\n if (shouldSendBookingPlatformPurchase(object)) {\n const netCents = bookingPlatformNetCents(object)\n after(() =>\n sendGa4Purchase({\n transactionId: String(object?.id ?? ''),\n value: netCents / 100,\n currency: String(object?.currency ?? 'usd'),\n items: [\n {\n // Ids only, no merchant free text. A service name is\n // merchant-authored and one edit away from carrying a\n // person's name into a dimension in OUR property; the id\n // already identifies it. The merchant's own property gets\n // the name, because there it is their own content.\n item_id: String(booking['serviceId'] ?? bookingId),\n item_name: String(booking['serviceId'] ?? bookingId),\n item_category: 'booking',\n // GA expects the items to sum to `value`; one item, one\n // price, and that price is our fee — not the service's.\n price: netCents / 100,\n quantity: 1,\n },\n ],\n // Booking sessions carry no `ga_client_id` today — nothing\n // captures one on a tenant site — so this is read for the day\n // one is stamped and the seed below is what actually resolves.\n clientId: object?.metadata?.ga_client_id,\n // ONE PAYING GUEST, ONE SYNTHETIC USER. Seeded from Stripe's\n // customer where there is one, and otherwise from the guest's\n // email, which `server.ts` passes as `customer_email` and Stripe\n // echoes on `customer_details`. The seed is hashed inside\n // `synthesizeClientId` and never transmitted.\n //\n // The booking id is the LAST resort deliberately: it is unique\n // per appointment, so seeding from it would turn a regular\n // client into a crowd of one-purchase strangers and make ARPA\n // nonsense — precisely what that helper's determinism exists to\n // prevent. Without any seed at all `sendGa4Purchase` returns\n // `no-client-id` and the event is silently never sent, which is\n // the void this whole change exists to close.\n stripeCustomerId:\n String(object?.customer ?? '') ||\n String(object?.customer_details?.email ?? '') ||\n String(bookingId),\n }).catch(() => undefined),\n )\n }\n // Contacts ingestion (AGL-1755). `server.ts` captures the contact at\n // REQUEST time, which is the right place — the booking is written\n // `pendingPayment` and at that moment no money has moved, so passing an\n // amount there would be a lie. But payment completes HERE, and this\n // handler never returned to the contact, so a paid booking's money\n // reached the booking document and nothing else. The same structural\n // shape as AGL-1748's draft branch: the completion handler does\n // everything except the contact.\n //\n // `source` stays `'booking'` — the same source the request-time capture\n // used — so the `sources` map still says where this person came from\n // and no schema change is needed to keep service revenue\n // distinguishable from product revenue. The second interaction is\n // deliberate rather than a duplicate: \"Booked X\" and \"Paid for X\" are\n // two real events, and `mergeContactInteraction` does not dedupe on\n // `refId`.\n //\n // The amount is `amount_total`, the money that moved, per AGL-1698 and\n // AGL-1711 — the booking document stores no price to re-derive it from.\n if (booking['email']) {\n void recordCapturedContact({\n orgId: '',\n hostId: String(hostId),\n identity: {\n email: booking['email'],\n ...(booking['name'] ? { name: String(booking['name']) } : {}),\n },\n // Paid, so a relationship (AGL-3232): the lead the request-time\n // capture filed for the address is closed onto the contact.\n surface: 'relationship',\n // The phone the booking form asked for (AGL-3493), onto the\n // contact only where it holds none — as the request-time\n // capture put it on the lead.\n ...(readBookingPhone(booking['phone'])\n ? { profileFill: { phone: readBookingPhone(booking['phone']) } }\n : {}),\n // Paid, so a customer (AGL-2612) — the request-time capture\n // said `lead`, and this is the door that has seen the money.\n lifecycleFloor: 'customer',\n // The SERVICE, not the charge (AGL-2028). `amount_total` now\n // includes the merchant's service tax where they set one, and\n // tax is collected for an authority rather than earned — booking\n // it as lifetime value would over-state what every service\n // client is worth. Identical to `paidAmountCents` on the default\n // store, which sets no rate.\n ...(serviceCents > 0 ? { purchaseCents: serviceCents } : {}),\n interaction: {\n source: 'booking',\n refId: String(bookingId),\n summary: `Paid for \"${String(\n booking['serviceName'] ?? 'a service',\n ).slice(0, 60)}\" ($${(serviceCents / 100).toFixed(2)})`,\n },\n })\n }\n // In the booking's own zone, and named (AGL-3432): a formatter with\n // no zone reads in the server's — UTC — and tells a paying guest the\n // wrong hour, and an empty zone prints \"()\".\n const timezone = await bookingTimeZoneFor(\n firestore,\n String(hostId),\n booking,\n )\n const when = formatBookingWhen(Number(booking['startsAtMs']), timezone)\n const paid = `$${(paidAmountCents / 100).toFixed(2)}`\n // The site's managers hear of a PAID booking too (AGL-3500): the\n // free path tells them when the request lands, and a paid one is\n // only a booking once this event has cleared the charge, so this is\n // its moment. Who, what, when, what was paid, and the phone and\n // address the service asked for.\n const contactLines = bookingContactLines(booking)\n void notifyHostManagers(String(hostId), {\n type: 'content.booking',\n title: 'New booking on {site}',\n body:\n `${String(booking['name'] ?? '') || 'A guest'}` +\n (booking['email'] ? ` (${String(booking['email'])})` : '') +\n ` booked ${String(booking['serviceName'] ?? '') || 'a service'} ` +\n `on {site} for ${when} (${timezone}). ${paid} was paid.` +\n (contactLines.length ? ` ${contactLines.join('. ')}.` : ''),\n link: `/${hostId}/bookings`,\n })\n // Confirmation email now that payment cleared (env-gated inside\n // sendEmail, which no-ops when Resend isn't configured).\n if (booking['email']) {\n const fallbackText =\n `Hi ${booking['name'] ?? ''},\\n\\nPayment received — ` +\n `\"${booking['serviceName'] ?? 'your booking'}\" is ` +\n `confirmed for ${when} (${timezone}). You paid ${paid}.` +\n `\\n\\nReference: ${bookingId}`\n // Site-owner-designed template when published (AGL-770); null keeps\n // the built-in copy.\n const designed = await renderHostEmailWithTokens(\n firebaseAdmin.app().firestore(),\n String(hostId),\n 'booking-confirmed',\n {\n name: String(booking['name'] ?? ''),\n 'service.name': String(booking['serviceName'] ?? ''),\n when,\n timezone,\n 'booking.payment': `You paid ${paid}.`,\n 'booking.ref': String(bookingId),\n },\n )\n // White-label sender identity (White-Label Phase 3), matching the\n // free-booking path — the store's brand via the one shared resolver.\n const branding = resolveBrandingProfile(\n (await getOrgForHost(String(hostId)).catch(() => null))?.org as never,\n )\n await sendEmail({\n to: String(booking['email']),\n subject:\n designed?.subject ??\n `Booking confirmed: ${booking['serviceName'] ?? ''}`,\n text: designed?.text || fallbackText,\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(String(hostId)),\n audience: 'tenant',\n context: 'paid booking confirmation',\n // Owed to the recipient by their own booking: the phishing\n // screen's soft rules never hold it (AGL-3356).\n owedFor: 'booking',\n })\n // Cost meter (AGL-1438), matching the free-booking path.\n // Transactional: the guest has already paid.\n await meterHostEmail(String(hostId))\n }\n }\n }\n}\n"],"names":["after","resolveBrandingProfile","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","notifyHostManagers","notifyRiskEvent","renderHostEmailWithTokens","sendGa4Purchase","paymentRiskEventFrom","recordPaymentRiskOnRecord","settlePaymentRiskDisputeOnRecord","reverseDestinationTransfer","recordCapturedContact","bookingPlatformNetCents","shouldSendBookingPlatformPurchase","sendEmail","pluginTaxProfile","fileBookingOnCrm","bookingTimeZoneFor","formatBookingWhen","bookingContactLines","readBookingPhone","GRPC_FAILED_PRECONDITION","findBookingForPayment","paymentIntentId","matches","app","firestore","collectionGroup","where","limit","get","catch","error","code","console","docs","length","reverseBookingSellerShare","bookingRef","dispute","disputeId","String","id","amountCents","Math","max","round","Number","amount","cents","settled","prior","failedReason","outcome","kind","chargeId","charge","metadata","bookingId","record","reversedTransferCents","reversalId","transferReversalId","atMs","Date","now","reason","owedCents","wrote","runTransaction","transaction","fresh","exists","current","set","disputeReversal","merge","bookingsBillingWebhookHandler","type","object","risk","booking","hostId","ref","parent","signal","subjectLabel","link","notificationType","isFinite","toFixed","evidenceDueByMs","evidence_details","due_by","notifyRisk","claimed","payment_intent","status","recovered","recoveredText","guest","trim","service","title","body","undefined","payment_status","collection","doc","paidAmountCents","amount_total","taxCents","serviceCents","chargedFeeCents","feeCents","confirmedNow","snapshot","data","taxMode","taxModeOf","checkoutSessionId","expiresAtMs","FieldValue","delete","confirmedAt","serverTimestamp","netCents","transactionId","value","currency","items","item_id","item_name","item_category","price","quantity","clientId","ga_client_id","stripeCustomerId","customer","customer_details","email","orgId","identity","name","surface","profileFill","phone","lifecycleFloor","purchaseCents","interaction","source","refId","summary","slice","timezone","when","paid","contactLines","join","fallbackText","designed","branding","org","to","subject","text","html","fromName","sendingIdentity","audience","context","owedFor"],"mappings":"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"}
@@ -94,7 +94,10 @@ import { bookingFollowUpDueMs, bookingFollowUpTitle, bookingMeetingBody, parseBo
94
94
  body: bookingMeetingBody({
95
95
  serviceName,
96
96
  startsAtMs,
97
- timezone
97
+ timezone,
98
+ // What the service asked the booker for (AGL-3493).
99
+ phone: booking['phone'],
100
+ address: booking['address']
98
101
  }),
99
102
  byUid: '',
100
103
  dedupeKey: `booking:${bookingId}`
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/server/booking-crm.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 */\nimport { isHostPluginEnabled } from '@aglyn/aglyn/plugin-manager/enabled-plugins'\nimport {\n pluginRecordTimelineWriter,\n type PluginRecordWrite,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport { getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport {\n bookingFollowUpDueMs,\n bookingFollowUpTitle,\n bookingMeetingBody,\n parseBookingRecordRef,\n} from '../model/booking-record'\nimport type { HostBookingService } from '../model'\n\n/** What became of a booking's filing on the workspace's records. */\nexport type BookingRecordOutcome =\n | {\n filed: false\n reason:\n /** No plugin keeps records for this workspace. */\n | 'no-record-system'\n /** The plugin that keeps records does not run on this site. */\n | 'record-system-off'\n /** The service asks for neither a meeting nor a follow-up. */\n | 'nothing-to-file'\n /** A read threw before anything was asked; logged. */\n | 'failed'\n }\n | {\n filed: true\n /** The record system's answer for the meeting, or `null` when none was asked for. */\n meeting: PluginRecordWrite | null\n /** Its answer for the follow-up, or `null` when the service asks for none. */\n followUp: PluginRecordWrite | null\n }\n\n/**\n * THE BOOKING'S WAY BACK TO THE RECORD IT CAME FROM (AGL-2660).\n *\n * Two doors write a confirmed booking: the create route, for a free\n * service, and the payment webhook, for a paid one once the charge clears.\n * Both call this with what they hold — the row as written and, when they\n * already read it, the service — and it files what the service asks for on\n * the workspace's records, through the core's record-timeline seam:\n *\n * - a `meeting` entry at the slot, naming the service and the time in the\n * service's own zone, unless the service switched it off;\n * - a follow-up task one business day after the slot, for whoever holds\n * the record, when the service asks for one.\n *\n * The record is the record system's to find: the one the booking link was\n * dropped from (`crmRef`, carried back as it was handed over), otherwise the\n * person at the booker's address, as this site sees them. Both entries are\n * keyed by the booking, so the webhook's redeliveries and a second door file\n * nothing twice; a full activity log refuses the meeting and still takes the\n * follow-up. Nothing is filed on a site that has switched the record system\n * off, and the record system refuses a plan without it.\n *\n * **Never throws**: the guest has their slot whatever the record system did,\n * so a failure here is an outcome the caller may log, never an error into\n * the request or the webhook.\n */\nexport async function fileBookingOnCrm(\n firestore: FirebaseFirestore.Firestore,\n input: {\n hostId: string\n bookingId: string\n /** The booking row as written — or as read back by the webhook. */\n booking: Record<string, unknown>\n /** The service, when the caller already read it. Read here otherwise. */\n service?: HostBookingService | null\n },\n): Promise<BookingRecordOutcome> {\n const { hostId, bookingId, booking } = input\n try {\n const records = pluginRecordTimelineWriter()\n if (!records) return { filed: false, reason: 'no-record-system' }\n const owner = await getOrgForHost(hostId)\n if (!owner?.orgId) return { filed: false, reason: 'failed' }\n const hostRef = firestore.collection('hosts').doc(hostId)\n const host = (await hostRef.get()).data() as\n | { disabledPlugins?: string[]; enabledPlugins?: string[] }\n | undefined\n if (!isHostPluginEnabled(owner.org as never, host ?? null, records.pluginId)) {\n return { filed: false, reason: 'record-system-off' }\n }\n const serviceId = String(booking['serviceId'] ?? '')\n const service =\n input.service !== undefined\n ? input.service\n : serviceId\n ? ((await hostRef.collection('services').doc(serviceId).get()).data() as\n | HostBookingService\n | undefined) ?? null\n : null\n const wantsMeeting = service?.crmMeetingActivity !== false\n const wantsFollowUp = service?.crmFollowUpTask === true\n if (!wantsMeeting && !wantsFollowUp) return { filed: false, reason: 'nothing-to-file' }\n\n const serviceName = String(service?.name ?? booking['serviceName'] ?? '').trim()\n const timezone = service?.timezone || undefined\n const startsAtMs = Number(booking['startsAtMs'] ?? 0)\n const endsAtMs = Number(booking['endsAtMs'] ?? 0)\n const context = {\n orgId: owner.orgId,\n hostId,\n link: {\n record: parseBookingRecordRef(booking['crmRef']),\n email: String(booking['email'] ?? ''),\n },\n sourcePluginId: BUNDLE_ID,\n }\n\n const meeting = wantsMeeting\n ? await records.writer.logActivity({\n ...context,\n kind: 'meeting',\n // When it HAPPENS, which is the slot — not when it was booked.\n atMs: startsAtMs,\n body: bookingMeetingBody({ serviceName, startsAtMs, timezone }),\n byUid: '',\n dedupeKey: `booking:${bookingId}`,\n })\n : null\n const followUp = wantsFollowUp\n ? await records.writer.createTask({\n ...context,\n dedupeKey: `booking-follow-up:${bookingId}`,\n title: bookingFollowUpTitle(serviceName),\n kind: 'todo',\n dueAtMs: bookingFollowUpDueMs(endsAtMs, timezone),\n // Whoever holds the record the booking lands on.\n assigneeUid: null,\n createdByUid: '',\n })\n : null\n return { filed: true, meeting, followUp }\n } catch (error) {\n console.error('[bookings] the booking could not be filed on its record', hostId, bookingId, error)\n return { filed: false, reason: 'failed' }\n }\n}\n"],"names":["isHostPluginEnabled","pluginRecordTimelineWriter","getOrgForHost","BUNDLE_ID","bookingFollowUpDueMs","bookingFollowUpTitle","bookingMeetingBody","parseBookingRecordRef","fileBookingOnCrm","firestore","input","hostId","bookingId","booking","service","records","filed","reason","owner","orgId","hostRef","collection","doc","host","get","data","org","pluginId","serviceId","String","undefined","wantsMeeting","crmMeetingActivity","wantsFollowUp","crmFollowUpTask","serviceName","name","trim","timezone","startsAtMs","Number","endsAtMs","context","link","record","email","sourcePluginId","meeting","writer","logActivity","kind","atMs","body","byUid","dedupeKey","followUp","createTask","title","dueAtMs","assigneeUid","createdByUid","error","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GACD,SAASA,mBAAmB,QAAQ,8CAA6C;AACjF,SACEC,0BAA0B,QAErB,qDAAoD;AAC3D,SAASC,aAAa,QAAQ,2BAA0B;AACxD,SAASC,SAAS,QAAQ,gCAA4B;AACtD,SACEC,oBAAoB,EACpBC,oBAAoB,EACpBC,kBAAkB,EAClBC,qBAAqB,QAChB,6BAAyB;AAyBhC;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,eAAeC,iBACpBC,SAAsC,EACtCC,KAOC;IAED,MAAM,EAAEC,MAAM,EAAEC,SAAS,EAAEC,OAAO,EAAE,GAAGH;IACvC,IAAI;YAYuBG,oBAKhB,OAQkBC,aAEDD,qBACFA,mBAMNA;QAjClB,MAAME,UAAUd;QAChB,IAAI,CAACc,SAAS,OAAO;YAAEC,OAAO;YAAOC,QAAQ;QAAmB;QAChE,MAAMC,QAAQ,MAAMhB,cAAcS;QAClC,IAAI,EAACO,yBAAAA,MAAOC,KAAK,GAAE,OAAO;YAAEH,OAAO;YAAOC,QAAQ;QAAS;QAC3D,MAAMG,UAAUX,UAAUY,UAAU,CAAC,SAASC,GAAG,CAACX;QAClD,MAAMY,OAAO,AAAC,CAAA,MAAMH,QAAQI,GAAG,EAAC,EAAGC,IAAI;QAGvC,IAAI,CAACzB,oBAAoBkB,MAAMQ,GAAG,EAAWH,eAAAA,OAAQ,MAAMR,QAAQY,QAAQ,GAAG;YAC5E,OAAO;gBAAEX,OAAO;gBAAOC,QAAQ;YAAoB;QACrD;QACA,MAAMW,YAAYC,QAAOhB,qBAAAA,OAAO,CAAC,YAAY,YAApBA,qBAAwB;QACjD,MAAMC,UACJJ,MAAMI,OAAO,KAAKgB,YACdpB,MAAMI,OAAO,GACbc,aACG,QAAA,AAAC,CAAA,MAAMR,QAAQC,UAAU,CAAC,YAAYC,GAAG,CAACM,WAAWJ,GAAG,EAAC,EAAGC,IAAI,cAAhE,QAEiB,OAClB;QACR,MAAMM,eAAejB,CAAAA,2BAAAA,QAASkB,kBAAkB,MAAK;QACrD,MAAMC,gBAAgBnB,CAAAA,2BAAAA,QAASoB,eAAe,MAAK;QACnD,IAAI,CAACH,gBAAgB,CAACE,eAAe,OAAO;YAAEjB,OAAO;YAAOC,QAAQ;QAAkB;QAEtF,MAAMkB,cAAcN,QAAOf,gBAAAA,2BAAAA,QAASsB,IAAI,oBAAIvB,OAAO,CAAC,cAAc,YAAvCC,OAA2C,IAAIuB,IAAI;QAC9E,MAAMC,WAAWxB,CAAAA,2BAAAA,QAASwB,QAAQ,KAAIR;QACtC,MAAMS,aAAaC,QAAO3B,sBAAAA,OAAO,CAAC,aAAa,YAArBA,sBAAyB;QACnD,MAAM4B,WAAWD,QAAO3B,oBAAAA,OAAO,CAAC,WAAW,YAAnBA,oBAAuB;QAC/C,MAAM6B,UAAU;YACdvB,OAAOD,MAAMC,KAAK;YAClBR;YACAgC,MAAM;gBACJC,QAAQrC,sBAAsBM,OAAO,CAAC,SAAS;gBAC/CgC,OAAOhB,QAAOhB,iBAAAA,OAAO,CAAC,QAAQ,YAAhBA,iBAAoB;YACpC;YACAiC,gBAAgB3C;QAClB;QAEA,MAAM4C,UAAUhB,eACZ,MAAMhB,QAAQiC,MAAM,CAACC,WAAW,CAAC,aAC5BP;YACHQ,MAAM;YACN,+DAA+D;YAC/DC,MAAMZ;YACNa,MAAM9C,mBAAmB;gBAAE6B;gBAAaI;gBAAYD;YAAS;YAC7De,OAAO;YACPC,WAAW,CAAC,QAAQ,EAAE1C,WAAW;cAEnC;QACJ,MAAM2C,WAAWtB,gBACb,MAAMlB,QAAQiC,MAAM,CAACQ,UAAU,CAAC,aAC3Bd;YACHY,WAAW,CAAC,kBAAkB,EAAE1C,WAAW;YAC3C6C,OAAOpD,qBAAqB8B;YAC5Be,MAAM;YACNQ,SAAStD,qBAAqBqC,UAAUH;YACxC,iDAAiD;YACjDqB,aAAa;YACbC,cAAc;cAEhB;QACJ,OAAO;YAAE5C,OAAO;YAAM+B;YAASQ;QAAS;IAC1C,EAAE,OAAOM,OAAO;QACdC,QAAQD,KAAK,CAAC,2DAA2DlD,QAAQC,WAAWiD;QAC5F,OAAO;YAAE7C,OAAO;YAAOC,QAAQ;QAAS;IAC1C;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/server/booking-crm.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 */\nimport { isHostPluginEnabled } from '@aglyn/aglyn/plugin-manager/enabled-plugins'\nimport {\n pluginRecordTimelineWriter,\n type PluginRecordWrite,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport { getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport {\n bookingFollowUpDueMs,\n bookingFollowUpTitle,\n bookingMeetingBody,\n parseBookingRecordRef,\n} from '../model/booking-record'\nimport type { HostBookingService } from '../model'\n\n/** What became of a booking's filing on the workspace's records. */\nexport type BookingRecordOutcome =\n | {\n filed: false\n reason:\n /** No plugin keeps records for this workspace. */\n | 'no-record-system'\n /** The plugin that keeps records does not run on this site. */\n | 'record-system-off'\n /** The service asks for neither a meeting nor a follow-up. */\n | 'nothing-to-file'\n /** A read threw before anything was asked; logged. */\n | 'failed'\n }\n | {\n filed: true\n /** The record system's answer for the meeting, or `null` when none was asked for. */\n meeting: PluginRecordWrite | null\n /** Its answer for the follow-up, or `null` when the service asks for none. */\n followUp: PluginRecordWrite | null\n }\n\n/**\n * THE BOOKING'S WAY BACK TO THE RECORD IT CAME FROM (AGL-2660).\n *\n * Two doors write a confirmed booking: the create route, for a free\n * service, and the payment webhook, for a paid one once the charge clears.\n * Both call this with what they hold — the row as written and, when they\n * already read it, the service — and it files what the service asks for on\n * the workspace's records, through the core's record-timeline seam:\n *\n * - a `meeting` entry at the slot, naming the service and the time in the\n * service's own zone, unless the service switched it off;\n * - a follow-up task one business day after the slot, for whoever holds\n * the record, when the service asks for one.\n *\n * The record is the record system's to find: the one the booking link was\n * dropped from (`crmRef`, carried back as it was handed over), otherwise the\n * person at the booker's address, as this site sees them. Both entries are\n * keyed by the booking, so the webhook's redeliveries and a second door file\n * nothing twice; a full activity log refuses the meeting and still takes the\n * follow-up. Nothing is filed on a site that has switched the record system\n * off, and the record system refuses a plan without it.\n *\n * **Never throws**: the guest has their slot whatever the record system did,\n * so a failure here is an outcome the caller may log, never an error into\n * the request or the webhook.\n */\nexport async function fileBookingOnCrm(\n firestore: FirebaseFirestore.Firestore,\n input: {\n hostId: string\n bookingId: string\n /** The booking row as written — or as read back by the webhook. */\n booking: Record<string, unknown>\n /** The service, when the caller already read it. Read here otherwise. */\n service?: HostBookingService | null\n },\n): Promise<BookingRecordOutcome> {\n const { hostId, bookingId, booking } = input\n try {\n const records = pluginRecordTimelineWriter()\n if (!records) return { filed: false, reason: 'no-record-system' }\n const owner = await getOrgForHost(hostId)\n if (!owner?.orgId) return { filed: false, reason: 'failed' }\n const hostRef = firestore.collection('hosts').doc(hostId)\n const host = (await hostRef.get()).data() as\n | { disabledPlugins?: string[]; enabledPlugins?: string[] }\n | undefined\n if (!isHostPluginEnabled(owner.org as never, host ?? null, records.pluginId)) {\n return { filed: false, reason: 'record-system-off' }\n }\n const serviceId = String(booking['serviceId'] ?? '')\n const service =\n input.service !== undefined\n ? input.service\n : serviceId\n ? ((await hostRef.collection('services').doc(serviceId).get()).data() as\n | HostBookingService\n | undefined) ?? null\n : null\n const wantsMeeting = service?.crmMeetingActivity !== false\n const wantsFollowUp = service?.crmFollowUpTask === true\n if (!wantsMeeting && !wantsFollowUp) return { filed: false, reason: 'nothing-to-file' }\n\n const serviceName = String(service?.name ?? booking['serviceName'] ?? '').trim()\n const timezone = service?.timezone || undefined\n const startsAtMs = Number(booking['startsAtMs'] ?? 0)\n const endsAtMs = Number(booking['endsAtMs'] ?? 0)\n const context = {\n orgId: owner.orgId,\n hostId,\n link: {\n record: parseBookingRecordRef(booking['crmRef']),\n email: String(booking['email'] ?? ''),\n },\n sourcePluginId: BUNDLE_ID,\n }\n\n const meeting = wantsMeeting\n ? await records.writer.logActivity({\n ...context,\n kind: 'meeting',\n // When it HAPPENS, which is the slot — not when it was booked.\n atMs: startsAtMs,\n body: bookingMeetingBody({\n serviceName,\n startsAtMs,\n timezone,\n // What the service asked the booker for (AGL-3493).\n phone: booking['phone'],\n address: booking['address'],\n }),\n byUid: '',\n dedupeKey: `booking:${bookingId}`,\n })\n : null\n const followUp = wantsFollowUp\n ? await records.writer.createTask({\n ...context,\n dedupeKey: `booking-follow-up:${bookingId}`,\n title: bookingFollowUpTitle(serviceName),\n kind: 'todo',\n dueAtMs: bookingFollowUpDueMs(endsAtMs, timezone),\n // Whoever holds the record the booking lands on.\n assigneeUid: null,\n createdByUid: '',\n })\n : null\n return { filed: true, meeting, followUp }\n } catch (error) {\n console.error('[bookings] the booking could not be filed on its record', hostId, bookingId, error)\n return { filed: false, reason: 'failed' }\n }\n}\n"],"names":["isHostPluginEnabled","pluginRecordTimelineWriter","getOrgForHost","BUNDLE_ID","bookingFollowUpDueMs","bookingFollowUpTitle","bookingMeetingBody","parseBookingRecordRef","fileBookingOnCrm","firestore","input","hostId","bookingId","booking","service","records","filed","reason","owner","orgId","hostRef","collection","doc","host","get","data","org","pluginId","serviceId","String","undefined","wantsMeeting","crmMeetingActivity","wantsFollowUp","crmFollowUpTask","serviceName","name","trim","timezone","startsAtMs","Number","endsAtMs","context","link","record","email","sourcePluginId","meeting","writer","logActivity","kind","atMs","body","phone","address","byUid","dedupeKey","followUp","createTask","title","dueAtMs","assigneeUid","createdByUid","error","console"],"mappings":"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"}
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { PluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure';
18
+ /**
19
+ * BOOKINGS' SHARE OF A PERSON ERASURE (AGL-2623, AGL-3080).
20
+ *
21
+ * A booking is the merchant's record of an appointment, kept as a record of
22
+ * what was sold and when. So the person is taken OFF every booking they made
23
+ * on a site of the workspace — their email, name and phone, and the street
24
+ * address a service asked them for (AGL-3493) — and a stamp says when; the
25
+ * booking itself, its service and its time stay.
26
+ *
27
+ * A DRY RUN counts the bookings and writes nothing.
28
+ */
29
+ type Firestore = FirebaseFirestore.Firestore;
30
+ export interface BookingsPersonEraserDeps {
31
+ firestore(): Firestore;
32
+ }
33
+ export declare function createBookingsPersonEraser(deps: BookingsPersonEraserDeps): PluginPersonEraser;
34
+ /** The eraser over the platform's own Firestore, as the declarations register it. */
35
+ export declare const bookingsPersonEraser: PluginPersonEraser;
36
+ export {};
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { updateWhereEquals } from "@aglyn/tenant-data-admin/server/paged-sweeps";
17
+ import { FieldValue } from "firebase-admin/firestore";
18
+ import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
19
+ const LABEL = 'bookings person eraser';
20
+ export function createBookingsPersonEraser(deps) {
21
+ return async ({ orgId, email, dryRun, atMs })=>{
22
+ const db = deps.firestore();
23
+ const hosts = await db.collection('hosts').where('orgId', '==', orgId).get();
24
+ let bookings = 0;
25
+ for (const host of hosts.docs){
26
+ const made = host.ref.collection('bookings');
27
+ if (dryRun) {
28
+ bookings += (await made.where('email', '==', email).get()).size;
29
+ continue;
30
+ }
31
+ bookings += await updateWhereEquals(db, made, 'email', email, {
32
+ email: null,
33
+ name: FieldValue.delete(),
34
+ phone: FieldValue.delete(),
35
+ address: FieldValue.delete(),
36
+ customerErasedAtMs: atMs
37
+ }, LABEL);
38
+ }
39
+ return {
40
+ bookings
41
+ };
42
+ };
43
+ }
44
+ /** The eraser over the platform's own Firestore, as the declarations register it. */ export const bookingsPersonEraser = (request)=>createBookingsPersonEraser({
45
+ firestore: ()=>firebaseAdmin.app().firestore()
46
+ })(request);
47
+
48
+ //# sourceMappingURL=person-eraser.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/server/person-eraser.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 type {\n PluginPersonEraser,\n PluginPersonErasureReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { updateWhereEquals } from '@aglyn/tenant-data-admin/server/paged-sweeps'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\n\n/**\n * BOOKINGS' SHARE OF A PERSON ERASURE (AGL-2623, AGL-3080).\n *\n * A booking is the merchant's record of an appointment, kept as a record of\n * what was sold and when. So the person is taken OFF every booking they made\n * on a site of the workspace — their email, name and phone, and the street\n * address a service asked them for (AGL-3493) — and a stamp says when; the\n * booking itself, its service and its time stay.\n *\n * A DRY RUN counts the bookings and writes nothing.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface BookingsPersonEraserDeps {\n firestore(): Firestore\n}\n\nconst LABEL = 'bookings person eraser'\n\nexport function createBookingsPersonEraser(deps: BookingsPersonEraserDeps): PluginPersonEraser {\n return async ({ orgId, email, dryRun, atMs }): Promise<PluginPersonErasureReport> => {\n const db = deps.firestore()\n const hosts = await db.collection('hosts').where('orgId', '==', orgId).get()\n let bookings = 0\n for (const host of hosts.docs) {\n const made = host.ref.collection('bookings')\n if (dryRun) {\n bookings += (await made.where('email', '==', email).get()).size\n continue\n }\n bookings += await updateWhereEquals(\n db,\n made,\n 'email',\n email,\n {\n email: null,\n name: FieldValue.delete(),\n phone: FieldValue.delete(),\n address: FieldValue.delete(),\n customerErasedAtMs: atMs,\n },\n LABEL,\n )\n }\n return { bookings }\n }\n}\n\n/** The eraser over the platform's own Firestore, as the declarations register it. */\nexport const bookingsPersonEraser: PluginPersonEraser = (request) =>\n createBookingsPersonEraser({ firestore: () => firebaseAdmin.app().firestore() })(request)\n"],"names":["updateWhereEquals","FieldValue","firebaseAdmin","LABEL","createBookingsPersonEraser","deps","orgId","email","dryRun","atMs","db","firestore","hosts","collection","where","get","bookings","host","docs","made","ref","size","name","delete","phone","address","customerErasedAtMs","bookingsPersonEraser","request","app"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAMD,SAASA,iBAAiB,QAAQ,+CAA8C;AAChF,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,aAAa,QAAQ,iDAAgD;AAoB9E,MAAMC,QAAQ;AAEd,OAAO,SAASC,2BAA2BC,IAA8B;IACvE,OAAO,OAAO,EAAEC,KAAK,EAAEC,KAAK,EAAEC,MAAM,EAAEC,IAAI,EAAE;QAC1C,MAAMC,KAAKL,KAAKM,SAAS;QACzB,MAAMC,QAAQ,MAAMF,GAAGG,UAAU,CAAC,SAASC,KAAK,CAAC,SAAS,MAAMR,OAAOS,GAAG;QAC1E,IAAIC,WAAW;QACf,KAAK,MAAMC,QAAQL,MAAMM,IAAI,CAAE;YAC7B,MAAMC,OAAOF,KAAKG,GAAG,CAACP,UAAU,CAAC;YACjC,IAAIL,QAAQ;gBACVQ,YAAY,AAAC,CAAA,MAAMG,KAAKL,KAAK,CAAC,SAAS,MAAMP,OAAOQ,GAAG,EAAC,EAAGM,IAAI;gBAC/D;YACF;YACAL,YAAY,MAAMhB,kBAChBU,IACAS,MACA,SACAZ,OACA;gBACEA,OAAO;gBACPe,MAAMrB,WAAWsB,MAAM;gBACvBC,OAAOvB,WAAWsB,MAAM;gBACxBE,SAASxB,WAAWsB,MAAM;gBAC1BG,oBAAoBjB;YACtB,GACAN;QAEJ;QACA,OAAO;YAAEa;QAAS;IACpB;AACF;AAEA,mFAAmF,GACnF,OAAO,MAAMW,uBAA2C,CAACC,UACvDxB,2BAA2B;QAAEO,WAAW,IAAMT,cAAc2B,GAAG,GAAGlB,SAAS;IAAG,GAAGiB,SAAQ"}
@@ -18,7 +18,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  // Leaf import, not the barrel: the specs in this library mock
19
19
  // `@aglyn/tenant-data-admin` wholesale, and a permissive stub would turn a
20
20
  // reversal that never happened green.
21
- import { reverseEmailAttributedRevenue } from "@aglyn/tenant-data-admin/server/email-revenue-attribution";
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
24
  /**
@@ -285,7 +285,7 @@ import { apiIdempotencyExpiry } from "@aglyn/aglyn/app-utils/api-idempotency";
285
285
  * Recorded beside the credit rather than subtracted from it, and swallowed
286
286
  * whole: the money has already left the merchant's account and the booking
287
287
  * already records it, so nothing here may fail a refund.
288
- */ await reverseEmailAttributedRevenue({
288
+ */ await reverseOrderConversion({
289
289
  hostId,
290
290
  orderId: bookingId,
291
291
  amountCents: refundCents,
@@ -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 { reverseEmailAttributedRevenue } from '@aglyn/tenant-data-admin/server/email-revenue-attribution'\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 reverseEmailAttributedRevenue({\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","reverseEmailAttributedRevenue","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'\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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"}