@wallfar/ocd-studio-core-sdk
Version:
Helper SDK for our OneClick Studio modules
1,329 lines (1,324 loc) • 51.1 kB
JavaScript
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) =