Skip to content

Commit 3394610

Browse files
Merge pull request #13 from peek-travel/docs/model-tsdoc
Booking model docs, per-ticket prices, and booking/order id validation
2 parents a6a7590 + 8a1ce01 commit 3394610

18 files changed

Lines changed: 443 additions & 124 deletions

docs/internal/ARCHITECTURE.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,16 @@ Recurring patterns inside services:
200200
`removeAddon` cancels options by their existing refids, marking the parent
201201
add-on canceled only when all of its options end up canceled. Both finish by
202202
re-listing and returning the booking's refreshed add-ons.
203-
- **Input validation** lives in the service (booking id prefix `b_`/`B-`,
204-
3-letter currency, positive-integer quantities, allowed payment sources, etc.).
205-
`normalizeBookingId` lowercases and converts `-``_`.
203+
- **Input validation** lives in the service (3-letter currency,
204+
positive-integer quantities, allowed payment sources, etc.).
205+
`normalizeBookingId` lowercases and converts `-``_`. Every booking/order id
206+
accepted as a parameter is format-checked **before** normalization by
207+
`assertBookingId`/`assertOrderId`: an id is valid only as a lowercase db id
208+
with `_` (`b_abc123` / `o_abc123`) or an uppercase display id with `-`
209+
(`B-ABC123` / `O-ABC123`) — bookings prefixed `b`, orders `o`. Mixed forms
210+
(`B_abc123`, `o-Ab123`), a missing prefix, or the wrong resource's id are
211+
rejected. Validating pre-normalization is deliberate: normalization would
212+
erase the case/separator distinction the check relies on.
206213

