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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +3 -1
  2. package/package.json +11 -11
  3. package/src/lib/components/bookings-console-page.js +30 -4
  4. package/src/lib/components/bookings-console-page.js.map +1 -1
  5. package/src/lib/declarations.console-server.d.ts +3 -0
  6. package/src/lib/declarations.console-server.js +20 -0
  7. package/src/lib/declarations.console-server.js.map +1 -1
  8. package/src/lib/model/booking-events.d.ts +52 -0
  9. package/src/lib/model/booking-events.js +53 -0
  10. package/src/lib/model/booking-events.js.map +1 -0
  11. package/src/lib/model/booking-in-person.d.ts +72 -0
  12. package/src/lib/model/booking-in-person.js +71 -0
  13. package/src/lib/model/booking-in-person.js.map +1 -0
  14. package/src/lib/model/booking-manage.d.ts +73 -0
  15. package/src/lib/model/booking-manage.js +104 -0
  16. package/src/lib/model/booking-manage.js.map +1 -0
  17. package/src/lib/model/booking-view.d.ts +63 -0
  18. package/src/lib/model/booking-view.js +70 -0
  19. package/src/lib/model/booking-view.js.map +1 -0
  20. package/src/lib/server/api-v1/bookings.d.ts +33 -0
  21. package/src/lib/server/api-v1/bookings.js +103 -0
  22. package/src/lib/server/api-v1/bookings.js.map +1 -0
  23. package/src/lib/server/api-v1/openapi.d.ts +22 -0
  24. package/src/lib/server/api-v1/openapi.js +107 -0
  25. package/src/lib/server/api-v1/openapi.js.map +1 -0
  26. package/src/lib/server/billing-webhook.js +17 -0
  27. package/src/lib/server/billing-webhook.js.map +1 -1
  28. package/src/lib/server/booking-events.d.ts +40 -0
  29. package/src/lib/server/booking-events.js +50 -0
  30. package/src/lib/server/booking-events.js.map +1 -0
  31. package/src/lib/server/booking-manage.d.ts +93 -0
  32. package/src/lib/server/booking-manage.js +380 -0
  33. package/src/lib/server/booking-manage.js.map +1 -0
  34. package/src/lib/server/in-person-payment.d.ts +20 -0
  35. package/src/lib/server/in-person-payment.js +469 -0
  36. package/src/lib/server/in-person-payment.js.map +1 -0
  37. package/src/lib/server/refund.js +17 -1
  38. package/src/lib/server/refund.js.map +1 -1
  39. package/src/lib/server.js +30 -2
  40. package/src/lib/server.js.map +1 -1
  41. package/src/lib/tenant-emails.js +59 -0
  42. package/src/lib/tenant-emails.js.map +1 -1
