Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
Add real Shopify Billing API integration: Free/Starter/Growth/Pro plans (app/lib/billing-plans.ts, priced per PRODUCT_STRATEGY.md §6) wired into shopify.server.ts's billing config, a merchant-facing plan page (app/routes/app.billing.tsx) using billing.request/billing.cancel, and webhooks.app_subscriptions.update.tsx as the durable sync path for Shop.tier (fires even when a merchant cancels from Shopify's own billing page, not just from this app). Gate the features actually built so far in both loader and action (never just hidden in the UI, so a direct POST can't bypass a tier limit): delivery zones/rates require Growth+, the dispatch dashboard requires Starter+, and location count is capped per tier (Free=1, Starter=3, Growth/Pro=unlimited). Split pure tier logic (app/lib/billing-plans.ts) from DB-backed reads/writes (app/services/billing.server.ts) so the client-rendered UpsellState component can import the Tier type without pulling server code into the client bundle — same split as currency.ts. Covered by tests/unit/billing-plans.test.ts (pure tier ranking/mapping) and tests/integration/billing.test.ts (tier persistence and location-limit enforcement against live Postgres). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
67 lines
2.5 KiB
TypeScript
67 lines
2.5 KiB
TypeScript
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";
|
|
import { getShopTier } from "../services/billing.server";
|
|
import { tierAtLeast } from "../lib/billing-plans";
|
|
|
|
// 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 tier = await getShopTier(session.shop);
|
|
if (!tierAtLeast(tier, "starter")) {
|
|
return new Response("The dispatch dashboard needs the Starter plan or higher.", { status: 403 });
|
|
}
|
|
|
|
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"`,
|
|
},
|
|
});
|
|
};
|