Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 63 additions & 4 deletions apps/api/src/lib/customers.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,48 @@
import { eq } from "drizzle-orm";
import { schema, createDb } from "@owostack/db";
import { EntitlementCache } from "./cache";
import { resolveCustomerByIdentifier } from "./customer-resolution";
import {
resolveCustomerByEmail,
resolveCustomerByIdentifier,
} from "./customer-resolution";
import { autoAssignPlansToNewCustomer } from "./customer-auto-plans";

type DB = ReturnType<typeof createDb>;
type Customer = typeof schema.customers.$inferSelect;

function isUniqueConstraintError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const message = "message" in error ? String(error.message) : "";
return /unique constraint failed|constraint failed/i.test(message);
}

async function resolveExistingCustomerAfterCreateConflict(opts: {
db: DB;
organizationId: string;
customerId: string;
email: string;
cache?: EntitlementCache | null;
waitUntil?: (promise: Promise<unknown>) => void;
}): Promise<Customer | null> {
const byIdentifier = await resolveCustomerByIdentifier({
db: opts.db,
organizationId: opts.organizationId,
customerId: opts.customerId,
cache: opts.cache,
waitUntil: opts.waitUntil,
});
if (byIdentifier?.customer) return byIdentifier.customer;

const byEmail = await resolveCustomerByEmail({
db: opts.db,
organizationId: opts.organizationId,
email: opts.email,
cache: opts.cache,
waitUntil: opts.waitUntil,
});

return byEmail?.customer ?? null;
}

export interface CustomerData {
email: string;
Expand Down Expand Up @@ -133,10 +171,31 @@ export async function resolveOrCreateCustomer(
updatedAt: now,
};

await db.insert(schema.customers).values(newCustomer);
customer = newCustomer as unknown as typeof schema.customers.$inferSelect;
let createdCustomer = false;
try {
await db.insert(schema.customers).values(newCustomer);
customer = newCustomer as unknown as typeof schema.customers.$inferSelect;
createdCustomer = true;
} catch (error) {
if (!isUniqueConstraintError(error)) {
throw error;
}

customer = await resolveExistingCustomerAfterCreateConflict({
db,
organizationId,
customerId,
email,
cache,
waitUntil: opts.waitUntil,
});

if (!customer) {
throw error;
}
}

if (opts.autoApplyPlansOnCreate) {
if (createdCustomer && opts.autoApplyPlansOnCreate) {
await autoAssignPlansToNewCustomer({
db,
organizationId,
Expand Down
74 changes: 74 additions & 0 deletions apps/api/test/runtime/customers-auto-create.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { and, eq } from "drizzle-orm";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { schema } from "@owostack/db";
import { resolveOrCreateCustomer } from "../../src/lib/customers";
import { createRuntimeBusinessDb } from "./helpers/business-db";
import { insertOrganization } from "./helpers/workflow-runtime";

describe("resolveOrCreateCustomer runtime integration", () => {
let businessDb: ReturnType<typeof createRuntimeBusinessDb>;

beforeEach(async () => {
businessDb = createRuntimeBusinessDb();
await insertOrganization(businessDb.d1, { id: "org_customer_create" });
});

afterEach(() => {
businessDb.close();
});

it("coalesces concurrent auto-create requests for the same organization-scoped aliases", async () => {
const created = await Promise.all(
Array.from({ length: 12 }, () =>
resolveOrCreateCustomer({
db: businessDb.db,
organizationId: "org_customer_create",
customerId: "external_user_123",
customerData: {
email: "USER@example.com",
name: "Runtime User",
},
}),
),
);

const customerIds = new Set(created.map((customer) => customer?.id));
expect(customerIds.size).toBe(1);

const rows = await businessDb.db.query.customers.findMany({
where: and(
eq(schema.customers.organizationId, "org_customer_create"),
eq(schema.customers.email, "user@example.com"),
),
});

expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
organizationId: "org_customer_create",
externalId: "external_user_123",
email: "user@example.com",
name: "Runtime User",
});
});

it("keeps the same email reusable across different organizations", async () => {
await insertOrganization(businessDb.d1, { id: "org_customer_create_2" });

const first = await resolveOrCreateCustomer({
db: businessDb.db,
organizationId: "org_customer_create",
customerId: "shared_user",
customerData: { email: "shared@example.com" },
});
const second = await resolveOrCreateCustomer({
db: businessDb.db,
organizationId: "org_customer_create_2",
customerId: "shared_user",
customerData: { email: "shared@example.com" },
});

expect(first?.id).toBeTruthy();
expect(second?.id).toBeTruthy();
expect(first?.id).not.toBe(second?.id);
});
});
4 changes: 4 additions & 0 deletions apps/api/test/runtime/helpers/sqlite-d1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ const MIGRATION_FILES = [
"../../../../../packages/db/migrations/0010_single_default_payment_method.sql",
import.meta.url,
),
new URL(
"../../../../../packages/db/migrations/0011_customer_alias_uniqueness.sql",
import.meta.url,
),
];

type SqliteRunResult = {
Expand Down
2 changes: 2 additions & 0 deletions packages/db/migrations/0011_customer_alias_uniqueness.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
CREATE UNIQUE INDEX `customers_org_email_uniq` ON `customers` (`organization_id`, `email`);--> statement-breakpoint
CREATE UNIQUE INDEX `customers_org_external_uniq` ON `customers` (`organization_id`, `external_id`) WHERE `external_id` IS NOT NULL;
7 changes: 7 additions & 0 deletions packages/db/src/schema/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ export const customers = sqliteTable(
index("customers_org_idx").on(table.organizationId),
index("customers_email_idx").on(table.email),
index("customers_external_idx").on(table.externalId),
uniqueIndex("customers_org_email_uniq").on(
table.organizationId,
table.email,
),
uniqueIndex("customers_org_external_uniq")
.on(table.organizationId, table.externalId)
.where(sql`${table.externalId} is not null`),
],
);

Expand Down
Loading