@farming-labs/orm

Billing Modules

Billing packages are one of the clearest fits for Farming Labs ORM because the commercial model usually changes more slowly than the consuming app stack.

The same billing package often needs to work across:

That means billing teams often end up repeating the same storage contract in too many places.

What billing packages usually need

Most reusable billing systems eventually model some combination of:

The drift usually shows up in three places:

  1. every stack gets its own schema flavor
  2. every backend gets its own storage helpers
  3. docs examples slowly stop matching the real billing model

Farming Labs ORM helps by letting the billing package own the domain contract once while the consuming application still chooses the final runtime or generated output.

The cleanest pattern usually looks like this:

  1. the billing package owns the billing schema
  2. the app decides what it wants to generate or execute
  3. the billing package writes its data helpers once against the unified runtime
  4. tests, demos, and examples bootstrap the same schema through runtime helpers

That means the billing package does not need separate Prisma-only, SQL-only, and Mongo-only business logic just to load subscriptions and issue invoices.

Step 1: define the billing schema once

billing-schema.ts
import {
  belongsTo,
  datetime,
  decimal,
  defineSchema,
  enumeration,
  hasMany,
  id,
  integer,
  model,
  string,
} from "@farming-labs/orm";

export const billingSchema = defineSchema({
  customer: model({
    table: "customers",
    fields: {
      id: id(),
      ownerId: string().unique(),
      providerCustomerId: string().unique(),
      email: string(),
      createdAt: datetime().defaultNow(),
    },
    relations: {
      subscriptions: hasMany("subscription", { foreignKey: "customerId" }),
      invoices: hasMany("invoice", { foreignKey: "customerId" }),
    },
  }),

  plan: model({
    table: "plans",
    fields: {
      id: id(),
      slug: string().unique(),
      name: string(),
      interval: enumeration(["monthly", "yearly"]),
      price: decimal(),
      createdAt: datetime().defaultNow(),
    },
    relations: {
      subscriptions: hasMany("subscription", { foreignKey: "planId" }),
    },
  }),

  subscription: model({
    table: "subscriptions",
    fields: {
      id: id(),
      customerId: string().references("customer.id"),
      planId: string().references("plan.id"),
      status: enumeration(["trialing", "active", "past_due", "canceled"]),
      seatCount: integer().default(1),
      currentPeriodStart: datetime(),
      currentPeriodEnd: datetime(),
      createdAt: datetime().defaultNow(),
    },
    relations: {
      customer: belongsTo("customer", { foreignKey: "customerId" }),
      plan: belongsTo("plan", { foreignKey: "planId" }),
      invoices: hasMany("invoice", { foreignKey: "subscriptionId" }),
      entitlements: hasMany("entitlement", { foreignKey: "subscriptionId" }),
      usageEvents: hasMany("usageEvent", { foreignKey: "subscriptionId" }),
    },
  }),

  invoice: model({
    table: "invoices",
    fields: {
      id: id(),
      customerId: string().references("customer.id"),
      subscriptionId: string().references("subscription.id"),
      status: enumeration(["draft", "open", "paid", "void"]),
      total: decimal(),
      issuedAt: datetime().defaultNow(),
    },
    relations: {
      customer: belongsTo("customer", { foreignKey: "customerId" }),
      subscription: belongsTo("subscription", { foreignKey: "subscriptionId" }),
    },
  }),

  entitlement: model({
    table: "entitlements",
    fields: {
      id: id(),
      subscriptionId: string().references("subscription.id"),
      key: string(),
      limit: integer(),
      createdAt: datetime().defaultNow(),
    },
    constraints: {
      unique: [["subscriptionId", "key"]],
    },
    relations: {
      subscription: belongsTo("subscription", { foreignKey: "subscriptionId" }),
    },
  }),

  usageEvent: model({
    table: "usage_events",
    fields: {
      id: id(),
      subscriptionId: string().references("subscription.id"),
      metric: string(),
      quantity: integer(),
      recordedAt: datetime().defaultNow(),
    },
    relations: {
      subscription: belongsTo("subscription", { foreignKey: "subscriptionId" }),
    },
  }),
});

This gives the package one durable business contract instead of asking every consumer to reinterpret billing tables in its own dialect.

If a Postgres deployment wants a dedicated namespace, the package can also move those tables into billing.* later with tableName(...) without changing the rest of the billing model.

import { tableName } from "@farming-labs/orm";

table: tableName("subscriptions", { schema: "billing" });

Step 2: let the app choose its generation or runtime path

If a consuming app wants Prisma artifacts:

farm-orm.config.ts
import { defineConfig } from "@farming-labs/orm-cli";
import { billingSchema } from "@acme/billing";

export default defineConfig({
  schemas: [billingSchema],
  targets: {
    prisma: {
      out: "./generated/prisma/schema.prisma",
      provider: "postgresql",
    },
  },
});

If another app wants Drizzle, safe SQL, or runtime-only execution, the billing package does not have to change its data model.

That split is a big deal for reusable billing kits because billing packages often need to serve:

If the package wants to render those artifacts directly in memory for a CLI, installer, or docs pipeline, it can use the generators:

import { renderDrizzleSchema, renderPrismaSchema, renderSafeSql } from "@farming-labs/orm";
import { billingSchema } from "./billing-schema";

const prisma = renderPrismaSchema(billingSchema, {
  provider: "postgresql",
});

const drizzle = renderDrizzleSchema(billingSchema, {
  dialect: "pg",
});

const sql = renderSafeSql(billingSchema, {
  dialect: "postgres",
});

That is useful when the billing package needs to:

Step 3: write billing helpers once against the unified runtime

billing-store.ts
import type { OrmClient } from "@farming-labs/orm";
import { billingSchema } from "./billing-schema";

