Skip to content
19 changes: 19 additions & 0 deletions src/lib/expand.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
import type { StrimulatorDB } from "../db";

/**
* Parse expand params from URL search params.
* Handles both `expand[]=field` (curl/raw) and `expand[0]=field` (Stripe SDK) formats.
*/
export function parseExpandParams(url: URL): string[] {
// Try expand[] format first (curl / raw requests)
const pushFormat = url.searchParams.getAll("expand[]");
if (pushFormat.length > 0) return pushFormat;

// Try indexed format: expand[0], expand[1], ... (Stripe SDK)
const indexed: string[] = [];
for (let i = 0; ; i++) {
const val = url.searchParams.get(`expand[${i}]`);
if (val === null) break;
indexed.push(val);
}
return indexed;
}

// Resolver: given an ID and DB, return the expanded object
type Resolver = (id: string, db: StrimulatorDB) => any;

Expand Down
20 changes: 20 additions & 0 deletions src/lib/pagination.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
import { gt, eq, and, or } from "drizzle-orm";
import type { SQLiteColumn } from "drizzle-orm/sqlite-core";

export interface ListResponse<T> {
object: "list";
data: T[];
has_more: boolean;
url: string;
}

/**
* Build a composite cursor condition for keyset pagination.
* Handles same-second tiebreaking by using (created, id) instead of just created.
* Returns: (created > cursor.created) OR (created = cursor.created AND id > cursor.id)
*/
export function cursorCondition(
createdCol: SQLiteColumn,
idCol: SQLiteColumn,
cursorCreated: number,
cursorId: string,
) {
return or(
gt(createdCol, cursorCreated),
and(eq(createdCol, cursorCreated), gt(idCol, cursorId)),
)!;
}

export function buildListResponse<T>(items: T[], url: string, hasMore: boolean): ListResponse<T> {
return { object: "list", data: items, has_more: hasMore, url };
}
Expand Down
4 changes: 2 additions & 2 deletions src/routes/charges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { CustomerService } from "../services/customers";
import { PaymentIntentService } from "../services/payment-intents";
import { PaymentMethodService } from "../services/payment-methods";
import { parseListParams } from "../lib/pagination";
import { applyExpand, type ExpandConfig } from "../lib/expand";
import { applyExpand, parseExpandParams, type ExpandConfig } from "../lib/expand";
import { StripeError } from "../errors";