207214
### 5. Public API surface
208215
`src/index.ts`

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@peektravel/app-utilities",
3-
"version": "0.2.5",
3+
"version": "0.2.6",
44
"description": "GraphQL JS mapping utilities extracted from the Peek Pro Autopilot connector.",
55
"license": "MIT",
66
"repository": {

src/internal/bookings/booking-converter.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export function fromBookingNode(
113113
timeslotId: data.timeSnapshot?.legacyId || null,
114114
totalTickets: ticketQuantity(ticketQuantities),
115115
ticketDescription: formatTickets(ticketQuantities),
116-
tickets: ticketsToTicketArray(ticketQuantities),
116+
tickets: ticketsToTicketArray(ticketQuantities, includePriceBreakdown),
117117

118118
isCanceled: data.reservationStatus === "CANCELED",
119119
isNoShow: data.fulfillmentStatusOverride?.status === "NO_SHOW",
@@ -244,12 +244,15 @@ function resellerNameFromChannelSnapshot(
244244

245245
function ticketsToTicketArray(
246246
ticketQuantities: BookingNode["ticketQuantities"],
247+
includePriceBreakdown: boolean,
247248
): Ticket[] {
248249
if (!ticketQuantities || ticketQuantities.length === 0) return [];
249250
return ticketQuantities.map((ticket) => ({
250251
name: ticket.resourceOptionSnapshot?.name || "Unknown",
251252
quantity: ticket.quantity || 0,
252253
ticketId: ticket.resourceOptionSnapshot?.id || "unknown",
254+
listPrice: includePriceBreakdown ? mapPrice(ticket.value?.price) : undefined,
255+
totalValue: includePriceBreakdown ? mapPrice(ticket.value?.total) : undefined,
253256
}));
254257
}
255258

src/internal/bookings/booking-queries.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,14 +171,30 @@ export const PRICE_BREAKDOWN_FIELDS = `
171171
tips { amount formatted }
172172
`;
173173

174+
/**
175+
* Per-ticket price selection (list price + line total) injected into
176+
* `ticketQuantities` when the price breakdown is requested.
177+
*/
178+
export const TICKET_VALUE_FIELDS = `
179+
value {
180+
price { amount formatted }
181+
total { amount formatted }
182+
}
183+
`;
184+
174185
/** Builds the bookings listing query, optionally including guests and price breakdown. */
175186
export function buildBookingsListingQuery(
176187
includeGuests: boolean,
177188
includePriceBreakdown: boolean,
178189
): string {
179190
const guestsSection = includeGuests ? bookingGuestsFields : "";
191+
// Inject the booking-level breakdown first: it anchors on the first `value {`,
192+
// which is the booking node's. The ticket-level `value` (added by the second
193+
// replace) must go in afterwards, or it would capture that anchor instead.
180194
const fields = includePriceBreakdown
181-
? bookingQueryFields.replace("value {", `value { ${PRICE_BREAKDOWN_FIELDS}`)
195+
? bookingQueryFields
196+
.replace("value {", `value { ${PRICE_BREAKDOWN_FIELDS}`)
197+
.replace("ticketQuantities {", `ticketQuantities { ${TICKET_VALUE_FIELDS}`)
182198
: bookingQueryFields;
183199

184200
return `
@@ -469,6 +485,11 @@ export interface BookingNode {
469485
ticketQuantities?: Array<{
470486
quantity?: number;
471487
resourceOptionSnapshot?: { name?: string; id?: string } | null;
488+
// Present only when the price breakdown is requested (see TICKET_VALUE_FIELDS).
489+
value?: {
490+
price?: { amount?: string; formatted?: string };
491+
total?: { amount?: string; formatted?: string };
492+
};
472493
}>;
473494
reservationStatus?: string;
474495
checkinStatus?: string;

src/internal/bookings/booking-service.ts

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,13 @@ const DEFAULT_CANCEL_NOTE = "Canceled";
8383
/** Default customer message attached to a charge. */
8484
const DEFAULT_CUSTOMER_MESSAGE = "Charge initiated via API";
8585

86-
const BOOKING_ID_PREFIX = "b_";
86+
// Booking/order ids come in two forms: a lowercase db id with `_`
87+
// (`b_abc123` / `o_abc123`) and an uppercase display id with `-`
88+
// (`B-ABC123` / `O-ABC123`). Bookings are prefixed `b`, orders `o`.
89+
const BOOKING_DB_ID_REGEX = /^b_[a-z0-9]+$/;
90+
const BOOKING_DISPLAY_ID_REGEX = /^B-[A-Z0-9]+$/;
91+
const ORDER_DB_ID_REGEX = /^o_[a-z0-9]+$/;
92+
const ORDER_DISPLAY_ID_REGEX = /^O-[A-Z0-9]+$/;
8793
const PAYMENT_SOURCE_PREFIX = "ps_";
8894
const PAYMENT_ID_PREFIX = "pmt_";
8995
const ALLOWED_PAYMENT_SOURCE_IDS = ["cash/cash", "custom/other", "custom/voucher"];
@@ -126,7 +132,10 @@ export interface AddAddonInput {
126132

127133
const ERROR_ADDON_OPTION_ID_REQUIRED = "addonOptionId is required";
128134
const ERROR_QUANTITY_INVALID = "quantity must be a positive integer string";
129-
const ERROR_BOOKING_ID_REQUIRED = "bookingId is required";
135+
const ERROR_INVALID_BOOKING_ID =
136+
"bookingId is required and must be a valid booking id, e.g. 'b_abc123' or 'B-ABC123'";
137+
const ERROR_INVALID_ORDER_ID =
138+
"orderId is required and must be a valid order id, e.g. 'o_abc123' or 'O-ABC123'";
130139
const ERROR_BOOKING_NOT_FOUND = "Booking not found";
131140
const ERROR_MULTIPLE_BOOKINGS_FOUND =
132141
"Expected exactly one booking for the provided bookingId";
@@ -166,6 +175,7 @@ export class BookingService {
166175
bookingId: string,
167176
options: BookingReadOptions = {},
168177
): Promise<Booking | null> {
178+
assertBookingId(bookingId);
169179
const includeGuests = options.includeGuests ?? false;
170180
const includePriceBreakdown = options.includePriceBreakdown ?? false;
171181

@@ -225,6 +235,7 @@ export class BookingService {
225235

226236
/** Returns the guests on a booking (primary guest included). */
227237
async getGuests(bookingId: string): Promise<Guest[]> {
238+
assertBookingId(bookingId);
228239
const body: GraphQLBody<BookingGuestsResponse> =
229240
await this.client.request<BookingGuestsResponse>(
230241
SALES_ENDPOINT,
@@ -240,6 +251,7 @@ export class BookingService {
240251

241252
/** Returns the payments on file for a booking, or null when not found. */
242253
async getPaymentsOnFile(bookingId: string): Promise<BookingPaymentsOnFile | null> {
254+
assertBookingId(bookingId);
243255
const normalized = normalizeBookingId(bookingId);
244256
const body: GraphQLBody<BookingPaymentsOnFileResponse> =
245257
await this.client.request<BookingPaymentsOnFileResponse>(
@@ -259,6 +271,7 @@ export class BookingService {
259271
note: string,
260272
mode: NoteMode = "append",
261273
): Promise<Booking | null> {
274+
assertBookingId(bookingId);
262275
const normalized = normalizeBookingId(bookingId);
263276
const booking = await this.getById(normalized);
264277
if (!booking) {
@@ -284,6 +297,7 @@ export class BookingService {
284297
bookingId: string,
285298
checkedIn: boolean,
286299
): Promise<Booking | null> {
300+
assertBookingId(bookingId);
287301
const normalized = normalizeBookingId(bookingId);
288302
const checkedInAt = checkedIn ? new Date().toISOString() : null;
289303

@@ -299,6 +313,7 @@ export class BookingService {
299313
bookingId: string,
300314
notes: string = DEFAULT_CANCEL_NOTE,
301315
): Promise<CancelBookingResult> {
316+
assertBookingId(bookingId);
302317
const body: GraphQLBody<CancelBookingResponse> =
303318
await this.client.request<CancelBookingResponse>(
304319
SALES_ENDPOINT,
@@ -337,12 +352,13 @@ export class BookingService {
337352
* @throws {Error} when `paymentSourceId` is missing or not a `ps_…` id / one
338353
* of `cash/cash`, `custom/other`, `custom/voucher`; when `amount` is not a
339354
* valid number; when `currency` is not a 3-letter uppercase code; when
340-
* `idempotencyKey` is empty; when `bookingId` does not resolve to a `b_…` id;
341-
* when the booking or payment source is not found; or when the charge fails.
355+
* `idempotencyKey` is empty; when `bookingId` is not a valid booking id
356+
* (`b_…`/`B-…`); when the booking or payment source is not found; or when the
357+
* charge fails.
342358
*/
343359
async makePayment(input: MakePaymentInput): Promise<MakePaymentResult> {
360+
this.validatePaymentInput(input);
344361
const normalized = normalizeBookingId(input.bookingId);
345-
this.validatePaymentInput(input, normalized);
346362

347363
const onFile = await this.getPaymentsOnFile(normalized);
348364
if (!onFile) {
@@ -392,8 +408,8 @@ export class BookingService {
392408
* payments-on-file, then applies the refund.
393409
*/
394410
async refund(input: RefundInput): Promise<RefundResult> {
411+
this.validateRefundInput(input);
395412
const normalized = normalizeBookingId(input.bookingId);
396-
this.validateRefundInput(input, normalized);
397413

398414
const onFile = await this.getPaymentsOnFile(normalized);
399415
if (!onFile) {
@@ -438,9 +454,7 @@ export class BookingService {
438454

439455
/** Creates an invoice link for a booking's order. */
440456
async createInvoiceLink(bookingId: string): Promise<InvoiceLinkResult> {
441-
if (!bookingId || bookingId.trim().length === 0) {
442-
throw new Error("bookingId is required");
443-
}
457+
assertBookingId(bookingId);
444458
const normalized = normalizeBookingId(bookingId);
445459

446460
const booking = await this.getById(normalized);
@@ -465,6 +479,7 @@ export class BookingService {
465479

466480
/** Returns the add-ons on a booking, grouped by add-on item (clean model). */
467481
async listAddons(bookingId: string): Promise<BookingAddons> {
482+
assertBookingId(bookingId);
468483
const { items, displayId, orderId, normalizedBookingId } =
469484
await this.fetchBookingSale(bookingId);
470485

@@ -508,6 +523,7 @@ export class BookingService {
508523
bookingId: string,
509524
input: AddAddonInput,
510525
): Promise<BookingAddonsMutationResult> {
526+
assertBookingId(bookingId);
511527
const addonOptionId = (input?.addonOptionId || input?.addonId || "").trim();
512528
if (!addonOptionId) {
513529
throw new Error(ERROR_ADDON_OPTION_ID_REQUIRED);
@@ -583,6 +599,7 @@ export class BookingService {
583599
bookingId: string,
584600
input: AddAddonInput,
585601
): Promise<BookingAddonsMutationResult> {
602+
assertBookingId(bookingId);
586603
const addonOptionId = (input?.addonOptionId || input?.addonId || "").trim();
587604
if (!addonOptionId) {
588605
throw new Error(ERROR_ADDON_OPTION_ID_REQUIRED);
@@ -627,16 +644,11 @@ export class BookingService {
627644
orderId: string;
628645
normalizedBookingId: string;
629646
}> {
630-
const searchString = (bookingId || "").trim();
631-
if (!searchString) {
632-
throw new Error(ERROR_BOOKING_ID_REQUIRED);
633-
}
634-
635647
const body: GraphQLBody<SalesAddonsResponse> =
636648
await this.client.request<SalesAddonsResponse>(
637649
SALES_ENDPOINT,
638650
SALES_ADDONS_QUERY,
639-
buildSalesAddonsVariables(searchString),
651+
buildSalesAddonsVariables(bookingId),
640652
);
641653

642654
const edges = body.data?.sales?.edges ?? [];
@@ -864,7 +876,7 @@ export class BookingService {
864876
return matched.productId;
865877
}
866878

867-
private validatePaymentInput(input: MakePaymentInput, normalizedBookingId: string): void {
879+
private validatePaymentInput(input: MakePaymentInput): void {
868880
if (
869881
!input.paymentSourceId ||
870882
(!input.paymentSourceId.startsWith(PAYMENT_SOURCE_PREFIX) &&
@@ -878,17 +890,17 @@ export class BookingService {
878890
assertAmount(input.amount);
879891
assertCurrency(input.currency);
880892
assertIdempotencyKey(input.idempotencyKey);
881-
assertBookingId(normalizedBookingId);
893+
assertBookingId(input.bookingId);
882894
}
883895

884-
private validateRefundInput(input: RefundInput, normalizedBookingId: string): void {
896+
private validateRefundInput(input: RefundInput): void {
885897
if (!input.paymentId || !input.paymentId.startsWith(PAYMENT_ID_PREFIX)) {
886898
throw new Error("paymentId is required and must start with 'pmt_'");
887899
}
888900
assertAmount(input.amount);
889901
assertCurrency(input.currency);
890902
assertIdempotencyKey(input.idempotencyKey);
891-
assertBookingId(normalizedBookingId);
903+
assertBookingId(input.bookingId);
892904
}
893905

894906
private async fetchPaginated(
@@ -951,9 +963,17 @@ function assertIdempotencyKey(idempotencyKey: string): void {
951963
}
952964
}
953965

954-
function assertBookingId(normalizedBookingId: string): void {
955-
if (!normalizedBookingId.startsWith(BOOKING_ID_PREFIX)) {
956-
throw new Error("bookingId is required and must start with 'b_' or 'B-'");
966+
/** Throws unless `bookingId` is a valid booking db id (`b_…`) or display id (`B-…`). */
967+
function assertBookingId(bookingId: string): void {
968+
if (!(BOOKING_DB_ID_REGEX.test(bookingId) || BOOKING_DISPLAY_ID_REGEX.test(bookingId))) {
969+
throw new Error(ERROR_INVALID_BOOKING_ID);
970+
}
971+
}
972+
973+
/** Throws unless `orderId` is a valid order db id (`o_…`) or display id (`O-…`). */
974+
function assertOrderId(orderId: string): void {
975+
if (!(ORDER_DB_ID_REGEX.test(orderId) || ORDER_DISPLAY_ID_REGEX.test(orderId))) {
976+
throw new Error(ERROR_INVALID_ORDER_ID);
957977
}
958978
}
959979

@@ -972,6 +992,9 @@ function validateCreateInput(input: CreateBookingInput): void {
972992
if (input.markAsPaid && !input.idempotencyKey) {
973993
throw new Error("idempotencyKey is required when markAsPaid is set");
974994
}
995+
if (input.parentOrderId) {
996+
assertOrderId(input.parentOrderId);
997+
}
975998
}
976999

9771000
function parseQuantity(value: unknown): number | null {

0 commit comments

Comments
 (0)