UNPKG

@wallfar/ocd-studio-core-sdk

Version:

Helper SDK for our OneClick Studio modules

1,329 lines (1,324 loc) 51.1 kB
import { v as validateAddress } from './shared/ocd-studio-core-sdk.ChPIrm-j.mjs'; import Stripe from 'stripe'; import { FieldValue } from 'firebase-admin/firestore'; import { r as roundMoney, c as clampMoney } from './shared/ocd-studio-core-sdk.D3EQiCxo.mjs'; async function validatePromoCode(config, code) { try { const { db, promoCodes: promo_codes_collection } = config; let result = await db.collection(promo_codes_collection).where("code", "==", code).get(); if (result.empty || !result?.docs?.[0]) { throw new Error(); } let promo = result.docs[0].data(); if (promo.disabled) throw new Error(); if (promo.usageLimit !== 0 && promo.usageLimit <= promo.usageCount) throw new Error("Code is already used"); if (promo.startsAt?.seconds && new Date(promo.startsAt.seconds * 1e3) > /* @__PURE__ */ new Date()) throw new Error(); if (promo.expiresAt?.seconds && new Date(promo.expiresAt.seconds * 1e3) < /* @__PURE__ */ new Date()) throw new Error(); return { valid: true, promo: { id: result.docs[0].id, code: promo.code, type: promo.type, value: promo.value, minOrderAmount: promo.minOrderAmount } }; } catch (error) { return { valid: false, error: error.message || "Code is invalid" }; } } async function createPaymentLinkStripe(config, stripeConfig, checkoutData) { if (!stripeConfig.key) throw new Error("No stripe key provided"); const stripe = new Stripe(stripeConfig.key); const { cart, shippingOption, activePromoCode, deliveryAddress } = checkoutData; if (!cart || cart.length === 0) throw new Error("No products in cart"); if (!shippingOption) throw new Error("No shipping option selected"); if (!deliveryAddress) throw new Error("No delivery address"); let subtotal = 0; let discountObject = null; const products = await Promise.all(cart.map(async (item) => { const product = await config.db.collection(config.products).doc(item.productId).get(); return { id: product.id, ...product.data() }; })); cart.forEach((cart_item) => { let product = products.find((i) => i.id == cart_item.productId); let stock = product.stock, price = product.price; if (Array.isArray(product.options) && product.options.length > 0) { let variant = getVariant(product, cart_item.variant); stock = variant.stock; price = variant.price; } let new_item = { id: cart_item.productId, slug: product.slug, title: product.title, featuredMedia: product.featuredMedia, variant: cart_item.variant, quantity: stock < cart_item.quantity ? stock : cart_item.quantity, price }; subtotal += new_item.quantity * new_item.price; }); if (activePromoCode) { let { valid, promo, error } = await validatePromoCode(config, activePromoCode); if (!valid) throw new Error(error); discountObject = promo; } const { valid: isValidShippingOption, price: shippingFee } = await validateShippingOption(config, shippingOption, deliveryAddress, subtotal); if (!isValidShippingOption) throw new Error("Invalid shipping option"); const isValidAddress = validateAddress(deliveryAddress); if (isValidAddress.length > 0) throw new Error(isValidAddress.join(", ")); const discount = discountObject ? calculateDiscount(discountObject, subtotal) : 0; let total = subtotal - discount + (shippingFee || 0); if (total <= 0) total = 0; const session = await stripe.checkout.sessions.create({ customer_email: "maxim@wallfar.com", payment_method_types: ["card", "bancontact", "ideal", "sofort"], line_items: [{ price_data: { currency: "eur", unit_amount: parseInt((total * 100).toFixed(0)), product_data: { name: "Order", images: [] } }, quantity: 1 }], mode: "payment", success_url: "https://soakhandmade.be/checkout/success", cancel_url: "https://soakhandmade.be/checkout/cancel", metadata: { fbase_order_id: "fbase_order_id" }, payment_intent_data: { metadata: { fbase_order_id: "fbase_order_id" } } }); if (!session.url) throw new Error("Could not create payment link"); return session.url; } async function validateShippingOption(config, optionId, deliveryAddress, subtotal) { const shippingOptions = await getAllShippingOptions(config); const shippingOption = shippingOptions?.find((opt) => opt.id === optionId); if (!shippingOption) return { valid: false }; if (shippingOption.status !== "published") return { valid: false }; const validCountry = !shippingOption.countries || shippingOption.countries.length === 0 || shippingOption.countries.includes(deliveryAddress.country); if (!validCountry) return { valid: false }; let validCondition = shippingOption.condition === "always"; if (!validCondition) { if (shippingOption.condition === "price_based") { const MIN = shippingOption.condition_min || 0; const MAX = shippingOption.condition_max || Infinity; validCondition = subtotal >= MIN && subtotal <= MAX; } else if (shippingOption.condition === "weight_based") ; } return { valid: validCountry && validCondition, price: shippingOption.price || 0 }; } async function getAllShippingOptions(config) { const { shippingOptions: shipping_options_collection } = config; if (!shipping_options_collection) return null; if (shipping_options_collection.includes(":")) { const collectionParts = shipping_options_collection.split(":"); const pathParts = collectionParts[0].split("/"); const path = pathParts.slice(0, pathParts.length - 1); const docId = pathParts[pathParts.length - 1]; const docSnap = await config.db.collection(path.join("/")).doc(docId).get(); const options = docSnap.data()?.[collectionParts[1]]; return options?.filter((opt) => opt.status === "published") || null; } else { const snapshot = await config.db.collection(shipping_options_collection).get(); return !snapshot.empty ? snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() })).filter((opt) => opt.status === "published") : null; } } function getVariant(product, variant) { if (!product.variants || product.variants?.length === 0) { return { id: product.id, options: [], combinedOptions: "", stock: product.stock, price: product.price, featuredMedia: product.featuredMedia, disabled: false, title: product.title, slug: product.slug }; } return product.variants?.find( (v) => v.options.every((o) => o.value === variant[o.option]) ); } function calculateDiscount(promo, subtotal) { if (!promo) return 0; if (promo.type === "percentage") { return subtotal * ((promo.value || 0) / 100) || 0; } if (promo.type === "fixed") { return promo.value || 0; } return 0; } class WebshopServer { config; constructor(config) { this.config = config; if (!this.config.defaultBrand) this.config.defaultBrand = ""; if (!this.config.defaultCurrency) this.config.defaultCurrency = "USD"; if (!this.config.defaultLocale) this.config.defaultLocale = "en-US"; } async validatePromoCode(code) { if (this.config.provider === "firebase") { return await validatePromoCode(this.config.firebase, code); } throw new Error(`Provider ${this.config.provider} not supported.`); } async createPaymentLink(checkoutData) { if (this.config.provider === "firebase" && this.config.paymentProvider === "stripe") { return await createPaymentLinkStripe(this.config.firebase, this.config.stripe, checkoutData); } throw new Error(`Provider ${this.config.provider} not supported.`); } } function getFirestoreFieldValue(db) { const firestoreConstructor = db.constructor; return firestoreConstructor.FieldValue ?? FieldValue; } function timeToMinutes(time) { const [hours, minutes] = time.split(":").map(Number); return hours * 60 + minutes; } function timeslotsOverlap(slot1, slot2) { const start1 = timeToMinutes(slot1.startTime); const end1 = timeToMinutes(slot1.endTime); const start2 = timeToMinutes(slot2.startTime); const end2 = timeToMinutes(slot2.endTime); return start1 < end2 && start2 < end1; } function getMaxConcurrentSpots(timeslot, intervals) { const rangeStart = timeToMinutes(timeslot.startTime); const rangeEnd = timeToMinutes(timeslot.endTime); const events = []; intervals.forEach((interval) => { const start = Math.max(timeToMinutes(interval.startTime), rangeStart); const end = Math.min(timeToMinutes(interval.endTime), rangeEnd); const spots = Math.max(Number(interval.spots) || 0, 0); if (start >= end || spots === 0) return; events.push({ minute: start, delta: spots }); events.push({ minute: end, delta: -spots }); }); events.sort((a, b) => a.minute - b.minute || a.delta - b.delta); let current = 0; let max = 0; events.forEach((event) => { current += event.delta; max = Math.max(max, current); }); return max; } function getReservedSpotsForSelection(reservedSpots, timeslot, resourceId) { return getMaxConcurrentSpots( timeslot, reservedSpots.filter((spot) => spot.resourceId === resourceId && timeslotsOverlap(timeslot, spot)).map((spot) => ({ startTime: spot.startTime, endTime: spot.endTime, spots: spot.reserved })) ); } function minutesToTime(minutes) { const hours = Math.floor(minutes / 60); const mins = minutes % 60; return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`; } function findException(agenda, resourceId, dateString) { const exceptions = agenda.exceptions || []; for (const exc of exceptions) { const dateInRange = dateString >= exc.startDate && dateString <= exc.endDate; if (!dateInRange) continue; if (exc.resourceIds === null) return exc; if (resourceId && exc.resourceIds?.includes(resourceId)) return exc; } return null; } function generateTimeSlotsForResource(resource, serviceDuration, date, dateString, agenda) { const exception = findException(agenda, resource.id, dateString); if (exception) { if (exception.isClosed) { return []; } const slots2 = []; if (exception.timeslots) { exception.timeslots.forEach((range) => { const startMinutes = timeToMinutes(range.startTime); const endMinutes = timeToMinutes(range.endTime); const interval = resource.interval || 30; for (let currentMinutes = startMinutes; currentMinutes < endMinutes; currentMinutes += interval) { const slotEndMinutes = currentMinutes + serviceDuration; if (slotEndMinutes <= endMinutes) { slots2.push({ startTime: minutesToTime(currentMinutes), endTime: minutesToTime(slotEndMinutes) }); } } }); } return slots2; } const dayOfWeek = date.getDay(); const openingHours = resource.openingHours?.[dayOfWeek] || []; if (openingHours.length === 0) { return []; } const slots = []; openingHours.forEach((range) => { const startMinutes = timeToMinutes(range.start); const endMinutes = timeToMinutes(range.end); const interval = resource.interval || 30; for (let currentMinutes = startMinutes; currentMinutes < endMinutes; currentMinutes += interval) { const slotEndMinutes = currentMinutes + serviceDuration; if (slotEndMinutes <= endMinutes) { slots.push({ startTime: minutesToTime(currentMinutes), endTime: minutesToTime(slotEndMinutes) }); } } }); return slots; } const DEFAULT_WEBHOOK_TYPE_METADATA_KEY = "webhook_type"; function getAllowedWebhookTypes(stripeConfig) { const allowedTypes = /* @__PURE__ */ new Set(); if (stripeConfig.webhookType) { allowedTypes.add(stripeConfig.webhookType); } for (const webhookType of stripeConfig.allowedWebhookTypes ?? []) { if (webhookType) { allowedTypes.add(webhookType); } } return Array.from(allowedTypes); } async function fetchAgenda(config, agendaId) { const { db, agendas } = config; const docSnap = await db.collection(agendas).doc(agendaId).get(); if (!docSnap.exists) return null; return docSnap.data(); } async function fetchReservedSpots(config, agendaId, dateString) { const { db, reserved_spots } = config; const docId = `${agendaId}_${dateString}`; const docSnap = await db.collection(reserved_spots).doc(docId).get(); if (!docSnap.exists) return []; let docData = docSnap.data(); delete docData?.lastUpdated; let result = []; Object.keys(docData || {}).forEach((key) => { Object.entries(docData?.[key] || {}).forEach(([timeRange, count]) => { const [startTime, endTime] = timeRange.split("-"); result.push({ resourceId: key, startTime, endTime, reserved: Number(count) }); }); }); return result || []; } async function validateReservation(config, input) { const errorDetails = []; const addError = (error) => { errorDetails.push({ ...error, scope: "reservation", reservationId: error.reservationId ?? input.id, agendaId: error.agendaId ?? input.agendaId, date: error.date ?? input.date, timeslot: error.timeslot ?? input.timeslot, resourceId: error.resourceId ?? input.resourceId }); }; const toInvalidResult = () => ({ valid: false, errors: errorDetails.map((error) => error.message), errorDetails }); const agenda = await fetchAgenda(config, input.agendaId); if (!agenda) { addError({ code: "agenda_not_found", message: "Agenda not found", field: "agendaId" }); return toInvalidResult(); } const bookingDate = new Date(input.date); const now = /* @__PURE__ */ new Date(); if (isNaN(bookingDate.getTime())) { addError({ code: "invalid_date_format", message: "Invalid date format", field: "date" }); } else { if (agenda.minimumAdvanceNotice) { const { unit, value } = agenda.minimumAdvanceNotice; const minTime = new Date(now); switch (unit) { case "minutes": minTime.setMinutes(minTime.getMinutes() + value); break; case "hours": minTime.setHours(minTime.getHours() + value); break; case "days": minTime.setDate(minTime.getDate() + value); break; } const [hours, minutes] = input.timeslot.startTime.split(":").map(Number); const slotDateTime = new Date(bookingDate); slotDateTime.setHours(hours, minutes, 0, 0); if (slotDateTime < minTime) { addError({ code: "minimum_advance_notice", message: "Booking does not meet minimum advance notice requirement", field: "timeslot", minimumAdvanceNotice: { unit, value }, minimumBookingDateTime: minTime.toISOString() }); } } const maxDays = Math.max(Number(agenda.serviceVisibility || 30), 1); const maxDate = new Date(now.getTime() + maxDays * 24 * 60 * 60 * 1e3); if (bookingDate > maxDate) { addError({ code: "booking_window_exceeded", message: "Date is beyond the booking window", field: "date", maximumBookingDate: maxDate.toISOString() }); } } const availableSlots = getAvailableTimeslots( agenda, input.date, input.resourceId, input.pricingOptionId ); const slotExists = availableSlots.some( (s) => s.startTime === input.timeslot.startTime && s.endTime === input.timeslot.endTime ); if (!slotExists) { addError({ code: "timeslot_unavailable", message: "Selected time slot is not available", field: "timeslot" }); } let selectedResource = void 0; if (agenda.resources && agenda.resources.length > 0) { if (!input.resourceId) { addError({ code: "resource_required", message: "Resource selection is required", field: "resourceId" }); } else { selectedResource = agenda.resources.find((r) => r.id === input.resourceId && r.isActive); if (!selectedResource) { addError({ code: "resource_unavailable", message: "Selected resource is not available", field: "resourceId" }); } } } if (input.spots <= 0) { addError({ code: "spots_required", message: "At least 1 spot is required", field: "spots", requestedSpots: input.spots }); } else { const reservedSpots = await fetchReservedSpots(config, input.agendaId, input.date); const maxCapacity = selectedResource?.capacity || 0; const existingReserved = getReservedSpotsForSelection( reservedSpots, input.timeslot, input.resourceId ); const availableSpots = Math.max(maxCapacity - existingReserved, 0); if (input.spots > availableSpots) { addError({ code: "spots_unavailable", message: `Only ${availableSpots} spots available`, field: "spots", requestedSpots: input.spots, availableSpots }); } } let selectedPricingOption = void 0; if (input.pricingOptionId) { selectedPricingOption = agenda.pricingOptions?.find((p) => p.id === input.pricingOptionId); if (!selectedPricingOption) { addError({ code: "pricing_option_unavailable", message: "Selected pricing option is not available", field: "pricingOptionId", pricingOptionId: input.pricingOptionId }); } } const resolvedAddOns = []; if (input.addOns) { const requiredAddOns = agenda.addOns?.filter((a) => a.required) || []; for (const required of requiredAddOns) { if (!input.addOns[required.id]) { addError({ code: "add_on_required", message: `${required.name} is required`, field: `addOns.${required.id}`, addOnId: required.id, addOnName: required.name }); } } for (const addOnId of Object.keys(input.addOns)) { if (!input.addOns[addOnId]) continue; const addOn = agenda.addOns?.find((a) => a.id === addOnId); if (!addOn) { addError({ code: "add_on_unavailable", message: `Add-on ${addOnId} is not available`, field: `addOns.${addOnId}`, addOnId }); } else { resolvedAddOns.push(addOn); } } } else { const requiredAddOns = agenda.addOns?.filter((a) => a.required) || []; for (const required of requiredAddOns) { addError({ code: "add_on_required", message: `${required.name} is required`, field: `addOns.${required.id}`, addOnId: required.id, addOnName: required.name }); } } if (errorDetails.length > 0) { return toInvalidResult(); } const pricing = calculateReservationPrice( agenda, input.spots, input.pricingOptionId, input.addOns || {}, input.date, input.timeslot ); const validatedReservation = { ...input, basePrice: pricing.basePrice, addOnsPrice: pricing.addOnsPrice, totalPrice: pricing.totalPrice, resource: selectedResource, pricingOption: selectedPricingOption, resolvedAddOns, status: agenda?.needsApproval ? "needs_approval" : "approved" }; return { valid: true, reservation: validatedReservation, errors: [], errorDetails: [] }; } async function validateReservationsAvailability(config, reservations) { const errorDetails = []; const reservedSpotsByDate = /* @__PURE__ */ new Map(); const getReservedSpots = async (agendaId, date) => { const cacheKey = `${agendaId}_${date}`; const cached = reservedSpotsByDate.get(cacheKey); if (cached) return cached; const reservedSpots = await fetchReservedSpots(config, agendaId, date); reservedSpotsByDate.set(cacheKey, reservedSpots); return reservedSpots; }; for (let index = 0; index < reservations.length; index++) { const reservation = reservations[index]; const maxCapacity = reservation.resource?.capacity || 0; if (!reservation.resourceId || maxCapacity <= 0) continue; const reservedSpots = await getReservedSpots(reservation.agendaId, reservation.date); const persistedIntervals = reservedSpots.filter( (spot) => spot.resourceId === reservation.resourceId && timeslotsOverlap(reservation.timeslot, spot) ).map((spot) => ({ startTime: spot.startTime, endTime: spot.endTime, spots: spot.reserved })); const sameOrderIntervals = reservations.filter( (otherReservation, otherIndex) => otherIndex !== index && otherReservation.agendaId === reservation.agendaId && otherReservation.date === reservation.date && otherReservation.resourceId === reservation.resourceId && timeslotsOverlap(reservation.timeslot, otherReservation.timeslot) ).map((otherReservation) => ({ startTime: otherReservation.timeslot.startTime, endTime: otherReservation.timeslot.endTime, spots: otherReservation.spots })); const reservedSpotsForSelection = getMaxConcurrentSpots( reservation.timeslot, [...persistedIntervals, ...sameOrderIntervals] ); const availableSpots = maxCapacity - reservedSpotsForSelection; if (reservation.spots > availableSpots) { errorDetails.push({ code: "spots_unavailable", message: `Only ${Math.max(availableSpots, 0)} spots available`, scope: "reservation", field: "spots", reservationIndex: index, reservationId: reservation.id, agendaId: reservation.agendaId, date: reservation.date, timeslot: reservation.timeslot, resourceId: reservation.resourceId, requestedSpots: reservation.spots, availableSpots: Math.max(availableSpots, 0) }); } } return errorDetails; } function getAvailableTimeslots(agenda, dateString, resourceId, pricingOptionId) { const date = new Date(dateString); let serviceDuration = 30; if (pricingOptionId) { const option = agenda.pricingOptions?.find((p) => p.id === pricingOptionId); serviceDuration = option?.duration || 30; } else if (agenda.pricingOptions && agenda.pricingOptions.length > 0) { serviceDuration = agenda.pricingOptions[0].duration || 30; } if (agenda.resources && agenda.resources.length > 0) { const activeResources = agenda.resources.filter((r) => r.isActive); if (resourceId) { const resource = activeResources.find((r) => r.id === resourceId); if (resource) { return generateTimeSlotsForResource(resource, serviceDuration, date, dateString, agenda); } return []; } else { const slotMap = /* @__PURE__ */ new Map(); activeResources.forEach((resource) => { const resourceSlots = generateTimeSlotsForResource(resource, serviceDuration, date, dateString, agenda); resourceSlots.forEach((slot) => { const key = `${slot.startTime}-${slot.endTime}`; if (!slotMap.has(key)) { slotMap.set(key, slot); } }); }); return Array.from(slotMap.values()); } } return []; } function calculateReservationPrice(agenda, spots, pricingOptionId, addOns, date, timeslot) { let unitPrice = 0; const option = pricingOptionId ? agenda.pricingOptions?.find((p) => p.id === pricingOptionId) : agenda.pricingOptions?.find((p) => p.isDefault); if (option) { unitPrice = option.price; } if (agenda.pricingRules && agenda.pricingRules.length > 0) { unitPrice = applyPricingRules(unitPrice, agenda.pricingRules, date, timeslot); } const basePrice = roundMoney(unitPrice * spots); let addOnsPrice = 0; const allAddOns = agenda.addOns || []; for (const [addOnId, value] of Object.entries(addOns)) { if (!value) continue; const addOn = allAddOns.find((a) => a.id === addOnId); if (addOn) { if (addOn.scope === "TICKET") { addOnsPrice += addOn.price * spots; } else if (addOn.scope === "BOOKING" || addOn.scope === "RESERVATION") { addOnsPrice += addOn.price; } } } addOnsPrice = roundMoney(addOnsPrice); return { basePrice, addOnsPrice, totalPrice: roundMoney(basePrice + addOnsPrice) }; } function parseTimeDecimal(time) { const [hours, minutes = 0] = time.split(":").map(Number); if (!Number.isFinite(hours) || !Number.isFinite(minutes)) { return null; } return hours + minutes / 60; } function isTimeRangeConditionValue(value) { return typeof value === "object" && value !== null && !Array.isArray(value) && "start" in value && "end" in value && typeof value.start === "string" && typeof value.end === "string"; } function isDateRangeConditionValue(value) { return typeof value === "object" && value !== null && !Array.isArray(value) && "startDate" in value && "endDate" in value && typeof value.startDate === "string" && typeof value.endDate === "string"; } function applyPricingRules(basePrice, rules, date, timeslot) { let price = basePrice; const bookingDate = new Date(date); const dayOfWeek = bookingDate.getDay(); const slotTimeDecimal = parseTimeDecimal(timeslot.startTime); for (const rule of rules) { let ruleApplies = false; switch (rule.condition) { case "time_after": { if (typeof rule.conditionValue !== "string" || slotTimeDecimal === null) break; const threshold = parseTimeDecimal(rule.conditionValue); ruleApplies = threshold !== null && slotTimeDecimal >= threshold; break; } case "time_before": { if (typeof rule.conditionValue !== "string" || slotTimeDecimal === null) break; const threshold = parseTimeDecimal(rule.conditionValue); ruleApplies = threshold !== null && slotTimeDecimal < threshold; break; } case "time_between": { if (!isTimeRangeConditionValue(rule.conditionValue) || slotTimeDecimal === null) break; const startThreshold = parseTimeDecimal(rule.conditionValue.start); const endThreshold = parseTimeDecimal(rule.conditionValue.end); ruleApplies = startThreshold !== null && endThreshold !== null && slotTimeDecimal >= startThreshold && slotTimeDecimal < endThreshold; break; } case "day_of_week": { ruleApplies = Array.isArray(rule.conditionValue) && rule.conditionValue.includes(dayOfWeek); break; } case "date_range": { if (!isDateRangeConditionValue(rule.conditionValue)) break; ruleApplies = date >= rule.conditionValue.startDate && date <= rule.conditionValue.endDate; break; } } if (ruleApplies) { if (rule.modifier === "fixed") { price += rule.amount; } else if (rule.modifier === "percentage") { price += basePrice * (rule.amount / 100); } } } return clampMoney(price); } async function createOrder(config, validatedReservations, customerInfo, paymentType = "full", orderAdjustments = [], metadata) { const { db, orders } = config; const fieldValue = getFirestoreFieldValue(db); const pricedReservations = validatedReservations.map((reservation) => ({ ...reservation, adjustments: reservation.adjustments || [], totalPrice: applyBookingAdjustments(reservation.totalPrice, reservation.adjustments) })); const reservationSubtotal = roundMoney(pricedReservations.reduce((sum, reservation) => sum + reservation.totalPrice, 0)); const subtotal = applyBookingAdjustments(reservationSubtotal, orderAdjustments); let discount = 0; if (new Set(pricedReservations.map((r) => r.agendaId)).size > 1) { throw new Error("All reservations in an order must belong to the same agenda"); } const agendaId = pricedReservations[0].agendaId; const storedReservations = pricedReservations.map((r) => { const { agendaId: agendaId2, ...rest } = r; return rest; }); const total = clampMoney(subtotal - discount); let amountDue = total; if (paymentType === "on-site") { amountDue = 0; } else if (paymentType === "partial") { amountDue = total; } const orderData = { status: paymentType === "on-site" ? "confirmed" : "pending", reservations: storedReservations, flattenedReservations: storedReservations?.map((r) => `${r.resourceId}_${r.date}_${r.timeslot.startTime.replace(":", "")}`), flattenedReservationDates: storedReservations?.map((r) => `${r.resourceId}_${r.date}`), customerInfo, adjustments: orderAdjustments.length > 0 ? [...orderAdjustments] : [], subtotal, discount, total, amountDue, amountPaid: 0, paymentType, paymentStatus: paymentType === "on-site" ? "unpaid" : "unpaid", createdAt: fieldValue.serverTimestamp(), updatedAt: fieldValue.serverTimestamp(), agendaId }; if (amountDue === 0) { orderData.paymentStatus = "paid"; orderData.status = "confirmed"; } if (metadata && Object.keys(metadata).length > 0) { orderData.metadata = { ...metadata }; } const docRef = await db.collection(orders).add(orderData); return { id: docRef.id, ...orderData }; } async function getOrder(config, orderId) { const { db, orders } = config; const docSnap = await db.collection(orders).doc(orderId).get(); if (!docSnap.exists) return null; return { id: docSnap.id, ...docSnap.data() }; } async function updateOrderStatus(config, orderId, status, paymentStatus, amountPaid) { const { db, orders } = config; const orderRef = db.collection(orders).doc(orderId); const updateData = { status, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }; if (paymentStatus !== void 0) { updateData.paymentStatus = paymentStatus; } if (amountPaid !== void 0) { updateData.amountPaid = amountPaid; const orderSnap = await orderRef.get(); if (!orderSnap.exists) throw new Error("Order not found"); const total = Number(orderSnap.data()?.total ?? 0); updateData.amountDue = Math.max(Number((total - amountPaid).toFixed(2)), 0); } await orderRef.update(updateData); } async function createPaymentLink(config, stripeConfig, orderId, options) { if (!stripeConfig.key) throw new Error("No Stripe key provided"); if (!orderId) throw new Error("No order ID provided"); const order = await getOrder(config, orderId); if (!order) throw new Error("Order not found"); const stripe = new Stripe(stripeConfig.key); const lineItems = order.reservations.map((r) => { const description = [ r.date, `${r.timeslot.startTime} - ${r.timeslot.endTime}`, r.resource?.name, `${r.spots} spot(s)` ].filter(Boolean).join(" | "); return { price_data: { currency: "eur", // TODO: get from agenda/config unit_amount: Math.round(r.totalPrice * 100), product_data: { name: r.pricingOption?.name || "Appointment", description } }, quantity: 1 }; }); const reservationSubtotal = order.reservations.reduce((sum, reservation) => sum + reservation.totalPrice, 0); const orderAdjustmentAmount = roundMoney(order.total - reservationSubtotal); if (orderAdjustmentAmount > 0) { lineItems.push({ price_data: { currency: "eur", unit_amount: Math.round(orderAdjustmentAmount * 100), product_data: { name: "Order adjustment" } }, quantity: 1 }); } let checkoutDiscount; if (orderAdjustmentAmount < 0) { const discountAmount = Math.round(Math.abs(orderAdjustmentAmount) * 100); if (discountAmount > 0) { const coupon = await stripe.coupons.create({ amount_off: discountAmount, currency: "eur", duration: "once", name: "Order discount", metadata: { order_id: order.id } }); checkoutDiscount = { coupon: coupon.id }; } } const webhookType = options.webhookType ?? stripeConfig.webhookType; const webhookTypeMetadataKey = options.webhookTypeMetadataKey ?? stripeConfig.webhookTypeMetadataKey ?? DEFAULT_WEBHOOK_TYPE_METADATA_KEY; const paymentMetadata = { order_id: order.id, ...options.metadata, ...webhookType ? { [webhookTypeMetadataKey]: webhookType } : {} }; const allowedWebhookTypes = getAllowedWebhookTypes(stripeConfig); if (webhookType && allowedWebhookTypes.length > 0 && !allowedWebhookTypes.includes(webhookType)) { throw new Error(`Webhook type "${webhookType}" is not allowed by Stripe config`); } const sessionParams = { payment_method_types: ["card", "bancontact", "ideal"], line_items: lineItems, mode: "payment", success_url: options.successUrl, cancel_url: options.cancelUrl, customer_email: options.customerEmail || order.customerInfo.email, metadata: paymentMetadata, payment_intent_data: { metadata: paymentMetadata } }; if (checkoutDiscount) { sessionParams.discounts = [checkoutDiscount]; } const session = await stripe.checkout.sessions.create(sessionParams); if (!session.url) { throw new Error("Could not create payment link"); } return { url: session.url, sessionId: session.id }; } async function handleStripeWebhook(config, stripeConfig, payload, signature) { const stripe = new Stripe(stripeConfig.key); const allowedWebhookTypes = getAllowedWebhookTypes(stripeConfig); const event = stripe.webhooks.constructEvent( payload, signature, stripeConfig.webhook_secret ); if (!event.type.startsWith("checkout.session.")) { return { handled: false, eventType: event.type, expectedWebhookTypes: allowedWebhookTypes, ignoredReason: "unsupported_event_type" }; } const session = event.data.object; const orderId = session.metadata?.order_id; const webhookTypeMetadataKey = stripeConfig.webhookTypeMetadataKey ?? DEFAULT_WEBHOOK_TYPE_METADATA_KEY; const webhookType = session.metadata?.[webhookTypeMetadataKey]; if (allowedWebhookTypes.length > 0 && (!webhookType || !allowedWebhookTypes.includes(webhookType))) { console.warn( `Ignoring Stripe webhook with ${webhookTypeMetadataKey}=${webhookType ?? "missing"}; expected one of: ${allowedWebhookTypes.join(", ")}` ); return { handled: false, eventType: event.type, webhookType: webhookType ?? null, expectedWebhookTypes: allowedWebhookTypes, ignoredReason: "unexpected_webhook_type" }; } if (!orderId) { console.warn("No order_id in webhook metadata, ignoring event"); return { handled: false, eventType: event.type, webhookType: webhookType ?? null, expectedWebhookTypes: allowedWebhookTypes, ignoredReason: "missing_order_id" }; } switch (event.type) { case "checkout.session.completed": { const amountPaid = (session.amount_total || 0) / 100; await updateOrderStatus(config, orderId, "confirmed", "paid", amountPaid); return { handled: true, orderId, eventType: event.type, webhookType }; } case "checkout.session.async_payment_succeeded": { const amountPaid = (session.amount_total || 0) / 100; await updateOrderStatus(config, orderId, "confirmed", "paid", amountPaid); return { handled: true, orderId, eventType: event.type, webhookType }; } case "checkout.session.async_payment_failed": { await updateOrderStatus(config, orderId, "cancelled", "unpaid"); return { handled: true, orderId, eventType: event.type, webhookType }; } case "checkout.session.expired": { await updateOrderStatus(config, orderId, "cancelled", "unpaid"); return { handled: true, orderId, eventType: event.type, webhookType }; } } return { handled: false, eventType: event.type, webhookType: webhookType ?? null, expectedWebhookTypes: allowedWebhookTypes, ignoredReason: "unsupported_event_type" }; } function applyBookingAdjustments(amount, adjustments = []) { let total = amount; for (const adjustment of adjustments) { if (adjustment.type === "fixed") { total += adjustment.value; continue; } total += total * (adjustment.value / 100); } return clampMoney(total); } function formatBookingValidationError(error) { if (error.scope === "reservation" && error.reservationIndex !== void 0) { return `Reservation ${error.reservationIndex + 1}: ${error.message}`; } return error.message; } function withReservationContext(error, input, reservationIndex) { return { ...error, scope: "reservation", reservationIndex: error.reservationIndex ?? reservationIndex, reservationId: error.reservationId ?? input.id, agendaId: error.agendaId ?? input.agendaId, date: error.date ?? input.date, timeslot: error.timeslot ?? input.timeslot, resourceId: error.resourceId ?? input.resourceId }; } function fallbackReservationErrors(errors) { return errors.map((message) => ({ code: "validation_error", message, scope: "reservation" })); } class BookerServer { config; constructor(config) { this.config = config; if (!this.config.defaultCurrency) this.config.defaultCurrency = "EUR"; if (!this.config.defaultLocale) this.config.defaultLocale = "en-US"; } // ============================================================================ // Reservation Validation // ============================================================================ async validateReservation(input) { if (this.config.provider === "firebase") { return await validateReservation(this.config.firebase, input); } throw new Error(`Provider ${this.config.provider} not supported.`); } async validateReservations(inputs) { return await Promise.all( inputs.map((input) => this.validateReservation(input)) ); } // ============================================================================ // Order Management // ============================================================================ async createOrder(input) { if (this.config.provider !== "firebase") { throw new Error(`Provider ${this.config.provider} not supported.`); } const errorDetails = []; const toFailureResult = () => ({ success: false, errors: errorDetails.map(formatBookingValidationError), errorDetails }); if (input.reservations.length === 0) { errorDetails.push({ code: "reservation_required", message: "At least one reservation is required", scope: "order", field: "reservations" }); } const validationResults = await this.validateReservations(input.reservations); const validatedReservations = []; const availabilityReservations = []; for (let i = 0; i < validationResults.length; i++) { const result = validationResults[i]; if (!result.valid) { const resultErrorDetails = result.errorDetails?.length ? result.errorDetails : fallbackReservationErrors(result.errors); errorDetails.push( ...resultErrorDetails.map( (error) => withReservationContext(error, input.reservations[i], i) ) ); } else if (result.reservation) { availabilityReservations.push(result.reservation); const cleanedReservation = { ...result.reservation, resolvedAddOns: result.reservation.resolvedAddOns?.map((addOn) => ({ id: addOn.id, name: addOn.name, price: addOn.price, description: addOn.description })) || [], resource: result.reservation.resource?.id ? { id: result.reservation.resource.id, name: result.reservation.resource.publicLabel ?? result.reservation.resource.name ?? "", description: result.reservation.resource.description ?? "", avatarLabel: result.reservation.resource.avatarLabel ?? "", color: result.reservation.resource.color ?? "" } : void 0, pricingOption: result.reservation.pricingOption?.id ? { id: result.reservation.pricingOption.id, name: result.reservation.pricingOption.name ?? "", price: result.reservation.pricingOption.price ?? "", duration: result.reservation.pricingOption.duration ?? "" } : void 0 }; validatedReservations.push(cleanedReservation); } } if (errorDetails.length > 0) { return toFailureResult(); } errorDetails.push(...await validateReservationsAvailability( this.config.firebase, availabilityReservations )); if (errorDetails.length > 0) { return toFailureResult(); } if (!input.customerInfo.email) { errorDetails.push({ code: "customer_email_required", message: "Customer email is required", scope: "customer", field: "customerInfo.email" }); } if (!input.customerInfo.firstName) { errorDetails.push({ code: "customer_first_name_required", message: "Customer first name is required", scope: "customer", field: "customerInfo.firstName" }); } if (!input.customerInfo.lastName) { errorDetails.push({ code: "customer_last_name_required", message: "Customer last name is required", scope: "customer", field: "customerInfo.lastName" }); } if (errorDetails.length > 0) { return toFailureResult(); } const order = await createOrder( this.config.firebase, validatedReservations, input.customerInfo, input.paymentType, input.adjustments, input.metadata ); return { success: true, order, errors: [], errorDetails: [] }; } async getOrder(orderId) { if (this.config.provider === "firebase") { return await getOrder(this.config.firebase, orderId); } throw new Error(`Provider ${this.config.provider} not supported.`); } async updateOrderStatus(orderId, status, paymentStatus, amountPaid) { if (this.config.provider === "firebase") { return await updateOrderStatus( this.config.firebase, orderId, status, paymentStatus, amountPaid ); } throw new Error(`Provider ${this.config.provider} not supported.`); } // ============================================================================ // Order Change Processing // ============================================================================ async processBookingChange(options) { const { event, onConfirmed, onCancelled, shouldProcessReservation } = options; if (!event.data) return; const { before, after } = event.data; const beforeOrder = snapshotToBookingOrder(before); const afterOrder = snapshotToBookingOrder(after); const eventId = event.id; const agendaId = afterOrder?.agendaId || beforeOrder?.agendaId || afterOrder?.reservations?.[0]?.agendaId || beforeOrder?.reservations?.[0]?.agendaId; const firebaseConfig = this.config.firebase; if (!firebaseConfig) { throw new Error("Firebase config is required to process booking changes"); } const { db, reserved_spots: reservedSpotsCollection, processed_events: processedEventsCollection } = firebaseConfig; if (!processedEventsCollection) { throw new Error("Processed events collection name is required in Firebase config"); } const fieldValue = getFirestoreFieldValue(db); const processReservation = (reservation, order) => { return shouldProcessReservation ? shouldProcessReservation(reservation, { event, order }) : reservation.status === "approved"; }; const { toAdd, toRemove } = calculateBookingDiff( beforeOrder, afterOrder, processReservation ); const confirmedOrder = afterOrder?.status === "confirmed" && beforeOrder?.status !== "confirmed" ? afterOrder : null; const cancelledOrder = afterOrder?.status === "cancelled" && beforeOrder?.status === "confirmed" ? afterOrder : null; const shouldCallConfirmed = Boolean(confirmedOrder && onConfirmed); const shouldCallCancelled = Boolean(cancelledOrder && onCancelled); if (toRemove.length === 0 && toAdd.length === 0 && !shouldCallConfirmed && !shouldCallCancelled) return; if (!agendaId && (toRemove.length > 0 || toAdd.length > 0)) { console.warn("No agendaId, skipping reserved spot updates", { eventId }); } try { console.log("Processing order change", { eventId, toRemove, toAdd }); const canRunCallbacks = await db.runTransaction(async (transaction) => { const eventRef = db.collection(processedEventsCollection).doc(eventId); const eventSnap = await transaction.get(eventRef); if (eventSnap.exists) { return eventSnap.data()?.callbackStateVersion === 1; } const dayChanges = {}; const getSlotKey = (reservation) => `${reservation.resourceId}~${reservation.timeslot.startTime}-${reservation.timeslot.endTime}`; if (agendaId) { toRemove.forEach((reservation) => { const docId = `${agendaId}_${reservation.date}`; const slotKey = getSlotKey(reservation); if (!dayChanges[docId]) dayChanges[docId] = {}; dayChanges[docId][slotKey] = (dayChanges[docId][slotKey] || 0) - reservation.spots; }); toAdd.forEach((reservation) => { const docId = `${agendaId}_${reservation.date}`; const slotKey = getSlotKey(reservation); if (!dayChanges[docId]) dayChanges[docId] = {}; dayChanges[docId][slotKey] = (dayChanges[docId][slotKey] || 0) + reservation.spots; }); } for (const [docId, slots] of Object.entries(dayChanges)) { const dayRef = db.collection(reservedSpotsCollection).doc(docId); const updateData = { lastUpdated: fieldValue.serverTimestamp() }; for (const [slotKey, delta] of Object.entries(slots)) { if (delta === 0) continue; const [resourceId, slotRange] = slotKey.split("~"); const resourceUpdates = updateData[resourceId] ??= {}; resourceUpdates[slotRange] = fieldValue.increment(delta); } transaction.set(dayRef, updateData, { merge: true }); } transaction.set(eventRef, { processedAt: fieldValue.serverTimestamp(), callbackStateVersion: 1, confirmedCallbackRequested: shouldCallConfirmed, cancelledCallbackRequested: shouldCallCancelled, orderId: afterOrder?.id ?? beforeOrder?.id }); return true; }); if (!canRunCallbacks) return; if (confirmedOrder && onConfirmed) { await runBookingChangeCallbackOnce( db, processedEventsCollection, fieldValue, eventId, "confirmedCallbackRequested", "confirmedCallbackProcessedAt", () => onConfirmed({ event, order: confirmedOrder, previousOrder: beforeOrder, beforeData: beforeOrder, afterData: confirmedOrder, addedReservations: toAdd, removedReservations: toRemove }) ); } if (cancelledOrder && onCancelled) { await runBookingChangeCallbackOnce( db, processedEventsCollection, fieldValue, eventId, "cancelledCallbackRequested", "cancelledCallbackProcessedAt", () => onCancelled({ event, order: cancelledOrder, previousOrder: beforeOrder, beforeData: beforeOrder, afterData: cancelledOrder, addedReservations: toAdd, removedReservations: toRemove }) ); } } catch (e) { console.error("Booking change processing failed:", e); throw e; } } // ============================================================================ // Payment // ============================================================================ async createPaymentLink(orderId, options) { if (this.config.provider === "firebase" && this.config.paymentProvider === "stripe") { return await createPaymentLink( this.config.firebase, this.config.stripe, orderId, options ); } throw new Error(`Provider ${this.config.provider}/${this.config.paymentProvider} not supported.`); } async handleWebhook(payload, signature) { if (this.config.provider === "firebase" && this.config.paymentProvider === "stripe") { return await handleStripeWebhook( this.config.firebase, this.config.stripe, payload, signature ); } throw new Error(`Provider ${this.config.provider}/${this.config.paymentProvider} not supported.`); } } function snapshotToBookingOrder(snapshot) { if (!snapshot.exists) return null; return { id: snapshot.id, ...snapshot.data() }; } async function runBookingChangeCallbackOnce(db, processedEventsCollection, fieldValue, eventId, requestedField, processedField, callback) { const eventRef = db.collection(processedEventsCollection).doc(eventId); const eventSnap = await eventRef.get(); const eventData = eventSnap.data(); if (eventData?.callbackStateVersion !== 1 || eventData?.[requestedField] !== true || eventData?.[processedField]) return; await callback(); await eventRef.set({ [processedField]: fieldValue.serverTimestamp() }, { merge: true }); } function calculateBookingDiff(beforeOrder, afterOrder, shouldProcessReservation) { const wasConfirmed = beforeOrder?.status === "confirmed"; const isConfirmed = afterOrder?.status === "confirmed"; let toRemove = []; let toAdd = []; const getProcessableReservations = (order) => (order?.reservations ?? []).filter( (reservation) =