const chargeExpandConfig: ExpandConfig = {
Expand Down Expand Up @@ -43,7 +43,7 @@ export function chargeRoutes(db: StrimulatorDB) {
// GET /v1/charges/:id — retrieve
.get("/:id", async ({ params: { id }, request }) => {
const url = new URL(request.url);
const expand = url.searchParams.getAll("expand[]");
const expand = parseExpandParams(url);
let result: any = service.retrieve(id);
if (expand.length) {
result = await applyExpand(result, expand, chargeExpandConfig, db);
Expand Down
4 changes: 2 additions & 2 deletions src/routes/invoices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { PaymentIntentService } from "../services/payment-intents";
import { EventService } from "../services/events";
import { parseStripeBody } from "../middleware/form-parser";
import { parseListParams } from "../lib/pagination";
import { applyExpand, type ExpandConfig } from "../lib/expand";
import { applyExpand, parseExpandParams, type ExpandConfig } from "../lib/expand";
import { StripeError } from "../errors";

const invoiceExpandConfig: ExpandConfig = {
Expand Down Expand Up @@ -75,7 +75,7 @@ export function invoiceRoutes(db: StrimulatorDB, eventService?: EventService) {
// GET /v1/invoices/:id — retrieve
.get("/:id", async ({ params: { id }, request }) => {
const url = new URL(request.url);
const expand = url.searchParams.getAll("expand[]");
const expand = parseExpandParams(url);
let result: any = service.retrieve(id);
if (expand.length) {
result = await applyExpand(result, expand, invoiceExpandConfig, db);
Expand Down
4 changes: 2 additions & 2 deletions src/routes/payment-intents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { CustomerService } from "../services/customers";
import { EventService } from "../services/events";
import { parseStripeBody } from "../middleware/form-parser";
import { parseListParams } from "../lib/pagination";
import { applyExpand, type ExpandConfig } from "../lib/expand";
import { applyExpand, parseExpandParams, type ExpandConfig } from "../lib/expand";
import { StripeError } from "../errors";

const paymentIntentExpandConfig: ExpandConfig = {
Expand Down Expand Up @@ -72,7 +72,7 @@ export function paymentIntentRoutes(db: StrimulatorDB, eventService?: EventServi
// GET /v1/payment_intents/:id — retrieve
.get("/:id", async ({ params: { id }, request }) => {
const url = new URL(request.url);
const expand = url.searchParams.getAll("expand[]");
const expand = parseExpandParams(url);
let result: any = service.retrieve(id);
if (expand.length) {
result = await applyExpand(result, expand, paymentIntentExpandConfig, db);
Expand Down
3 changes: 3 additions & 0 deletions src/routes/refunds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export function refundRoutes(db: StrimulatorDB, eventService?: EventService) {
.post("/", async ({ request }) => {
const rawBody = await request.text();
const params = parseStripeBody(rawBody);
if (typeof params.amount === "string") {
params.amount = parseInt(params.amount, 10);
}
const refund = service.create(params);
eventService?.emit("refund.created", refund as unknown as Record<string, unknown>);
return refund;
Expand Down
4 changes: 2 additions & 2 deletions src/routes/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { PaymentIntentService } from "../services/payment-intents";
import { EventService } from "../services/events";
import { parseStripeBody } from "../middleware/form-parser";
import { parseListParams } from "../lib/pagination";
import { applyExpand, type ExpandConfig } from "../lib/expand";
import { applyExpand, parseExpandParams, type ExpandConfig } from "../lib/expand";
import { StripeError } from "../errors";

const subscriptionExpandConfig: ExpandConfig = {
Expand Down Expand Up @@ -107,7 +107,7 @@ export function subscriptionRoutes(db: StrimulatorDB, eventService?: EventServic
// GET /v1/subscriptions/:id — retrieve
.get("/:id", async ({ params: { id }, request }) => {
const url = new URL(request.url);
const expand = url.searchParams.getAll("expand[]");
const expand = parseExpandParams(url);
let result: any = service.retrieve(id);
if (expand.length) {
result = await applyExpand(result, expand, subscriptionExpandConfig, db);
Expand Down
16 changes: 8 additions & 8 deletions src/services/charges.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type Stripe from "stripe";
import { eq, gt, and } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import type { StrimulatorDB } from "../db";
import { charges } from "../db/schema/charges";
import { generateId } from "../lib/id-generator";
import { now } from "../lib/timestamps";
import { buildListResponse, type ListParams, type ListResponse } from "../lib/pagination";
import { buildListResponse, cursorCondition, type ListParams, type ListResponse } from "../lib/pagination";
import { resourceNotFoundError } from "../errors";

export interface CreateChargeParams {
Expand Down Expand Up @@ -115,7 +115,7 @@ export class ChargeService {
const { limit, startingAfter, paymentIntentId, customerId } = params;
const fetchLimit = limit + 1;

const buildConditions = (extraCondition?: ReturnType<typeof gt>) => {
const buildConditions = (extraCondition?: ReturnType<typeof eq>) => {
const conditions = [];
if (paymentIntentId) conditions.push(eq(charges.payment_intent_id, paymentIntentId));
if (customerId) conditions.push(eq(charges.customer_id, customerId));
Expand All @@ -131,15 +131,15 @@ export class ChargeService {
throw resourceNotFoundError("charge", startingAfter);
}

const condition = buildConditions(gt(charges.created, cursor.created));
const condition = buildConditions(cursorCondition(charges.created, charges.id, cursor.created, cursor.id));
rows = condition
? this.db.select().from(charges).where(condition).limit(fetchLimit).all()
: this.db.select().from(charges).limit(fetchLimit).all();
? this.db.select().from(charges).where(condition).orderBy(charges.created, charges.id).limit(fetchLimit).all()
: this.db.select().from(charges).orderBy(charges.created, charges.id).limit(fetchLimit).all();
} else {
const condition = buildConditions();
rows = condition
? this.db.select().from(charges).where(condition).limit(fetchLimit).all()
: this.db.select().from(charges).limit(fetchLimit).all();
? this.db.select().from(charges).where(condition).orderBy(charges.created, charges.id).limit(fetchLimit).all()
: this.db.select().from(charges).orderBy(charges.created, charges.id).limit(fetchLimit).all();
}

const hasMore = rows.length > limit;
Expand Down
8 changes: 5 additions & 3 deletions src/services/customers.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type Stripe from "stripe";
import { eq, gt, desc, and } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import type { StrimulatorDB } from "../db";
import { customers } from "../db/schema/customers";
import { generateId } from "../lib/id-generator";
import { now } from "../lib/timestamps";
import { buildListResponse, type ListParams, type ListResponse } from "../lib/pagination";
import { buildListResponse, cursorCondition, type ListParams, type ListResponse } from "../lib/pagination";
import { parseSearchQuery, matchesCondition, buildSearchResult, type SearchResult } from "../lib/search";
import { resourceNotFoundError } from "../errors";

Expand Down Expand Up @@ -181,13 +181,15 @@ export class CustomerService {

rows = this.db.select()
.from(customers)
.where(and(eq(customers.deleted, 0), gt(customers.created, cursor.created)))
.where(and(eq(customers.deleted, 0), cursorCondition(customers.created, customers.id, cursor.created, cursor.id)))
.orderBy(customers.created, customers.id)
.limit(fetchLimit)
.all();
} else {
rows = this.db.select()
.from(customers)
.where(eq(customers.deleted, 0))
.orderBy(customers.created, customers.id)
.limit(fetchLimit)
.all();
}
Expand Down
39 changes: 18 additions & 21 deletions src/services/events.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type Stripe from "stripe";
import { eq, desc, and } from "drizzle-orm";
import { eq, desc, and, lt, or } from "drizzle-orm";
import type { StrimulatorDB } from "../db";
import { events } from "../db/schema/events";
import { generateId } from "../lib/id-generator";
Expand Down Expand Up @@ -94,10 +94,10 @@ export class EventService {
const { limit, type, startingAfter } = params;
const fetchLimit = limit + 1;

const buildConditions = (cursorCondition?: ReturnType<typeof eq>) => {
const buildConditions = (extraCondition?: ReturnType<typeof eq>) => {
const conditions = [];
if (type) conditions.push(eq(events.type, type));
if (cursorCondition) conditions.push(cursorCondition);
if (extraCondition) conditions.push(extraCondition);
return conditions.length > 0 ? and(...conditions) : undefined;
};

Expand All @@ -109,35 +109,32 @@ export class EventService {
throw resourceNotFoundError("event", startingAfter);
}

// Use desc ordering on created, paginating "after" means created < cursor.created
const condition = buildConditions();
if (condition) {
rows = this.db.select()
.from(events)
.where(and(condition, eq(events.created, cursor.created)))
.orderBy(desc(events.created))
.limit(fetchLimit)
.all();
} else {
rows = this.db.select()
.from(events)
.orderBy(desc(events.created))
.limit(fetchLimit)
.all();
}
// Desc ordering: "after" means older items (created < cursor.created),
// with id tiebreaker for same-second items
const cc = or(
lt(events.created, cursor.created),
and(eq(events.created, cursor.created), lt(events.id, cursor.id)),
)!;
const condition = buildConditions(cc);
rows = this.db.select()
.from(events)
.where(condition)
.orderBy(desc(events.created), desc(events.id))
.limit(fetchLimit)
.all();
} else {
const condition = buildConditions();
if (condition) {
rows = this.db.select()
.from(events)
.where(condition)
.orderBy(desc(events.created))
.orderBy(desc(events.created), desc(events.id))
.limit(fetchLimit)
.all();
} else {
rows = this.db.select()
.from(events)
.orderBy(desc(events.created))
.orderBy(desc(events.created), desc(events.id))
.limit(fetchLimit)
.all();
}
Expand Down
16 changes: 8 additions & 8 deletions src/services/invoices.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type Stripe from "stripe";
import { eq, gt, and } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import type { StrimulatorDB } from "../db";
import { invoices } from "../db/schema/invoices";
import { generateId } from "../lib/id-generator";
import { now } from "../lib/timestamps";
import { buildListResponse, type ListParams, type ListResponse } from "../lib/pagination";
import { buildListResponse, cursorCondition, type ListParams, type ListResponse } from "../lib/pagination";
import { parseSearchQuery, matchesCondition, buildSearchResult, type SearchResult } from "../lib/search";
import { resourceNotFoundError, invalidRequestError, stateTransitionError } from "../errors";

Expand Down Expand Up @@ -300,7 +300,7 @@ export class InvoiceService {
const { limit, startingAfter, customerId, subscriptionId } = params;
const fetchLimit = limit + 1;

const buildConditions = (extraCondition?: ReturnType<typeof gt>) => {
const buildConditions = (extraCondition?: ReturnType<typeof eq>) => {
const conditions = [];
if (customerId) conditions.push(eq(invoices.customerId, customerId));
if (subscriptionId) conditions.push(eq(invoices.subscriptionId, subscriptionId));
Expand All @@ -316,15 +316,15 @@ export class InvoiceService {
throw resourceNotFoundError("invoice", startingAfter);
}

const condition = buildConditions(gt(invoices.created, cursor.created));
const condition = buildConditions(cursorCondition(invoices.created, invoices.id, cursor.created, cursor.id));
rows = condition
? this.db.select().from(invoices).where(condition).limit(fetchLimit).all()
: this.db.select().from(invoices).limit(fetchLimit).all();
? this.db.select().from(invoices).where(condition).orderBy(invoices.created, invoices.id).limit(fetchLimit).all()
: this.db.select().from(invoices).orderBy(invoices.created, invoices.id).limit(fetchLimit).all();
} else {
const condition = buildConditions();
rows = condition
? this.db.select().from(invoices).where(condition).limit(fetchLimit).all()
: this.db.select().from(invoices).limit(fetchLimit).all();
? this.db.select().from(invoices).where(condition).orderBy(invoices.created, invoices.id).limit(fetchLimit).all()
: this.db.select().from(invoices).orderBy(invoices.created, invoices.id).limit(fetchLimit).all();
}

const hasMore = rows.length > limit;
Expand Down
16 changes: 8 additions & 8 deletions src/services/payment-intents.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type Stripe from "stripe";
import { eq, gt, and } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import type { StrimulatorDB } from "../db";
import { paymentIntents } from "../db/schema/payment-intents";
import { generateId } from "../lib/id-generator";
import { now } from "../lib/timestamps";
import { buildListResponse, type ListParams, type ListResponse } from "../lib/pagination";
import { buildListResponse, cursorCondition, type ListParams, type ListResponse } from "../lib/pagination";
import { parseSearchQuery, matchesCondition, buildSearchResult, type SearchResult } from "../lib/search";
import { randomBytes } from "crypto";
import { resourceNotFoundError, invalidRequestError, stateTransitionError, cardError } from "../errors";
Expand Down Expand Up @@ -516,7 +516,7 @@ export class PaymentIntentService {
const { limit, startingAfter, customerId } = params;
const fetchLimit = limit + 1;

const buildConditions = (extraCondition?: ReturnType<typeof gt>) => {
const buildConditions = (extraCondition?: ReturnType<typeof eq>) => {
const conditions = [];
if (customerId) conditions.push(eq(paymentIntents.customer_id, customerId));
if (extraCondition) conditions.push(extraCondition);
Expand All @@ -531,15 +531,15 @@ export class PaymentIntentService {
throw resourceNotFoundError("payment_intent", startingAfter);
}

const condition = buildConditions(gt(paymentIntents.created, cursor.created));
const condition = buildConditions(cursorCondition(paymentIntents.created, paymentIntents.id, cursor.created, cursor.id));
rows = condition
? this.db.select().from(paymentIntents).where(condition).limit(fetchLimit).all()
: this.db.select().from(paymentIntents).limit(fetchLimit).all();
? this.db.select().from(paymentIntents).where(condition).orderBy(paymentIntents.created, paymentIntents.id).limit(fetchLimit).all()
: this.db.select().from(paymentIntents).orderBy(paymentIntents.created, paymentIntents.id).limit(fetchLimit).all();
} else {
const condition = buildConditions();
rows = condition
? this.db.select().from(paymentIntents).where(condition).limit(fetchLimit).all()
: this.db.select().from(paymentIntents).limit(fetchLimit).all();
? this.db.select().from(paymentIntents).where(condition).orderBy(paymentIntents.created, paymentIntents.id).limit(fetchLimit).all()
: this.db.select().from(paymentIntents).orderBy(paymentIntents.created, paymentIntents.id).limit(fetchLimit).all();
}

const hasMore = rows.length > limit;
Expand Down
Loading
Loading