Skip to content

Commit 2269a31

Browse files
oskarbrueningclaude
andcommitted
feat: validate booking and order id format in the service layer
Every booking/order id accepted as a parameter is now format-checked before normalization by assertBookingId/assertOrderId. An id is valid only as a lowercase db id with `_` (b_abc123 / o_abc123) or an uppercase display id with `-` (B-ABC123 / O-ABC123) — bookings prefixed `b`, orders `o`. Mixed forms (B_abc123, o-Ab123), a missing prefix, or the wrong resource's id are rejected. Checking pre-normalization is deliberate: normalizeBookingId would erase the case/separator distinction the check relies on. Wired into getById, getGuests, getPaymentsOnFile, appendNote, setCheckinStatus, cancel, makePayment, refund, createInvoiceLink, listAddons, addAddon, removeAddon, and create's parentOrderId. Also renames the non-canonical mock ids (bkg_*/ord-*) in the booking and membership tests to the single-letter form the API documents. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f8839e2 commit 2269a31

9 files changed

Lines changed: 160 additions & 97 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`

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 {

test/bookings/addon-converter.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ function node(overrides: Partial<SalesAddonBookingNode> = {}): SalesAddonBooking
1414
displayId: "B-1",
1515
refid: "bq-1",
1616
reservationStatus: "CONFIRMED",
17-
order: { id: "ord-1", displayId: "O-1" },
17+
order: { id: "o_1", displayId: "O-1" },
1818
items: [],
1919
...overrides,
2020
} as SalesAddonBookingNode;
@@ -48,7 +48,7 @@ describe("parseSaleNode", () => {
4848
{
4949
bookingId: "b_1",
5050
displayId: "B-1",
51-
orderId: "ord-1",
51+
orderId: "o_1",
5252
total: MONEY,
5353
bookingQuoteRefid: "bq-1",
5454
bookingQuoteReservationStatus: "CONFIRMED",
@@ -136,7 +136,7 @@ describe("toBookingAddon", () => {
136136
return {
137137
bookingId: "b_1",
138138
displayId: "B-1",
139-
orderId: "ord-1",
139+
orderId: "o_1",
140140
total: MONEY,
141141
bookingQuoteRefid: "bq-1",
142142
bookingQuoteReservationStatus: "CONFIRMED",

test/bookings/booking-converter.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { BookingNode } from "../../src/internal/bookings/booking-queries.js
88

99
function fullNode(): BookingNode {
1010
return {
11-
id: "bkg_1",
11+
id: "b_1",
1212
displayId: "B-1",
1313
primaryGuest: {
1414
id: "g1",
@@ -60,7 +60,7 @@ function fullNode(): BookingNode {
6060
balance: { total: { amount: "10.00", formatted: "$10.00" } },
6161
tips: [{ price: { amount: "5.00", formatted: "$5.00" } }],
6262
order: {
63-
id: "ord-1",
63+
id: "o_1",
6464
promoCodes: [{ code: "SUMMER" }],
6565
channelSnapshot: { id: "ch-1", name: "Acme", agent: { name: "Jane" } },
6666
initialQuote: { source: { actor: { app: "WIDGET" } } },
@@ -108,7 +108,7 @@ describe("fromBookingNode", () => {
108108
it("maps a fully-populated node with guests and price breakdown", () => {
109109
const booking = fromBookingNode(fullNode(), true, true);
110110

111-
expect(booking.bookingId).toBe("bkg_1");
111+
expect(booking.bookingId).toBe("b_1");
112112
expect(booking.displayId).toBe("B-1");
113113
expect(booking.source).toBe("website");
114114
expect(booking.sourceApp).toBe("WIDGET");
@@ -141,7 +141,7 @@ describe("fromBookingNode", () => {
141141
expect(booking.resourcePoolAssignments).toEqual([{ id: "res-1", name: "Ada" }]);
142142
expect(booking.resellerId).toBe("ch-1");
143143
expect(booking.resellerName).toBe("Acme - Jane");
144-
expect(booking.orderId).toBe("ord-1");
144+
expect(booking.orderId).toBe("o_1");
145145
expect(booking.convenienceFee).toEqual({ amount: "2.00", display: "$2.00" });
146146
expect(booking.price).toEqual({ amount: "90.00", display: "$90.00" });
147147
expect(booking.taxes).toBeUndefined();

test/bookings/booking-guest-converter.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ describe("fromBookingGuestsResponse", () => {
1212
const guests = fromBookingGuestsResponse(
1313
response({
1414
displayId: "B-1",
15-
id: "bkg_1",
15+
id: "b_1",
1616
primaryGuest: { id: "g1" },
1717
bookingGuests: [{ id: "g1" }, { id: "g2" }],
1818
}),
@@ -27,7 +27,7 @@ describe("fromBookingGuestsResponse", () => {
2727
const guests = fromBookingGuestsResponse(
2828
response({
2929
displayId: "B-1",
30-
id: "bkg_1",
30+
id: "b_1",
3131
primaryGuest: { id: "p1" },
3232
bookingGuests: [{ id: "g2" }],
3333
}),
@@ -37,7 +37,7 @@ describe("fromBookingGuestsResponse", () => {
3737

3838
it("returns an empty list when the booking has no guests at all", () => {
3939
expect(
40-
fromBookingGuestsResponse(response({ displayId: "B-1", id: "bkg_1" })),
40+
fromBookingGuestsResponse(response({ displayId: "B-1", id: "b_1" })),
4141
).toEqual([]);
4242
});
4343

0 commit comments

Comments
 (0)