@@ -0,0 +1,469 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { createHash } from "crypto";
18
+ import { firebaseAdmin, getOrgForHost } from "@aglyn/tenant-data-admin";
19
+ import { merchantAccountIsReady } from "@aglyn/tenant-data-admin/server/payment-provider";
20
+ import { checkEntitlement, resolveTransactionFeeCents } from "@aglyn/aglyn/server";
21
+ import { pluginTaxProfile } from "@aglyn/aglyn/plugin-manager/plugin-tax-profile";
22
+ import { bookingInPersonAmountProblem, bookingInPersonState } from "../model/booking-in-person.js";
23
+ import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
24
+ /*==========================================
25
+ * `POST /api/bookings/in-person-payment` (AGL-3618).
26
+ *
27
+ * Takes payment for a booking at the counter, on the card reader the native
28
+ * Aglyn POS app has connected (Tap to Pay or a Bluetooth reader). The server
29
+ * owns the money; the app only presents the card:
30
+ *
31
+ * - `start` prices the charge (the amount staff typed, plus the merchant's
32
+ * own service tax), and creates a card-present PaymentIntent: a
33
+ * DESTINATION charge to the merchant, settled on Aglyn's platform account
34
+ * with no `on_behalf_of` (ToS §10.7, AGL-3607), with the take and
35
+ * Stripe's card cost as `application_fee_amount` — exactly the fee a paid
36
+ * booking already carries online (`resolveTransactionFeeCents`, `service`).
37
+ * Returns the intent's client secret for the app's Terminal SDK.
38
+ * - `settle` re-reads the intent from Stripe (never trusting the app's word),
39
+ * captures an authorized one, and records the booking paid with the same
40
+ * fields an online payment writes, so the console's refund and the dispute
41
+ * handling work on it unchanged.
42
+ * - `cancel` releases an intent that was not collected.
43
+ *
44
+ * MANUAL capture, so a card that authorizes but whose booking was changed in
45
+ * between is released rather than charged. On-reader tipping is not offered
46
+ * here; the amount is the amount.
47
+ *
48
+ * Gate: a site `admin` or `editor`, and the owning org's `bookings` and
49
+ * `pos` entitlements (taking card-present payments is a POS capability).
50
+ *=========================================*/ const BOOKING_ID = /^[A-Za-z0-9_-]{1,128}$/;
51
+ const PAYMENT_INTENT_ID = /^pi_[A-Za-z0-9_]{6,128}$/;
52
+ function requestBody(req) {
53
+ if (typeof req.body === 'string') {
54
+ try {
55
+ const parsed = JSON.parse(req.body);
56
+ return parsed && typeof parsed === 'object' ? parsed : {};
57
+ } catch (unused) {
58
+ return {};
59
+ }
60
+ }
61
+ return req.body && typeof req.body === 'object' ? req.body : {};
62
+ }
63
+ /** Card-present payments: always in test mode, in live mode once switched on. */ export function inPersonCardsAvailable() {
64
+ var _process_env_STRIPE_SECRET_KEY, _process_env_STRIPE_TERMINAL_LIVE_ENABLED;
65
+ const key = String((_process_env_STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY) != null ? _process_env_STRIPE_SECRET_KEY : '');
66
+ if (!key) return false;
67
+ return key.startsWith('sk_test_') || String((_process_env_STRIPE_TERMINAL_LIVE_ENABLED = process.env.STRIPE_TERMINAL_LIVE_ENABLED) != null ? _process_env_STRIPE_TERMINAL_LIVE_ENABLED : '').toLowerCase() === 'true';
68
+ }
69
+ async function stripe(method, path, options = {}) {
70
+ var _options_params;
71
+ const form = new URLSearchParams();
72
+ for (const [key, value] of Object.entries((_options_params = options.params) != null ? _options_params : {})){
73
+ if (value !== undefined) form.append(key, String(value));
74
+ }
75
+ const query = method === 'GET' && form.toString() ? `?${form}` : '';
76
+ const response = await fetch(`https://api.stripe.com/v1/${path}${query}`, _extends({
77
+ method,
78
+ headers: _extends({
79
+ Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
80
+ 'Content-Type': 'application/x-www-form-urlencoded'
81
+ }, options.idempotencyKey ? {
82
+ 'Idempotency-Key': options.idempotencyKey
83
+ } : {})
84
+ }, method === 'POST' ? {
85
+ body: form.toString()
86
+ } : {}));
87
+ const body = await response.json().catch(()=>null);
88
+ return {
89
+ ok: response.ok,
90
+ status: response.status,
91
+ body
92
+ };
93
+ }
94
+ function stripeMessage(body, fallback) {
95
+ var _body_error;
96
+ const message = body == null ? void 0 : (_body_error = body.error) == null ? void 0 : _body_error.message;
97
+ return typeof message === 'string' && message ? message : fallback;
98
+ }
99
+ async function authorize(req, hostId) {
100
+ var _req_headers_authorization, _host_get, _ref;
101
+ var _this;
102
+ const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
103
+ const idToken = authorization.startsWith('Bearer ') ? authorization.slice(7) : '';
104
+ if (!idToken) return {
105
+ ok: false,
106
+ status: 401,
107
+ error: 'Unauthenticated'
108
+ };
109
+ if (!hostId) return {
110
+ ok: false,
111
+ status: 400,
112
+ error: 'Missing hostId'
113
+ };
114
+ let uid;
115
+ try {
116
+ uid = (await firebaseAdmin.app().auth().verifyIdToken(idToken)).uid;
117
+ } catch (error) {
118
+ if (!isRefusedIdToken(error)) throw error;
119
+ return {
120
+ ok: false,
121
+ status: 401,
122
+ error: 'Unauthenticated'
123
+ };
124
+ }
125
+ const host = await firebaseAdmin.app().firestore().collection('hosts').doc(hostId).get();
126
+ if (!host.exists) return {
127
+ ok: false,
128
+ status: 404,
129
+ error: 'Unknown site'
130
+ };
131
+ const role = ((_host_get = host.get('memberRoles')) != null ? _host_get : {})[uid];
132
+ if (role !== 'admin' && role !== 'editor') return {
133
+ ok: false,
134
+ status: 403,
135
+ error: 'Not permitted'
136
+ };
137
+ const org = (_ref = (_this = await getOrgForHost(hostId)) == null ? void 0 : _this.org) != null ? _ref : null;
138
+ if (!checkEntitlement(org, 'bookings') || !checkEntitlement(org, 'pos')) {
139
+ return {
140
+ ok: false,
141
+ status: 403,
142
+ error: 'Taking booking payments in person needs the POS plan.'
143
+ };
144
+ }
145
+ return {
146
+ ok: true,
147
+ staff: {
148
+ uid,
149
+ hostId,
150
+ org
151
+ }
152
+ };
153
+ }
154
+ export const bookingInPersonPaymentHandler = async (req, res)=>{
155
+ var _body_hostId, _body_bookingId, _body_action;
156
+ if (req.method !== 'POST') return res.status(405).json({
157
+ error: 'Method not allowed'
158
+ });
159
+ const body = requestBody(req);
160
+ const hostId = String((_body_hostId = body['hostId']) != null ? _body_hostId : '');
161
+ const bookingId = String((_body_bookingId = body['bookingId']) != null ? _body_bookingId : '');
162
+ const gate = await authorize(req, hostId);
163
+ if ('error' in gate) return res.status(gate.status).json({
164
+ error: gate.error
165
+ });
166
+ if (!BOOKING_ID.test(bookingId)) return res.status(400).json({
167
+ error: 'Missing bookingId'
168
+ });
169
+ if (!inPersonCardsAvailable()) {
170
+ return res.status(409).json({
171
+ error: 'Card readers are not available yet.'
172
+ });
173
+ }
174
+ const action = String((_body_action = body['action']) != null ? _body_action : '');
175
+ try {
176
+ switch(action){
177
+ case 'start':
178
+ return await start(gate.staff, bookingId, body, req, res);
179
+ case 'settle':
180
+ return await settle(gate.staff, bookingId, res);
181
+ case 'cancel':
182
+ return await cancel(gate.staff, bookingId, res);
183
+ default:
184
+ return res.status(400).json({
185
+ error: 'Unknown action'
186
+ });
187
+ }
188
+ } catch (error) {
189
+ console.error('[bookings/in-person-payment]', action, error);
190
+ return res.status(500).json({
191
+ error: 'The payment could not be updated. Check the booking before trying again.'
192
+ });
193
+ }
194
+ };
195
+ function bookingRef(hostId, bookingId) {
196
+ return firebaseAdmin.app().firestore().collection('hosts').doc(hostId).collection('bookings').doc(bookingId);
197
+ }
198
+ async function merchantAccount(org, hostId) {
199
+ var _ref, _profile_get;
200
+ const ownerUid = String((_ref = org == null ? void 0 : org['ownerUid']) != null ? _ref : '');
201
+ if (!ownerUid) return null;
202
+ const profile = await firebaseAdmin.app().firestore().collection('profiles').doc(ownerUid).get();
203
+ const accountId = String((_profile_get = profile.get('stripeAccountId')) != null ? _profile_get : '');
204
+ return merchantAccountIsReady({
205
+ accountId,
206
+ chargesEnabled: profile.get('stripeChargesEnabled'),
207
+ accountLivemode: profile.get('stripeAccountLivemode')
208
+ }, {
209
+ subject: `booking in-person host ${hostId}`
210
+ }) ? accountId : null;
211
+ }
212
+ async function start(staff, bookingId, body, req, res) {
213
+ var _ref, _req_headers_idempotencykey, _booking_serviceName;
214
+ var _intent_body, _intent_body1;
215
+ const attemptKey = String((_ref = (_req_headers_idempotencykey = req.headers['idempotency-key']) != null ? _req_headers_idempotencykey : req.headers['Idempotency-Key']) != null ? _ref : '').trim().slice(0, 200);
216
+ if (!attemptKey) return res.status(400).json({
217
+ error: 'Missing Idempotency-Key'
218
+ });
219
+ const serviceCents = typeof body['amountCents'] === 'number' ? body['amountCents'] : NaN;
220
+ const problem = bookingInPersonAmountProblem(serviceCents);
221
+ if (problem) return res.status(400).json({
222
+ error: problem
223
+ });
224
+ const ref = bookingRef(staff.hostId, bookingId);
225
+ const snapshot = await ref.get();
226
+ if (!snapshot.exists) return res.status(404).json({
227
+ error: 'Unknown booking'
228
+ });
229
+ const booking = snapshot.data();
230
+ const state = bookingInPersonState(booking);
231
+ const existing = booking['inPersonPayment'];
232
+ const attempt = createHash('sha256').update(`${bookingId}:${attemptKey}`).digest('hex').slice(0, 40);
233
+ if (state === 'paid') return res.status(409).json({
234
+ error: 'This booking is already paid.'
235
+ });
236
+ if (state === 'canceled') return res.status(409).json({
237
+ error: 'This booking was canceled.'
238
+ });
239
+ if (state === 'awaiting-online') {
240
+ return res.status(409).json({
241
+ error: 'The customer is paying for this booking online right now.'
242
+ });
243
+ }
244
+ const destination = await merchantAccount(staff.org, staff.hostId);
245
+ if (!destination) return res.status(409).json({
246
+ error: 'Finish setting up payments before taking cards.'
247
+ });
248
+ // The merchant's own service tax, off unless they set one (AGL-2028): the
249
+ // same rate a paid booking is charged online.
250
+ const tax = pluginTaxProfile().flatTax(await pluginTaxProfile().flatRate(staff.hostId, 'service'), serviceCents, 'Service tax');
251
+ const amountCents = serviceCents + tax.taxCents;
252
+ const feeCents = resolveTransactionFeeCents(staff.org, 'service', serviceCents, amountCents);
253
+ // A pending intent for this booking that is not this attempt's: release it
254
+ // first, so one booking never has two cards in flight.
255
+ if ((existing == null ? void 0 : existing.status) === 'pending' && existing.paymentIntentId) {
256
+ const sameAttempt = existing.attempt === attempt;
257
+ if (sameAttempt) {
258
+ var _replay_body;
259
+ const replay = await stripe('GET', `payment_intents/${existing.paymentIntentId}`);
260
+ if (replay.ok && ((_replay_body = replay.body) == null ? void 0 : _replay_body.client_secret)) {
261
+ return res.status(200).json(startAnswer(existing, String(replay.body.client_secret)));
262
+ }
263
+ } else {
264
+ const released = await releaseIntent(existing.paymentIntentId);
265
+ if (!released) {
266
+ return res.status(409).json({
267
+ error: 'A card was already authorized for this booking. Settle it before starting another payment.'
268
+ });
269
+ }
270
+ }
271
+ }
272
+ const intent = await stripe('POST', 'payment_intents', {
273
+ idempotencyKey: `booking-in-person:${attempt}`,
274
+ params: _extends({
275
+ amount: amountCents,
276
+ currency: 'usd',
277
+ 'payment_method_types[]': 'card_present',
278
+ capture_method: 'manual',
279
+ 'transfer_data[destination]': destination
280
+ }, feeCents > 0 ? {
281
+ application_fee_amount: feeCents
282
+ } : {}, {
283
+ description: `Booking: ${String((_booking_serviceName = booking['serviceName']) != null ? _booking_serviceName : 'appointment').slice(0, 80)}`,
284
+ 'metadata[type]': 'booking-in-person',
285
+ 'metadata[hostId]': staff.hostId,
286
+ 'metadata[bookingId]': bookingId,
287
+ 'metadata[feeCents]': feeCents,
288
+ 'metadata[cashierId]': staff.uid
289
+ }, tax.taxCents > 0 ? {
290
+ 'metadata[taxCents]': tax.taxCents,
291
+ 'metadata[taxPct]': tax.pct
292
+ } : {})
293
+ });
294
+ if (!intent.ok || !((_intent_body = intent.body) == null ? void 0 : _intent_body.id) || !((_intent_body1 = intent.body) == null ? void 0 : _intent_body1.client_secret)) {
295
+ return res.status(502).json({
296
+ error: stripeMessage(intent.body, 'The card payment could not be started.')
297
+ });
298
+ }
299
+ const payment = {
300
+ paymentIntentId: String(intent.body.id),
301
+ amountCents,
302
+ serviceCents,
303
+ taxCents: tax.taxCents,
304
+ feeCents,
305
+ status: 'pending',
306
+ startedAtMs: Date.now(),
307
+ startedBy: staff.uid,
308
+ attempt
309
+ };
310
+ // Recorded in a transaction that re-checks the booking: a payment that
311
+ // landed meanwhile (online, or on another device) wins, and this intent is
312
+ // released instead of left to authorize a second charge.
313
+ const recorded = await firebaseAdmin.app().firestore().runTransaction(async (transaction)=>{
314
+ var _fresh_data;
315
+ const fresh = await transaction.get(ref);
316
+ const freshState = bookingInPersonState((_fresh_data = fresh.data()) != null ? _fresh_data : {});
317
+ const freshPending = fresh.get('inPersonPayment');
318
+ if (freshState === 'paid' || freshState === 'canceled' || freshState === 'awaiting-online') return false;
319
+ if ((freshPending == null ? void 0 : freshPending.status) === 'pending' && freshPending.paymentIntentId !== payment.paymentIntentId && freshPending.paymentIntentId !== (existing == null ? void 0 : existing.paymentIntentId)) {
320
+ return false;
321
+ }
322
+ transaction.set(ref, {
323
+ inPersonPayment: payment
324
+ }, {
325
+ merge: true
326
+ });
327
+ return true;
328
+ });
329
+ if (!recorded) {
330
+ await releaseIntent(payment.paymentIntentId);
331
+ return res.status(409).json({
332
+ error: 'This booking changed while the payment started. Open it again.'
333
+ });
334
+ }
335
+ return res.status(200).json(startAnswer(payment, String(intent.body.client_secret)));
336
+ }
337
+ function startAnswer(payment, clientSecret) {
338
+ return {
339
+ paymentIntentId: payment.paymentIntentId,
340
+ clientSecret,
341
+ amountCents: payment.amountCents,
342
+ serviceCents: payment.serviceCents,
343
+ taxCents: payment.taxCents
344
+ };
345
+ }
346
+ /** Cancels an intent that holds no money yet; false when it does. */ async function releaseIntent(paymentIntentId) {
347
+ var _ref, _ref1;
348
+ var _current_body, _canceled_body;
349
+ const current = await stripe('GET', `payment_intents/${paymentIntentId}`);
350
+ const status = String((_ref = (_current_body = current.body) == null ? void 0 : _current_body.status) != null ? _ref : '');
351
+ if (status === 'canceled') return true;
352
+ if (status === 'succeeded' || status === 'requires_capture' || status === 'processing') return false;
353
+ const canceled = await stripe('POST', `payment_intents/${paymentIntentId}/cancel`, {
354
+ idempotencyKey: `booking-in-person-cancel:${paymentIntentId}`
355
+ });
356
+ return canceled.ok || String((_ref1 = (_canceled_body = canceled.body) == null ? void 0 : _canceled_body.status) != null ? _ref1 : '') === 'canceled';
357
+ }
358
+ async function settle(staff, bookingId, res) {
359
+ var _ref, _intent_amount_received;
360
+ var _intent_metadata, _intent_metadata1, _intent_metadata2;
361
+ const ref = bookingRef(staff.hostId, bookingId);
362
+ const snapshot = await ref.get();
363
+ if (!snapshot.exists) return res.status(404).json({
364
+ error: 'Unknown booking'
365
+ });
366
+ const payment = snapshot.get('inPersonPayment');
367
+ if ((payment == null ? void 0 : payment.status) === 'paid') return res.status(200).json({
368
+ status: 'paid',
369
+ amountCents: payment.amountCents
370
+ });
371
+ if (!payment || payment.status !== 'pending' || !PAYMENT_INTENT_ID.test(String(payment.paymentIntentId))) {
372
+ return res.status(409).json({
373
+ error: 'No card payment is in progress for this booking.'
374
+ });
375
+ }
376
+ let intent = (await stripe('GET', `payment_intents/${payment.paymentIntentId}`)).body;
377
+ // The intent must be the one this route made for THIS booking on THIS site.
378
+ if ((intent == null ? void 0 : (_intent_metadata = intent.metadata) == null ? void 0 : _intent_metadata.type) !== 'booking-in-person' || (intent == null ? void 0 : (_intent_metadata1 = intent.metadata) == null ? void 0 : _intent_metadata1.hostId) !== staff.hostId || (intent == null ? void 0 : (_intent_metadata2 = intent.metadata) == null ? void 0 : _intent_metadata2.bookingId) !== bookingId) {
379
+ return res.status(409).json({
380
+ error: 'That payment is not for this booking.'
381
+ });
382
+ }
383
+ if (intent.status === 'requires_capture') {
384
+ const captured = await stripe('POST', `payment_intents/${payment.paymentIntentId}/capture`, {
385
+ idempotencyKey: `booking-in-person-capture:${payment.paymentIntentId}`,
386
+ params: payment.feeCents > 0 ? {
387
+ application_fee_amount: payment.feeCents
388
+ } : {}
389
+ });
390
+ if (!captured.ok) {
391
+ return res.status(502).json({
392
+ error: stripeMessage(captured.body, 'The card could not be charged. Try again.')
393
+ });
394
+ }
395
+ intent = captured.body;
396
+ }
397
+ if ((intent == null ? void 0 : intent.status) !== 'succeeded') {
398
+ // Not collected yet, declined, or canceled: the booking stays payable.
399
+ const status = (intent == null ? void 0 : intent.status) === 'canceled' ? 'canceled' : 'pending';
400
+ if (status === 'canceled') {
401
+ await ref.set({
402
+ inPersonPayment: _extends({}, payment, {
403
+ status: 'canceled'
404
+ })
405
+ }, {
406
+ merge: true
407
+ });
408
+ }
409
+ return res.status(200).json({
410
+ status
411
+ });
412
+ }
413
+ const paidAmountCents = Math.max(0, Math.round(Number((_ref = (_intent_amount_received = intent.amount_received) != null ? _intent_amount_received : intent.amount) != null ? _ref : 0)));
414
+ const paidAtMs = Date.now();
415
+ await firebaseAdmin.app().firestore().runTransaction(async (transaction)=>{
416
+ const fresh = await transaction.get(ref);
417
+ if (fresh.get('paymentIntentId')) return;
418
+ transaction.set(ref, _extends({
419
+ // The same fields an online payment writes (billing-webhook.ts), so
420
+ // the console's refund and dispute handling read it unchanged.
421
+ paidAmountCents,
422
+ paymentIntentId: payment.paymentIntentId,
423
+ feeCents: payment.feeCents,
424
+ taxMode: pluginTaxProfile().taxModeOf(intent, payment.taxCents)
425
+ }, payment.taxCents > 0 ? {
426
+ taxCents: payment.taxCents
427
+ } : {}, {
428
+ paidInPerson: true,
429
+ inPersonPayment: _extends({}, payment, {
430
+ status: 'paid',
431
+ paidAtMs
432
+ })
433
+ }), {
434
+ merge: true
435
+ });
436
+ });
437
+ return res.status(200).json({
438
+ status: 'paid',
439
+ amountCents: paidAmountCents
440
+ });
441
+ }
442
+ async function cancel(staff, bookingId, res) {
443
+ const ref = bookingRef(staff.hostId, bookingId);
444
+ const snapshot = await ref.get();
445
+ if (!snapshot.exists) return res.status(404).json({
446
+ error: 'Unknown booking'
447
+ });
448
+ const payment = snapshot.get('inPersonPayment');
449
+ if (!payment || payment.status !== 'pending') return res.status(200).json({
450
+ status: 'none'
451
+ });
452
+ if (!await releaseIntent(payment.paymentIntentId)) {
453
+ return res.status(409).json({
454
+ error: 'The card was already authorized. Settle the payment instead.'
455
+ });
456
+ }
457
+ await ref.set({
458
+ inPersonPayment: _extends({}, payment, {
459
+ status: 'canceled'
460
+ })
461
+ }, {
462
+ merge: true
463
+ });
464
+ return res.status(200).json({
465
+ status: 'canceled'
466
+ });
467
+ }
468
+
469
+ //# sourceMappingURL=in-person-payment.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/server/in-person-payment.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 { createHash } from 'crypto'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { merchantAccountIsReady } from '@aglyn/tenant-data-admin/server/payment-provider'\nimport {\n checkEntitlement,\n resolveTransactionFeeCents,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { pluginTaxProfile } from '@aglyn/aglyn/plugin-manager/plugin-tax-profile'\nimport {\n bookingInPersonAmountProblem,\n bookingInPersonState,\n type BookingInPersonPayment,\n} from '../model/booking-in-person'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\n\n/*==========================================\n * `POST /api/bookings/in-person-payment` (AGL-3618).\n *\n * Takes payment for a booking at the counter, on the card reader the native\n * Aglyn POS app has connected (Tap to Pay or a Bluetooth reader). The server\n * owns the money; the app only presents the card:\n *\n * - `start` prices the charge (the amount staff typed, plus the merchant's\n * own service tax), and creates a card-present PaymentIntent: a\n * DESTINATION charge to the merchant, settled on Aglyn's platform account\n * with no `on_behalf_of` (ToS §10.7, AGL-3607), with the take and\n * Stripe's card cost as `application_fee_amount` — exactly the fee a paid\n * booking already carries online (`resolveTransactionFeeCents`, `service`).\n * Returns the intent's client secret for the app's Terminal SDK.\n * - `settle` re-reads the intent from Stripe (never trusting the app's word),\n * captures an authorized one, and records the booking paid with the same\n * fields an online payment writes, so the console's refund and the dispute\n * handling work on it unchanged.\n * - `cancel` releases an intent that was not collected.\n *\n * MANUAL capture, so a card that authorizes but whose booking was changed in\n * between is released rather than charged. On-reader tipping is not offered\n * here; the amount is the amount.\n *\n * Gate: a site `admin` or `editor`, and the owning org's `bookings` and\n * `pos` entitlements (taking card-present payments is a POS capability).\n *=========================================*/\n\nconst BOOKING_ID = /^[A-Za-z0-9_-]{1,128}$/\nconst PAYMENT_INTENT_ID = /^pi_[A-Za-z0-9_]{6,128}$/\n\ntype Res = Parameters<PluginApiHandler>[1]\n\ninterface Staff {\n uid: string\n hostId: string\n org: Record<string, any> | null\n}\n\nfunction requestBody(req: Parameters<PluginApiHandler>[0]): Record<string, any> {\n if (typeof req.body === 'string') {\n try {\n const parsed = JSON.parse(req.body)\n return parsed && typeof parsed === 'object' ? parsed : {}\n } catch {\n return {}\n }\n }\n return req.body && typeof req.body === 'object' ? req.body : {}\n}\n\n/** Card-present payments: always in test mode, in live mode once switched on. */\nexport function inPersonCardsAvailable(): boolean {\n const key = String(process.env.STRIPE_SECRET_KEY ?? '')\n if (!key) return false\n return (\n key.startsWith('sk_test_') ||\n String(process.env.STRIPE_TERMINAL_LIVE_ENABLED ?? '').toLowerCase() === 'true'\n )\n}\n\nasync function stripe(\n method: 'GET' | 'POST',\n path: string,\n options: { params?: Record<string, string | number | boolean | undefined>; idempotencyKey?: string } = {},\n): Promise<{ ok: boolean; status: number; body: any }> {\n const form = new URLSearchParams()\n for (const [key, value] of Object.entries(options.params ?? {})) {\n if (value !== undefined) form.append(key, String(value))\n }\n const query = method === 'GET' && form.toString() ? `?${form}` : ''\n const response = await fetch(`https://api.stripe.com/v1/${path}${query}`, {\n method,\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n ...(options.idempotencyKey ? { 'Idempotency-Key': options.idempotencyKey } : {}),\n },\n ...(method === 'POST' ? { body: form.toString() } : {}),\n })\n const body = await response.json().catch(() => null)\n return { ok: response.ok, status: response.status, body }\n}\n\nfunction stripeMessage(body: any, fallback: string): string {\n const message = body?.error?.message\n return typeof message === 'string' && message ? message : fallback\n}\n\nasync function authorize(\n req: Parameters<PluginApiHandler>[0],\n hostId: string,\n): Promise<{ ok: true; staff: Staff } | { ok: false; status: number; error: string }> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ') ? authorization.slice(7) : ''\n if (!idToken) return { ok: false, status: 401, error: 'Unauthenticated' }\n if (!hostId) return { ok: false, status: 400, error: 'Missing hostId' }\n let uid: string\n try {\n uid = (await firebaseAdmin.app().auth().verifyIdToken(idToken)).uid\n } catch (error) {\n if (!isRefusedIdToken(error)) throw error\n return { ok: false, status: 401, error: 'Unauthenticated' }\n }\n const host = await firebaseAdmin.app().firestore().collection('hosts').doc(hostId).get()\n if (!host.exists) return { ok: false, status: 404, error: 'Unknown site' }\n const role = (host.get('memberRoles') ?? {})[uid]\n if (role !== 'admin' && role !== 'editor') return { ok: false, status: 403, error: 'Not permitted' }\n const org = ((await getOrgForHost(hostId))?.org as Record<string, any> | undefined) ?? null\n if (!checkEntitlement(org as never, 'bookings') || !checkEntitlement(org as never, 'pos')) {\n return { ok: false, status: 403, error: 'Taking booking payments in person needs the POS plan.' }\n }\n return { ok: true, staff: { uid, hostId, org } }\n}\n\nexport const bookingInPersonPaymentHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })\n const body = requestBody(req)\n const hostId = String(body['hostId'] ?? '')\n const bookingId = String(body['bookingId'] ?? '')\n const gate = await authorize(req, hostId)\n if ('error' in gate) return res.status(gate.status).json({ error: gate.error })\n if (!BOOKING_ID.test(bookingId)) return res.status(400).json({ error: 'Missing bookingId' })\n if (!inPersonCardsAvailable()) {\n return res.status(409).json({ error: 'Card readers are not available yet.' })\n }\n const action = String(body['action'] ?? '')\n try {\n switch (action) {\n case 'start':\n return await start(gate.staff, bookingId, body, req, res)\n case 'settle':\n return await settle(gate.staff, bookingId, res)\n case 'cancel':\n return await cancel(gate.staff, bookingId, res)\n default:\n return res.status(400).json({ error: 'Unknown action' })\n }\n } catch (error) {\n console.error('[bookings/in-person-payment]', action, error)\n return res.status(500).json({ error: 'The payment could not be updated. Check the booking before trying again.' })\n }\n}\n\nfunction bookingRef(hostId: string, bookingId: string) {\n return firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('bookings')\n .doc(bookingId)\n}\n\nasync function merchantAccount(org: Record<string, any> | null, hostId: string): Promise<string | null> {\n const ownerUid = String(org?.['ownerUid'] ?? '')\n if (!ownerUid) return null\n const profile = await firebaseAdmin.app().firestore().collection('profiles').doc(ownerUid).get()\n const accountId = String(profile.get('stripeAccountId') ?? '')\n return merchantAccountIsReady(\n {\n accountId,\n chargesEnabled: profile.get('stripeChargesEnabled'),\n accountLivemode: profile.get('stripeAccountLivemode'),\n },\n { subject: `booking in-person host ${hostId}` },\n )\n ? accountId\n : null\n}\n\nasync function start(\n staff: Staff,\n bookingId: string,\n body: Record<string, any>,\n req: Parameters<PluginApiHandler>[0],\n res: Res,\n) {\n const attemptKey = String(req.headers['idempotency-key'] ?? req.headers['Idempotency-Key'] ?? '')\n .trim()\n .slice(0, 200)\n if (!attemptKey) return res.status(400).json({ error: 'Missing Idempotency-Key' })\n const serviceCents = typeof body['amountCents'] === 'number' ? body['amountCents'] : NaN\n const problem = bookingInPersonAmountProblem(serviceCents)\n if (problem) return res.status(400).json({ error: problem })\n\n const ref = bookingRef(staff.hostId, bookingId)\n const snapshot = await ref.get()\n if (!snapshot.exists) return res.status(404).json({ error: 'Unknown booking' })\n const booking = snapshot.data() as Record<string, any>\n const state = bookingInPersonState(booking)\n const existing = booking['inPersonPayment'] as BookingInPersonPayment | undefined\n const attempt = createHash('sha256').update(`${bookingId}:${attemptKey}`).digest('hex').slice(0, 40)\n\n if (state === 'paid') return res.status(409).json({ error: 'This booking is already paid.' })\n if (state === 'canceled') return res.status(409).json({ error: 'This booking was canceled.' })\n if (state === 'awaiting-online') {\n return res.status(409).json({ error: 'The customer is paying for this booking online right now.' })\n }\n\n const destination = await merchantAccount(staff.org, staff.hostId)\n if (!destination) return res.status(409).json({ error: 'Finish setting up payments before taking cards.' })\n\n // The merchant's own service tax, off unless they set one (AGL-2028): the\n // same rate a paid booking is charged online.\n const tax = pluginTaxProfile().flatTax(\n await pluginTaxProfile().flatRate(staff.hostId, 'service'),\n serviceCents,\n 'Service tax',\n )\n const amountCents = serviceCents + tax.taxCents\n const feeCents = resolveTransactionFeeCents(staff.org as never, 'service', serviceCents, amountCents)\n\n // A pending intent for this booking that is not this attempt's: release it\n // first, so one booking never has two cards in flight.\n if (existing?.status === 'pending' && existing.paymentIntentId) {\n const sameAttempt = existing.attempt === attempt\n if (sameAttempt) {\n const replay = await stripe('GET', `payment_intents/${existing.paymentIntentId}`)\n if (replay.ok && replay.body?.client_secret) {\n return res.status(200).json(startAnswer(existing, String(replay.body.client_secret)))\n }\n } else {\n const released = await releaseIntent(existing.paymentIntentId)\n if (!released) {\n return res.status(409).json({\n error: 'A card was already authorized for this booking. Settle it before starting another payment.',\n })\n }\n }\n }\n\n const intent = await stripe('POST', 'payment_intents', {\n idempotencyKey: `booking-in-person:${attempt}`,\n params: {\n amount: amountCents,\n currency: 'usd',\n 'payment_method_types[]': 'card_present',\n capture_method: 'manual',\n 'transfer_data[destination]': destination,\n ...(feeCents > 0 ? { application_fee_amount: feeCents } : {}),\n description: `Booking: ${String(booking['serviceName'] ?? 'appointment').slice(0, 80)}`,\n 'metadata[type]': 'booking-in-person',\n 'metadata[hostId]': staff.hostId,\n 'metadata[bookingId]': bookingId,\n 'metadata[feeCents]': feeCents,\n 'metadata[cashierId]': staff.uid,\n ...(tax.taxCents > 0 ? { 'metadata[taxCents]': tax.taxCents, 'metadata[taxPct]': tax.pct } : {}),\n },\n })\n if (!intent.ok || !intent.body?.id || !intent.body?.client_secret) {\n return res.status(502).json({ error: stripeMessage(intent.body, 'The card payment could not be started.') })\n }\n const payment: BookingInPersonPayment = {\n paymentIntentId: String(intent.body.id),\n amountCents,\n serviceCents,\n taxCents: tax.taxCents,\n feeCents,\n status: 'pending',\n startedAtMs: Date.now(),\n startedBy: staff.uid,\n attempt,\n }\n // Recorded in a transaction that re-checks the booking: a payment that\n // landed meanwhile (online, or on another device) wins, and this intent is\n // released instead of left to authorize a second charge.\n const recorded = await firebaseAdmin.app().firestore().runTransaction(async (transaction: any) => {\n const fresh = await transaction.get(ref)\n const freshState = bookingInPersonState((fresh.data() ?? {}) as Record<string, any>)\n const freshPending = fresh.get('inPersonPayment') as BookingInPersonPayment | undefined\n if (freshState === 'paid' || freshState === 'canceled' || freshState === 'awaiting-online') return false\n if (\n freshPending?.status === 'pending' &&\n freshPending.paymentIntentId !== payment.paymentIntentId &&\n freshPending.paymentIntentId !== existing?.paymentIntentId\n ) {\n return false\n }\n transaction.set(ref, { inPersonPayment: payment }, { merge: true })\n return true\n })\n if (!recorded) {\n await releaseIntent(payment.paymentIntentId)\n return res.status(409).json({ error: 'This booking changed while the payment started. Open it again.' })\n }\n return res.status(200).json(startAnswer(payment, String(intent.body.client_secret)))\n}\n\nfunction startAnswer(payment: BookingInPersonPayment, clientSecret: string) {\n return {\n paymentIntentId: payment.paymentIntentId,\n clientSecret,\n amountCents: payment.amountCents,\n serviceCents: payment.serviceCents,\n taxCents: payment.taxCents,\n }\n}\n\n/** Cancels an intent that holds no money yet; false when it does. */\nasync function releaseIntent(paymentIntentId: string): Promise<boolean> {\n const current = await stripe('GET', `payment_intents/${paymentIntentId}`)\n const status = String(current.body?.status ?? '')\n if (status === 'canceled') return true\n if (status === 'succeeded' || status === 'requires_capture' || status === 'processing') return false\n const canceled = await stripe('POST', `payment_intents/${paymentIntentId}/cancel`, {\n idempotencyKey: `booking-in-person-cancel:${paymentIntentId}`,\n })\n return canceled.ok || String(canceled.body?.status ?? '') === 'canceled'\n}\n\nasync function settle(staff: Staff, bookingId: string, res: Res) {\n const ref = bookingRef(staff.hostId, bookingId)\n const snapshot = await ref.get()\n if (!snapshot.exists) return res.status(404).json({ error: 'Unknown booking' })\n const payment = snapshot.get('inPersonPayment') as BookingInPersonPayment | undefined\n if (payment?.status === 'paid') return res.status(200).json({ status: 'paid', amountCents: payment.amountCents })\n if (!payment || payment.status !== 'pending' || !PAYMENT_INTENT_ID.test(String(payment.paymentIntentId))) {\n return res.status(409).json({ error: 'No card payment is in progress for this booking.' })\n }\n let intent = (await stripe('GET', `payment_intents/${payment.paymentIntentId}`)).body\n // The intent must be the one this route made for THIS booking on THIS site.\n if (\n intent?.metadata?.type !== 'booking-in-person' ||\n intent?.metadata?.hostId !== staff.hostId ||\n intent?.metadata?.bookingId !== bookingId\n ) {\n return res.status(409).json({ error: 'That payment is not for this booking.' })\n }\n if (intent.status === 'requires_capture') {\n const captured = await stripe('POST', `payment_intents/${payment.paymentIntentId}/capture`, {\n idempotencyKey: `booking-in-person-capture:${payment.paymentIntentId}`,\n params: payment.feeCents > 0 ? { application_fee_amount: payment.feeCents } : {},\n })\n if (!captured.ok) {\n return res.status(502).json({ error: stripeMessage(captured.body, 'The card could not be charged. Try again.') })\n }\n intent = captured.body\n }\n if (intent?.status !== 'succeeded') {\n // Not collected yet, declined, or canceled: the booking stays payable.\n const status = intent?.status === 'canceled' ? 'canceled' : 'pending'\n if (status === 'canceled') {\n await ref.set({ inPersonPayment: { ...payment, status: 'canceled' } }, { merge: true })\n }\n return res.status(200).json({ status })\n }\n const paidAmountCents = Math.max(0, Math.round(Number(intent.amount_received ?? intent.amount ?? 0)))\n const paidAtMs = Date.now()\n await firebaseAdmin.app().firestore().runTransaction(async (transaction: any) => {\n const fresh = await transaction.get(ref)\n if (fresh.get('paymentIntentId')) return\n transaction.set(\n ref,\n {\n // The same fields an online payment writes (billing-webhook.ts), so\n // the console's refund and dispute handling read it unchanged.\n paidAmountCents,\n paymentIntentId: payment.paymentIntentId,\n feeCents: payment.feeCents,\n taxMode: pluginTaxProfile().taxModeOf(intent, payment.taxCents),\n ...(payment.taxCents > 0 ? { taxCents: payment.taxCents } : {}),\n paidInPerson: true,\n inPersonPayment: { ...payment, status: 'paid', paidAtMs },\n },\n { merge: true },\n )\n })\n return res.status(200).json({ status: 'paid', amountCents: paidAmountCents })\n}\n\nasync function cancel(staff: Staff, bookingId: string, res: Res) {\n const ref = bookingRef(staff.hostId, bookingId)\n const snapshot = await ref.get()\n if (!snapshot.exists) return res.status(404).json({ error: 'Unknown booking' })\n const payment = snapshot.get('inPersonPayment') as BookingInPersonPayment | undefined\n if (!payment || payment.status !== 'pending') return res.status(200).json({ status: 'none' })\n if (!(await releaseIntent(payment.paymentIntentId))) {\n return res.status(409).json({ error: 'The card was already authorized. Settle the payment instead.' })\n }\n await ref.set({ inPersonPayment: { ...payment, status: 'canceled' } }, { merge: true })\n return res.status(200).json({ status: 'canceled' })\n}\n"],"names":["createHash","firebaseAdmin","getOrgForHost","merchantAccountIsReady","checkEntitlement","resolveTransactionFeeCents","pluginTaxProfile","bookingInPersonAmountProblem","bookingInPersonState","isRefusedIdToken","BOOKING_ID","PAYMENT_INTENT_ID","requestBody","req","body","parsed","JSON","parse","inPersonCardsAvailable","process","key","String","env","STRIPE_SECRET_KEY","startsWith","STRIPE_TERMINAL_LIVE_ENABLED","toLowerCase","stripe","method","path","options","form","URLSearchParams","value","Object","entries","params","undefined","append","query","toString","response","fetch","headers","Authorization","idempotencyKey","json","catch","ok","status","stripeMessage","fallback","message","error","authorize","hostId","host","authorization","idToken","slice","uid","app","auth","verifyIdToken","firestore","collection","doc","get","exists","role","org","staff","bookingInPersonPaymentHandler","res","bookingId","gate","test","action","start","settle","cancel","console","bookingRef","merchantAccount","profile","ownerUid","accountId","chargesEnabled","accountLivemode","subject","booking","intent","attemptKey","trim","serviceCents","NaN","problem","ref","snapshot","data","state","existing","attempt","update","digest","destination","tax","flatTax","flatRate","amountCents","taxCents","feeCents","paymentIntentId","sameAttempt","replay","client_secret","startAnswer","released","releaseIntent","amount","currency","capture_method","application_fee_amount","description","pct","id","payment","startedAtMs","Date","now","startedBy","recorded","runTransaction","transaction","fresh","freshState","freshPending","set","inPersonPayment","merge","clientSecret","current","canceled","metadata","type","captured","paidAmountCents","Math","max","round","Number","amount_received","paidAtMs","taxMode","taxModeOf","paidInPerson"],"mappings":"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"}
@@ -21,6 +21,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
21
21
  import { reverseOrderConversion } from "@aglyn/aglyn/plugin-manager/plugin-conversion-credit";