export function createBillingStore(orm: OrmClient<typeof billingSchema>) {
  return {
    findPlanBySlug(slug: string) {
      return orm.plan.findUnique({
        where: { slug },
      });
    },

    findActiveSubscriptionForOwner(ownerId: string) {
      return orm.customer.findUnique({
        where: { ownerId },
        select: {
          id: true,
          ownerId: true,
          providerCustomerId: true,
          subscriptions: {
            where: {
              status: "active",
            },
            select: {
              id: true,
              status: true,
              seatCount: true,
              currentPeriodEnd: true,
              plan: {
                select: {
                  slug: true,
                  interval: true,
                  price: true,
                },
              },
              entitlements: {
                select: {
                  key: true,
                  limit: true,
                },
              },
            },
          },
        },
      });
    },

    async createCustomer(input: { ownerId: string; providerCustomerId: string; email: string }) {
      return orm.customer.create({
        data: input,
        select: {
          id: true,
          ownerId: true,
          providerCustomerId: true,
        },
      });
    },

    async changePlan(input: { subscriptionId: string; nextPlanId: string; nextPeriodEnd: Date }) {
      return orm.transaction(async (tx) => {
        const subscription = await tx.subscription.update({
          where: { id: input.subscriptionId },
          data: {
            planId: input.nextPlanId,
            currentPeriodEnd: input.nextPeriodEnd,
          },
          select: {
            id: true,
            customerId: true,
            planId: true,
            status: true,
          },
        });

        await tx.invoice.create({
          data: {
            customerId: subscription.customerId,
            subscriptionId: subscription.id,
            total: "0",
            status: "draft",
          },
        });

        return subscription;
      });
    },

    async replaceEntitlements(input: {
      subscriptionId: string;
      entries: Array<{ key: string; limit: number }>;
    }) {
      return orm.transaction(async (tx) => {
        await tx.entitlement.deleteMany({
          where: { subscriptionId: input.subscriptionId },
        });

        for (const entry of input.entries) {
          await tx.entitlement.create({
            data: {
              subscriptionId: input.subscriptionId,
              key: entry.key,
              limit: entry.limit,
            },
          });
        }
      });
    },

    recordUsage(input: { subscriptionId: string; metric: string; quantity: number }) {
      return orm.usageEvent.create({
        data: input,
      });
    },

    issueInvoice(input: { customerId: string; subscriptionId: string; total: string }) {
      return orm.invoice.create({
        data: {
          customerId: input.customerId,
          subscriptionId: input.subscriptionId,
          total: input.total,
          status: "open",
        },
      });
    },
  };
}

That gives the billing package one place to implement:

without duplicating the logic per backend.

Step 4: accept raw clients when the integration needs to

If your billing package or commerce framework wants to accept a raw database client directly, it should lean on the runtime helpers instead of rebuilding runtime detection and driver creation itself.

create-billing-runtime.ts
import { createOrmFromRuntime } from "@farming-labs/orm-runtime";
import { billingSchema } from "./billing-schema";

export async function createBillingOrm(database: unknown) {
  return createOrmFromRuntime({
    schema: billingSchema,
    client: database,
  });
}

That gives the consumer a small integration surface:

  1. pass the raw client
  2. let Farming Labs ORM normalize it
  3. run shared billing helpers against the normalized runtime

If a consuming app passes an unexpected wrapper or proxy around the database client, inspect it first:

import { inspectDatabaseRuntime } from "@farming-labs/orm";

const inspection = inspectDatabaseRuntime(database);

if (!inspection.runtime) {
  throw new Error(inspection.summary);
}

Step 5: use capabilities for billing behavior

Billing systems often need to branch on runtime behavior more carefully than a typical app.

const caps = orm.$driver.capabilities;

caps.supportsTransactions;
caps.numericIds;
caps.supportsSchemaNamespaces;
caps.upsert;
caps.returning.create;
caps.textMatching;

That can influence decisions like:

Step 6: normalize storage failures at the boundary

Billing systems usually need to convert backend failures into product-facing behavior.

import { isOrmError } from "@farming-labs/orm";

try {
  await orm.customer.create({
    data: {
      ownerId: "org_123",
      providerCustomerId: "cus_123",
      email: "billing@farminglabs.dev",
    },
  });
} catch (error) {
  if (isOrmError(error)) {
    switch (error.code) {
      case "UNIQUE_CONSTRAINT_VIOLATION":
        // customer already linked
        break;
      case "FOREIGN_KEY_VIOLATION":
        // bad plan or subscription reference
        break;
      case "TRANSACTION_CONFLICT":
        // safe place for retry logic
        break;
    }
  }

  throw error;
}

That keeps the billing package from learning Prisma, Postgres, MySQL, and Mongo error formats separately.

Step 7: bootstrap billing databases in tests, demos, and webhooks

Billing modules often need a real database in:

Use the runtime-aware setup helpers for that:

import { bootstrapDatabase } from "@farming-labs/orm-runtime/setup";
import { billingSchema } from "./billing-schema";

const orm = await bootstrapDatabase({
  schema: billingSchema,
  client,
});

That gives the package a single runtime-aware setup path instead of one Prisma-specific setup story, one SQL-specific story, and one Mongo-specific story.

If the billing package wants schema setup as a distinct step, use pushSchema(...) or applySchema(...) directly:

import { pushSchema } from "@farming-labs/orm-runtime/setup";

await pushSchema({
  schema: billingSchema,
  client,
});

That is often useful for:

Helper map for billing modules

Good places for a billing package to use this

When this approach is less useful

Farming Labs ORM is less compelling when a billing package:

Practical rule of thumb

If your billing problem sounds like:

then Farming Labs ORM is a strong fit.