diff --git a/README.md b/README.md index 0dc7403..c47a77c 100644 --- a/README.md +++ b/README.md @@ -53,9 +53,9 @@ public launch or Built-for-Shopify submission** — don't ship without it. ## Status -Phase 0 (scaffold & CI) through Phase 5 (zones, rates, auto-assignment) are +Phase 0 (scaffold & CI) through Phase 6 (ops/dispatch dashboard) are complete. See §6 of `IMPLEMENTATION_PLAN.md` for the phased build order and -acceptance criteria — next up is Phase 6 (ops/dispatch dashboard). +acceptance criteria — next up is Phase 7 (POS + Checkout UI extensions). Phase 5's Google Maps / geocoding features (pickup-location map in the widget, radius-zone eligibility, address auto-geocoding on Save Location) diff --git a/app/routes/app.dashboard.export.tsx b/app/routes/app.dashboard.export.tsx new file mode 100644 index 0000000..554c49d --- /dev/null +++ b/app/routes/app.dashboard.export.tsx @@ -0,0 +1,58 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { DateTime } from "luxon"; +import type { Method } from "@prisma/client"; +import { authenticate } from "../shopify.server"; +import db from "../db.server"; +import { bookingsToCsv, type BookingSummary } from "../services/dashboard.server"; + +// Resource route (no default export/component) — safe to hit directly for +// a file download, and safe to import dashboard.server.ts freely since +// there's no client component here for Remix to worry about bundling it into. +export const loader = async ({ request }: LoaderFunctionArgs) => { + const { session } = await authenticate.admin(request); + const url = new URL(request.url); + + const locationIdFilter = url.searchParams.get("locationId") || undefined; + const methodFilter = (url.searchParams.get("method") as Method | null) || undefined; + const statusFilter = url.searchParams.get("status") || undefined; + const startDate = url.searchParams.get("startDate") || DateTime.now().toISODate()!; + const endDate = url.searchParams.get("endDate") || DateTime.now().plus({ days: 7 }).toISODate()!; + + const rangeStart = DateTime.fromISO(startDate, { zone: "utc" }).toJSDate(); + const rangeEnd = DateTime.fromISO(endDate, { zone: "utc" }).plus({ days: 1 }).toJSDate(); + + const bookings = await db.booking.findMany({ + where: { + shopDomain: session.shop, + locationId: locationIdFilter, + method: methodFilter, + status: statusFilter, + slotStart: { gte: rangeStart, lt: rangeEnd }, + }, + include: { location: true }, + orderBy: { slotStart: "asc" }, + }); + + const summaries: BookingSummary[] = bookings.map((b) => ({ + id: b.id, + orderName: b.orderName, + locationId: b.locationId, + locationName: b.location.name, + method: b.method, + slotStart: b.slotStart, + slotEnd: b.slotEnd, + timezone: b.location.timezone, + status: b.status, + totalPriceCents: b.totalPriceCents, + customerEmail: b.customerEmail, + })); + + const csv = bookingsToCsv(summaries); + + return new Response(csv, { + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="dispatch-${startDate}-to-${endDate}.csv"`, + }, + }); +}; diff --git a/app/routes/app.dashboard.tsx b/app/routes/app.dashboard.tsx new file mode 100644 index 0000000..ab584ae --- /dev/null +++ b/app/routes/app.dashboard.tsx @@ -0,0 +1,341 @@ +import { data, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node"; +import { Form, useLoaderData, useNavigation, useSearchParams } from "@remix-run/react"; +import { + Page, + Card, + BlockStack, + InlineStack, + Text, + Button, + Select, + TextField, + IndexTable, + Badge, + Box, +} from "@shopify/polaris"; +import { TitleBar } from "@shopify/app-bridge-react"; +import { DateTime } from "luxon"; +import type { Method } from "@prisma/client"; +import { authenticate } from "../shopify.server"; +import db from "../db.server"; +import { + bucketByLocalDate, + revenueByMethod, + upcomingFulfillments, + utilizationByDateLocation, + type BookingSummary, +} from "../services/dashboard.server"; +import { formatPriceLabel } from "../lib/currency"; + +const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]; +const STATUSES = ["confirmed", "fulfilled", "no_show", "cancelled"]; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const { session } = await authenticate.admin(request); + const url = new URL(request.url); + + const locationIdFilter = url.searchParams.get("locationId") || undefined; + const methodFilter = (url.searchParams.get("method") as Method | null) || undefined; + const statusFilter = url.searchParams.get("status") || undefined; + const startDate = url.searchParams.get("startDate") || DateTime.now().toISODate()!; + const endDate = url.searchParams.get("endDate") || DateTime.now().plus({ days: 7 }).toISODate()!; + + const rangeStart = DateTime.fromISO(startDate, { zone: "utc" }).toJSDate(); + const rangeEnd = DateTime.fromISO(endDate, { zone: "utc" }).plus({ days: 1 }).toJSDate(); + + const [locations, bookings, slotTemplates] = await Promise.all([ + db.location.findMany({ where: { shopDomain: session.shop }, orderBy: { createdAt: "asc" } }), + db.booking.findMany({ + where: { + shopDomain: session.shop, + locationId: locationIdFilter, + method: methodFilter, + status: statusFilter, + slotStart: { gte: rangeStart, lt: rangeEnd }, + }, + include: { location: true }, + orderBy: { slotStart: "asc" }, + }), + db.slotTemplate.findMany({ + where: { shopDomain: session.shop, locationId: locationIdFilter, method: methodFilter }, + }), + ]); + + const summaries: BookingSummary[] = bookings.map((b) => ({ + id: b.id, + orderName: b.orderName, + locationId: b.locationId, + locationName: b.location.name, + method: b.method, + slotStart: b.slotStart, + slotEnd: b.slotEnd, + timezone: b.location.timezone, + status: b.status, + totalPriceCents: b.totalPriceCents, + customerEmail: b.customerEmail, + })); + + const byDate = bucketByLocalDate(summaries); + const revenue = revenueByMethod(summaries); + const utilization = utilizationByDateLocation( + summaries, + slotTemplates.map((t) => ({ locationId: t.locationId, method: t.method, weekday: t.weekday, capacity: t.capacity })), + ); + const upcoming = upcomingFulfillments(summaries); + + return { + locations, + filters: { locationId: locationIdFilter ?? "", method: methodFilter ?? "", status: statusFilter ?? "", startDate, endDate }, + byDate, + revenue, + utilization, + upcoming, + totalBookings: summaries.length, + }; +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + const { session } = await authenticate.admin(request); + const formData = await request.formData(); + const bookingId = String(formData.get("bookingId") || ""); + const status = String(formData.get("status") || ""); + + if (!bookingId || !STATUSES.includes(status)) { + return data({ error: "Invalid request" }, { status: 400 }); + } + + await db.booking.updateMany({ where: { id: bookingId, shopDomain: session.shop }, data: { status } }); + return data({ ok: true }); +}; + +export default function Dashboard() { + const { locations, filters, byDate, revenue, utilization, upcoming, totalBookings } = useLoaderData(); + const [searchParams, setSearchParams] = useSearchParams(); + const navigation = useNavigation(); + const isSubmitting = navigation.state === "submitting"; + + const setFilter = (key: string, value: string) => { + const next = new URLSearchParams(searchParams); + if (value) next.set(key, value); + else next.delete(key); + setSearchParams(next); + }; + + const dates = Object.keys(byDate).sort(); + const exportQuery = new URLSearchParams(searchParams).toString(); + + return ( + + + + + + + Filters + + + setFilter("startDate", v)} + /> + setFilter("endDate", v)} + /> + ({ label: m.replace("_", " "), value: m }))]} + value={filters.method} + onChange={(v) => setFilter("method", v)} + /> + + + + +
+ + + +
+
+ + + ); + })} + + )} +
+ + + + + All bookings by day + + + {dates.length === 0 ? ( + + + No bookings in this range. + + + ) : ( + dates.map((date) => ( + + + + {date} + + {byDate[date].map((b) => { + const local = DateTime.fromISO(b.slotStart, { zone: "utc" }).setZone(b.timezone); + return ( + + + {local.toFormat("h:mm a")} · {b.orderName ?? "—"} · {b.method.replace("_", " ")} · {b.locationName} + + + {b.status} + + + ); + })} + + + )) + )} + +
+
+ ); +} diff --git a/app/routes/app.tsx b/app/routes/app.tsx index 67caf6d..db40982 100644 --- a/app/routes/app.tsx +++ b/app/routes/app.tsx @@ -29,6 +29,7 @@ export default function App() { Blackout dates Delivery zones Delivery rates + Dispatch dashboard diff --git a/app/services/booking.server.ts b/app/services/booking.server.ts index 43fa164..9af8eaa 100644 --- a/app/services/booking.server.ts +++ b/app/services/booking.server.ts @@ -16,9 +16,16 @@ export interface OrderWebhookPayload { cart_token?: string | null; email?: string | null; phone?: string | null; + total_price?: string | null; // e.g. "49.99" — Shopify sends this as a decimal string, not cents note_attributes?: OrderNoteAttribute[]; } +function parsePriceCents(totalPrice: string | null | undefined): number | undefined { + if (!totalPrice) return undefined; + const value = Number(totalPrice); + return Number.isFinite(value) ? Math.round(value * 100) : undefined; +} + function readAttr(attrs: OrderNoteAttribute[], key: string): string | undefined { return attrs.find((a) => a.name === key)?.value; } @@ -71,6 +78,7 @@ export async function createBookingFromOrder(shopDomain: string, order: OrderWeb slotEnd: slotEnd.toJSDate(), customerEmail: order.email ?? undefined, customerPhone: order.phone ?? undefined, + totalPriceCents: parsePriceCents(order.total_price), }, update: {}, // redelivered webhook — the booking already exists, nothing to change }); diff --git a/app/services/dashboard.server.ts b/app/services/dashboard.server.ts new file mode 100644 index 0000000..6a6ae55 --- /dev/null +++ b/app/services/dashboard.server.ts @@ -0,0 +1,171 @@ +import { DateTime } from "luxon"; +import type { Method } from "@prisma/client"; + +// Pure aggregation for the ops/dispatch dashboard (IMPLEMENTATION_PLAN.md +// Phase 6). No DB calls — the route loader fetches Bookings/SlotTemplates +// and passes plain data in here, per CLAUDE.md's "inject data" rule. + +export interface BookingSummary { + id: string; + orderName: string | null; + locationId: string; + locationName: string; + method: Method; + slotStart: Date; // UTC instant + slotEnd: Date; + timezone: string; // the booking's location's timezone, for local-date bucketing + status: string; + totalPriceCents: number | null; + customerEmail: string | null; +} + +export interface CapacitySummary { + locationId: string; + method: Method; + weekday: number; + capacity: number; +} + +export interface DailyLocationUtilization { + date: string; // location-local ISO date + locationId: string; + locationName: string; + method: Method; + booked: number; + capacity: number; + utilizationPct: number; // 0-100, capped even if overbooked past capacity +} + +function localDate(booking: BookingSummary): string { + return DateTime.fromJSDate(booking.slotStart, { zone: "utc" }).setZone(booking.timezone).toISODate()!; +} + +function localWeekday(booking: BookingSummary): number { + return DateTime.fromJSDate(booking.slotStart, { zone: "utc" }).setZone(booking.timezone).weekday % 7; +} + +/** Groups bookings by their own location's local calendar date — the basis for "bookings by day/slot/location". */ +export function bucketByLocalDate(bookings: BookingSummary[]): Record { + const buckets: Record = {}; + for (const booking of bookings) { + const key = localDate(booking); + (buckets[key] ??= []).push(booking); + } + for (const list of Object.values(buckets)) { + list.sort((a, b) => a.slotStart.getTime() - b.slotStart.getTime()); + } + return buckets; +} + +/** Confirmed/fulfilled revenue, summed per method — cancelled/no_show bookings never counted. */ +export function revenueByMethod(bookings: BookingSummary[]): Record { + const totals: Record = { SHIPPING: 0, LOCAL_DELIVERY: 0, PICKUP: 0 }; + for (const booking of bookings) { + if (booking.status !== "confirmed" && booking.status !== "fulfilled") continue; + totals[booking.method] += booking.totalPriceCents ?? 0; + } + return totals; +} + +/** + * Capacity-utilization view: for each date/location/method that has at + * least one booking, what fraction of that weekday's total slot capacity + * (summed across every matching SlotTemplate) is booked. Dates/locations + * with zero bookings are omitted — there's nothing to show a merchant for + * an empty day. + */ +export function utilizationByDateLocation( + bookings: BookingSummary[], + capacities: CapacitySummary[], +): DailyLocationUtilization[] { + const capacityByKey = new Map(); + for (const c of capacities) { + const key = `${c.locationId}|${c.method}|${c.weekday}`; + capacityByKey.set(key, (capacityByKey.get(key) ?? 0) + c.capacity); + } + + const bookedByKey = new Map< + string, + { date: string; locationId: string; locationName: string; method: Method; weekday: number; count: number } + >(); + + for (const booking of bookings) { + if (booking.status === "cancelled") continue; + const date = localDate(booking); + const weekday = localWeekday(booking); + const key = `${date}|${booking.locationId}|${booking.method}`; + const existing = bookedByKey.get(key); + if (existing) { + existing.count += 1; + } else { + bookedByKey.set(key, { + date, + locationId: booking.locationId, + locationName: booking.locationName, + method: booking.method, + weekday, + count: 1, + }); + } + } + + return [...bookedByKey.values()] + .map((entry) => { + const capacity = capacityByKey.get(`${entry.locationId}|${entry.method}|${entry.weekday}`) ?? 0; + const utilizationPct = capacity > 0 ? Math.min(100, Math.round((entry.count / capacity) * 100)) : 0; + return { + date: entry.date, + locationId: entry.locationId, + locationName: entry.locationName, + method: entry.method, + booked: entry.count, + capacity, + utilizationPct, + }; + }) + .sort((a, b) => a.date.localeCompare(b.date) || a.locationName.localeCompare(b.locationName)); +} + +/** Nearest-first list of not-yet-fulfilled bookings, for the "upcoming fulfillment" panel. */ +export function upcomingFulfillments(bookings: BookingSummary[], limit = 25): BookingSummary[] { + return bookings + .filter((b) => b.status === "confirmed") + .sort((a, b) => a.slotStart.getTime() - b.slotStart.getTime()) + .slice(0, limit); +} + +export function toCsvRow(values: Array): string { + return values + .map((v) => { + const s = String(v); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }) + .join(","); +} + +export function bookingsToCsv(bookings: BookingSummary[]): string { + const header = toCsvRow([ + "Order", + "Date", + "Time", + "Method", + "Location", + "Status", + "Customer email", + "Total", + ]); + const rows = bookings.map((b) => { + const local = DateTime.fromJSDate(b.slotStart, { zone: "utc" }).setZone(b.timezone); + return toCsvRow([ + b.orderName ?? "", + local.toISODate() ?? "", + local.toFormat("h:mm a"), + b.method, + b.locationName, + b.status, + b.customerEmail ?? "", + b.totalPriceCents != null ? (b.totalPriceCents / 100).toFixed(2) : "", + ]); + }); + return [header, ...rows].join("\n"); +} diff --git a/prisma/migrations/20260824072805_add_booking_total_price/migration.sql b/prisma/migrations/20260824072805_add_booking_total_price/migration.sql new file mode 100644 index 0000000..5ff9931 --- /dev/null +++ b/prisma/migrations/20260824072805_add_booking_total_price/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Booking" ADD COLUMN "totalPriceCents" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0b23f3a..b1d8247 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -144,6 +144,7 @@ model Booking { status String @default("confirmed") // confirmed|cancelled|fulfilled|no_show customerEmail String? customerPhone String? + totalPriceCents Int? // order's total_price at booking time, for the dispatch dashboard's revenue-by-method view createdAt DateTime @default(now()) @@index([shopDomain, locationId, method, slotStart]) diff --git a/tests/integration/booking.test.ts b/tests/integration/booking.test.ts index aeef995..3c68b79 100644 --- a/tests/integration/booking.test.ts +++ b/tests/integration/booking.test.ts @@ -87,4 +87,45 @@ describe("createBookingFromOrder", () => { const count = await db.booking.count({ where: { shopDomain, orderId: "gid://shopify/Order/3" } }); expect(count).toBe(0); }); + + it("converts total_price to integer cents for the dashboard's revenue view", async () => { + const location = await db.location.create({ + data: { shopDomain, name: "Test Location", address: "", timezone: "America/Toronto" }, + }); + + await createBookingFromOrder(shopDomain, { + admin_graphql_api_id: "gid://shopify/Order/4", + total_price: "49.99", + note_attributes: [ + { name: "dd_method", value: "PICKUP" }, + { name: "dd_date", value: "2026-08-25" }, + { name: "dd_start_min", value: "540" }, + { name: "dd_end_min", value: "600" }, + { name: "dd_location_id", value: location.id }, + ], + }); + + const booking = await db.booking.findUnique({ where: { orderId: "gid://shopify/Order/4" } }); + expect(booking?.totalPriceCents).toBe(4999); + }); + + it("leaves totalPriceCents unset when total_price is missing", async () => { + const location = await db.location.create({ + data: { shopDomain, name: "Test Location", address: "", timezone: "America/Toronto" }, + }); + + await createBookingFromOrder(shopDomain, { + admin_graphql_api_id: "gid://shopify/Order/5", + note_attributes: [ + { name: "dd_method", value: "PICKUP" }, + { name: "dd_date", value: "2026-08-25" }, + { name: "dd_start_min", value: "540" }, + { name: "dd_end_min", value: "600" }, + { name: "dd_location_id", value: location.id }, + ], + }); + + const booking = await db.booking.findUnique({ where: { orderId: "gid://shopify/Order/5" } }); + expect(booking?.totalPriceCents).toBeNull(); + }); }); diff --git a/tests/unit/dashboard.test.ts b/tests/unit/dashboard.test.ts new file mode 100644 index 0000000..26b1e4d --- /dev/null +++ b/tests/unit/dashboard.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import { + bookingsToCsv, + bucketByLocalDate, + revenueByMethod, + toCsvRow, + upcomingFulfillments, + utilizationByDateLocation, + type BookingSummary, + type CapacitySummary, +} from "../../app/services/dashboard.server"; + +const ZONE = "America/Toronto"; + +function booking(overrides: Partial = {}): BookingSummary { + return { + id: "b1", + orderName: "#1001", + locationId: "loc_1", + locationName: "Main Bakery", + method: "PICKUP", + slotStart: new Date("2026-08-25T13:00:00.000Z"), // 9 AM EDT, a Tuesday + slotEnd: new Date("2026-08-25T14:00:00.000Z"), + timezone: ZONE, + status: "confirmed", + totalPriceCents: 2500, + customerEmail: "shopper@example.com", + ...overrides, + }; +} + +describe("bucketByLocalDate", () => { + it("groups bookings under their location-local calendar date", () => { + const buckets = bucketByLocalDate([booking()]); + expect(Object.keys(buckets)).toEqual(["2026-08-25"]); + }); + + it("sorts bookings within a day by slot start time", () => { + const late = booking({ id: "late", slotStart: new Date("2026-08-25T20:00:00.000Z") }); + const early = booking({ id: "early", slotStart: new Date("2026-08-25T13:00:00.000Z") }); + const buckets = bucketByLocalDate([late, early]); + expect(buckets["2026-08-25"].map((b) => b.id)).toEqual(["early", "late"]); + }); + + it("puts a booking near midnight into the correct location-local day, not the UTC day", () => { + // 11 PM EDT on Aug 25 is already Aug 26 in UTC. + const b = booking({ slotStart: new Date("2026-08-26T03:00:00.000Z") }); + const buckets = bucketByLocalDate([b]); + expect(Object.keys(buckets)).toEqual(["2026-08-25"]); + }); +}); + +describe("revenueByMethod", () => { + it("sums confirmed and fulfilled bookings per method", () => { + const bookings = [ + booking({ method: "PICKUP", totalPriceCents: 1000, status: "confirmed" }), + booking({ method: "PICKUP", totalPriceCents: 500, status: "fulfilled" }), + booking({ method: "LOCAL_DELIVERY", totalPriceCents: 2000, status: "confirmed" }), + ]; + expect(revenueByMethod(bookings)).toEqual({ SHIPPING: 0, LOCAL_DELIVERY: 2000, PICKUP: 1500 }); + }); + + it("excludes cancelled and no_show bookings", () => { + const bookings = [ + booking({ totalPriceCents: 1000, status: "cancelled" }), + booking({ totalPriceCents: 1000, status: "no_show" }), + ]; + expect(revenueByMethod(bookings).PICKUP).toBe(0); + }); + + it("treats a missing totalPriceCents as 0, not an error", () => { + expect(revenueByMethod([booking({ totalPriceCents: null })]).PICKUP).toBe(0); + }); +}); + +describe("utilizationByDateLocation", () => { + it("computes booked/capacity percentage for a date+location+method with bookings", () => { + // 2026-08-25 is a Tuesday -> weekday 2. + const capacities: CapacitySummary[] = [{ locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 4 }]; + const bookings = [booking(), booking({ id: "b2" })]; + const result = utilizationByDateLocation(bookings, capacities); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ date: "2026-08-25", booked: 2, capacity: 4, utilizationPct: 50 }); + }); + + it("sums capacity across multiple slot templates for the same weekday", () => { + const capacities: CapacitySummary[] = [ + { locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 4 }, + { locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 6 }, + ]; + const result = utilizationByDateLocation([booking()], capacities); + expect(result[0].capacity).toBe(10); + }); + + it("caps utilization at 100% even if overbooked", () => { + const capacities: CapacitySummary[] = [{ locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 1 }]; + const bookings = [booking(), booking({ id: "b2" }), booking({ id: "b3" })]; + expect(utilizationByDateLocation(bookings, capacities)[0].utilizationPct).toBe(100); + }); + + it("excludes cancelled bookings from the booked count", () => { + const capacities: CapacitySummary[] = [{ locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 4 }]; + const result = utilizationByDateLocation([booking({ status: "cancelled" })], capacities); + expect(result).toHaveLength(0); + }); + + it("omits a date/location/method with no bookings at all", () => { + expect(utilizationByDateLocation([], [{ locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 4 }])).toEqual([]); + }); +}); + +describe("upcomingFulfillments", () => { + it("returns only confirmed bookings, nearest-first", () => { + const bookings = [ + booking({ id: "later", slotStart: new Date("2026-08-26T13:00:00.000Z") }), + booking({ id: "sooner", slotStart: new Date("2026-08-25T13:00:00.000Z") }), + booking({ id: "fulfilled-already", status: "fulfilled" }), + ]; + const result = upcomingFulfillments(bookings); + expect(result.map((b) => b.id)).toEqual(["sooner", "later"]); + }); + + it("respects the limit", () => { + const bookings = Array.from({ length: 5 }, (_, i) => booking({ id: `b${i}` })); + expect(upcomingFulfillments(bookings, 2)).toHaveLength(2); + }); +}); + +describe("toCsvRow / bookingsToCsv", () => { + it("quotes a field containing a comma", () => { + expect(toCsvRow(["a,b", "c"])).toBe('"a,b",c'); + }); + + it("escapes an embedded quote by doubling it", () => { + expect(toCsvRow(['say "hi"'])).toBe('"say ""hi"""'); + }); + + it("produces a header row plus one row per booking", () => { + const csv = bookingsToCsv([booking()]); + const lines = csv.split("\n"); + expect(lines).toHaveLength(2); + expect(lines[0]).toContain("Order"); + expect(lines[1]).toContain("#1001"); + expect(lines[1]).toContain("25.00"); + }); +});