22
22
  import { createHash } from "crypto";
23
23
  import { apiIdempotencyExpiry } from "@aglyn/aglyn/app-utils/api-idempotency";
24
+ import { raiseBookingEvent } from "./booking-events.js";
24
25
  /**
25
26
  * Refund a paid booking (AGL-2315), full or partial, site-admin only.
26
27
  *
@@ -103,7 +104,7 @@ import { apiIdempotencyExpiry } from "@aglyn/aglyn/app-utils/api-idempotency";
103
104
  });
104
105
  }
105
106
  try {
106
- var _hostSnapshot_get, _bookingSnapshot_get, _bookingSnapshot_get1;
107
+ var _hostSnapshot_get, _bookingSnapshot_data, _bookingSnapshot_get, _bookingSnapshot_get1;
107
108
  const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
108
109
  const firestore = firebaseAdmin.app().firestore();
109
110
  const hostRef = firestore.collection('hosts').doc(hostId);
@@ -128,6 +129,8 @@ import { apiIdempotencyExpiry } from "@aglyn/aglyn/app-utils/api-idempotency";
128
129
  error: 'Unknown booking'
129
130
  });
130
131
  }
132
+ // As it stood before this refund, for the event a full refund raises.
133
+ const bookingBefore = (_bookingSnapshot_data = bookingSnapshot.data()) != null ? _bookingSnapshot_data : {};
131
134
  const paymentIntentId = String((_bookingSnapshot_get = bookingSnapshot.get('paymentIntentId')) != null ? _bookingSnapshot_get : '');
132
135
  const paidCents = Math.max(0, Math.round(Number((_bookingSnapshot_get1 = bookingSnapshot.get('paidAmountCents')) != null ? _bookingSnapshot_get1 : 0)));
133
136
  if (!paidCents) {
@@ -258,6 +261,19 @@ import { apiIdempotencyExpiry } from "@aglyn/aglyn/app-utils/api-idempotency";
258
261
  } : {}), {
259
262
  merge: true
260
263
  }).catch(()=>undefined);
264
+ // The full refund is what cancels a paid booking (AGL-3643). Keyed by the
265
+ // booking, so a booking canceled before is not announced again.
266
+ if (fullyRefunded && bookingBefore['status'] !== 'canceled') {
267
+ await raiseBookingEvent(firestore, {
268
+ event: 'booking.canceled',
269
+ hostId,
270
+ bookingId,
271
+ booking: _extends({}, bookingBefore, {
272
+ refundedCents: totalRefunded,
273
+ status: 'canceled'
274
+ })
275
+ });
276
+ }
261
277
  const payload = {
262
278
  refundedCents: refundCents,
263
279
  totalRefundedCents: totalRefunded,