Adding a New Product

Steps to add a new product to the payment system

The Zooly payment system follows a Product-Agnostic architecture. A central stripe_payments table handles all payment logic (Stripe integration, share calculations, completion tracking), while each product type has its own dedicated table and pricing API. The payFor enum acts as the router that tells the system which product type is being purchased.

This document describes every step needed to integrate a new product into this system.


Architecture Overview

┌──────────────┐     ┌──────────────────┐     ┌────────────────────┐
│  UI / Client │────▶│  Product-Creation │────▶│ Product Table      │
│              │     │  API              │     │ (domain-specific   │
│              │     │  (calc price,     │     │  record, returns   │
│              │     │   create record)  │     │  payForId)         │
│              │     └──────────────────┘     └────────────────────┘
│              │              │
    │              │              ▼  PaymentProduct
│              │     ┌──────────────────┐     ┌────────────────────┐
│              │────▶│ POST             │────▶│ stripe_payments    │
│              │     │ /api/payments/   │     │ (central table)    │
│              │     │ create-intent    │     └────────────────────┘
│              │     └──────────────────┘
│              │              │  { stripeClientSecret }
│              │              ▼
│              │     ┌──────────────────┐
│              │────▶│ Stripe Elements  │
│              │     │ confirmPayment() │
│              │     └──────────────────┘
│              │              │
│              │              ▼
│              │     ┌──────────────────┐
│              │────▶│ POST             │  ← client calls to complete
│              │     │ /api/payments/   │    (verifies with Stripe API,
│              │     │ complete         │     webhook is fallback)
│              │     └──────────────────┘
└──────────────┘

Key concept: The product-creation API creates the product-specific record first, returns a PaymentProduct (ID + price data) to the UI, and then the UI passes payFor and payForId to the generic create-intent API. The backend fetches price and seller from the product table (product is source of truth).


Step 1 — Add the Product Type to payFor

Add the new product type to the TypeScript type definition. The database enum automatically references this, eliminating duplication.

File: packages/types/src/types/StripePayment.ts

// Source of truth: packages/types/src/types/StripePayment.ts
// IP-term keys come from PAYABLE_IP_TERM_MAP; additional values are listed explicitly.
export const STRIPE_PAYMENT_PAY_FOR_VALUES = [
  "VOICE_OVER",
  "LIKENESS",
  "IMAGE",
  "MERCH",
  "OFFER", // marketplace offers — resolver in getProductByPayForId.ts
  "MY_NEW_PRODUCT", // ← add new pay-for values here
] as const;

The database enum (packages/db/src/schema/paymentEnums.ts) automatically imports and uses STRIPE_PAYMENT_PAY_FOR_VALUES, so no changes are needed there. The Drizzle schema and TypeScript type are kept in sync via build-time type checks in stripePayments.ts.

1b. Generate a DB migration

See Database Migrations for instructions on generating and applying migrations.


Step 2 — Create the Product Database Table and Type

Create a new Drizzle schema for your product's domain-specific data and the corresponding TypeScript type with build-time type safety.

2a. Create the schema table

File: packages/db/src/schema/myNewProduct.ts

import { pgTable, text, integer, timestamp, jsonb } from "drizzle-orm/pg-core";
import { nanoid } from "@zooly/util";
import { accountTable } from "./account";
import type { MyNewProduct } from "@zooly/types";

export const myNewProductTable = pgTable("my_new_product", {
  id: text("id").primaryKey().$defaultFn(() => nanoid()),
  accountId: text("account_id")
    .notNull()
    .references(() => accountTable.id),

  // Product-specific fields — customise these
  title: text("title").notNull(),
  description: text("description"),
  amountCent: integer("amount_cent").notNull(),
  priceData: jsonb("price_data"),     // ProductPriceData JSON
  terms: jsonb("terms"),              // ProductTerms[] JSON

  createdAt: timestamp("created_at", { withTimezone: true })
    .notNull()
    .defaultNow(),
  updatedAt: timestamp("updated_at", { withTimezone: true })
    .notNull()
    .defaultNow(),
});

// Type compatibility check (will error at build time if incompatible)
const _typeCheck: MyNewProduct = {} as typeof myNewProductTable.$inferSelect;

Key fields:

FieldPurpose
idPrimary key — becomes payForId in stripe_payments
accountIdThe seller (talent) account
amountCentThe price that will be charged
priceDataProductPriceData JSON with breakdown
termsProductTerms[] JSON with legal text shown to buyer

Existing product tables for reference

ProductTableSchema file
Voice-Over / Image / Likenessip_termspackages/db/src/schema/ipTerms.ts
Merchlicense_merchpackages/db/src/schema/licenseMerch.ts
Marketplace offer (checkout)offerspackages/db/src/schema/offers.ts

OFFER: There is no separate “product-only” table — the offers row is the domain record and payForId in stripe_payments is offers.id. Pricing is derived from offer_amount_cent, zooly_fee_cent, and total_brand_price_cent. Payment completion uses escrow: share tracking is created at delivery acceptance, not at charge time. See Payment Flows — Offer marketplace payments.

2b. Create the TypeScript type

File: packages/types/src/types/MyNewProduct.ts

import type { ProductPriceData, ProductTerms } from "./ProductPricing";

export interface MyNewProduct {
  id: string;
  accountId: string;
  title: string;
  description: string | null;
  amountCent: number;
  priceData: ProductPriceData | null;
  terms: ProductTerms[] | null;
  stripeProductId: string | null;
  createdAt: Date;
  updatedAt: Date;
}

Export it from packages/types/src/index.ts.

The type compatibility check in the schema file (see 2a) ensures build-time type safety between your schema and TypeScript type, following the same pattern used by Account, StripePayment, IpTerms, etc.

After creating the schema and type, export the schema from the DB package barrel and generate a migration.


Step 3 — Create the DB Access Functions

File: packages/db/src/access/myNewProduct.ts

All database access for this table goes through a dedicated access module (we never import tables directly from outside the DB package).

import { eq } from "drizzle-orm";
import { db } from "../db";
import { myNewProductTable } from "../schema/myNewProduct";

export async function createMyNewProduct(data: typeof myNewProductTable.$inferInsert) {
  const [row] = await db.insert(myNewProductTable).values(data).returning();
  return row;
}

export async function getMyNewProductById(id: string) {
  return db.query.myNewProductTable.findFirst({
    where: eq(myNewProductTable.id, id),
  });
}

Export these from packages/db/src/index.ts.


Step 4 — Create the Product-Creation API

This is the product-specific API. It receives product details from the UI, computes the price, creates a record in the domain table, and returns a standardised PaymentProduct.

4a. Service function

File: packages/app/srv/src/my-new-product/createMyNewProduct.ts

import type { Account, PaymentProduct, ProductPriceData } from "@zooly/types";
import { createMyNewProduct as dbCreate } from "@zooly/app-db";
import { calculateStripeFees } from "@zooly/util";

export async function createMyNewProductService(
  accountData: Account,
  productDetails: { /* your product-specific inputs */ }
): Promise<PaymentProduct> {
  // 1. Calculate price
  const baseAmountCent = /* your logic */; // Base price before fees
  const platformFeeCent = 500; // e.g. $5.00 fixed
  const amountCent = baseAmountCent + platformFeeCent; // Total charge amount
  
  // Calculate Stripe fee and talent share
  const stripeFees = calculateStripeFees(amountCent);
  const stripeFeeCent = stripeFees.stripeFeeTotalCent;
  const talentGrossShareCent = amountCent - stripeFeeCent - platformFeeCent;
  
  const priceData: ProductPriceData = {
    platformFeeCent,
    stripeFeeCent,
    talentGrossShareCent,
    rounding: 0,
    amountCent,
  };

  // 2. Build terms (optional)
  // Terms are derived from the Seller Account's IP Terms or from the product type
  const terms = [
    { shortTerms: "...", longTerms: "..." },
  ];

  // 3. Persist product record
  const record = await dbCreate({
    accountId: accountData.id,
    title: productDetails.title,
    description: productDetails.description ?? null,
    amountCent: priceData.amountCent,
    priceData,
    terms,
  });

  // 4. Return standardised result
  return {
    payFor: "MY_NEW_PRODUCT",
    payForId: record.id,
    sellerAccountId: accountData.id,
    priceData,
    title: record.title,
    description: record.description ?? undefined,
    terms,
  };
}

4b. API route

File: apps/zooly-app/app/api/my-new-product/create/route.ts

import { NextRequest, NextResponse } from "next/server";
import { resolveAccountId } from "@zooly/util-srv";
import { getAccountById } from "@zooly/app-db";
import { createMyNewProductService } from "@zooly/app-srv";

export async function POST(request: NextRequest) {
  const cookieHeader = request.headers.get("cookie") || "";
  if (!cookieHeader) {
    return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
  }

  const body = await request.json();
  const { accountId } = await resolveAccountId(cookieHeader, body.accountId);
  const account = await getAccountById(accountId);
  if (!account) {
    return NextResponse.json({ error: "Account not found" }, { status: 404 });
  }

  const result = await createMyNewProductService(account, body);
  return NextResponse.json({ status: "success", data: result });
}

4c. Response shape

The API returns:

{
  status: "success",
  data: PaymentProduct
}

Where PaymentProduct (from @zooly/types) is:

interface PaymentProduct {
  payFor: StripePaymentPayFor;   // e.g. "MY_NEW_PRODUCT"
  payForId: string;              // product record ID
  sellerAccountId: string;       // seller account ID
  priceData: ProductPriceData;   // pricing breakdown
  title: string;
  description?: string;
  terms: ProductTerms[];        // legal terms to display
}

interface ProductPriceData {
  platformFeeCent: number;       // platform fee in cents
  stripeFeeCent: number;          // estimated Stripe fee
  talentGrossShareCent: number;   // talent share before agent deductions
  rounding: number;               // rounding adjustment
  amountCent: number;             // final charge amount in cents
}

interface ProductTerms {
  shortTerms: string;
  longTerms: string;
}

These types are defined in packages/types/src/types/ProductPricing.ts.


Step 5 — UI Integration

5a. Call the product-creation API

async function createMyNewProduct(productDetails: {
  accountId: string;
  title: string;
  /* ... */
}): Promise<PaymentProduct> {
  const res = await fetch("/api/my-new-product/create", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(productDetails),
  });
  const json = await res.json();
  return json.data;
}

5b. Display price to the user

Use priceData.amountCent for the total charge and optionally show the breakdown:

import { formatCentToDollar } from "@zooly/util";

function PriceSummary({ priceData }: { priceData: ProductPriceData }) {
  return (
    <div data-testid="price-summary">
      <p>Platform fee: {formatCentToDollar(priceData.platformFeeCent)}</p>
      <p>Stripe fee: {formatCentToDollar(priceData.stripeFeeCent)}</p>
      <p><strong>Total: {formatCentToDollar(priceData.amountCent)}</strong></p>
    </div>
  );
}

5c. Create the payment intent

After the user confirms, call create-intent with only payFor and payForId. The backend fetches price and seller from the product table (product is source of truth):

const product = await createMyNewProduct(details);

const intentRes = await fetch("/api/payments/create-intent", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    payFor: product.payFor,           // "MY_NEW_PRODUCT"
    payForId: product.payForId,       // product record ID
  }),
});

const { stripeClientSecret, stripePublishableKey, stripePaymentId } =
  await intentRes.json();

5d. Confirm payment with Stripe Elements

import { loadStripe } from "@stripe/stripe-js";

const stripe = await loadStripe(stripePublishableKey);
const { error } = await stripe.confirmPayment({
  clientSecret: stripeClientSecret,
  confirmParams: { return_url: window.location.href },
});

5e. Complete payment and get purchase code

After Stripe redirects back, call the complete endpoint. This endpoint verifies the charge with Stripe's API and completes the payment (the webhook also fires as a fallback, but the client endpoint is the primary path):

const completeRes = await fetch("/api/payments/complete", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ stripePaymentId }),
});

if (completeRes.status === 202) {
  // Payment still processing at Stripe — retry after a short delay
}

const { ppuCode, stripePayment } = await completeRes.json();

The generic payment flow (steps 5c–5e) requires no changes — it works for all product types automatically.


Checklist

#TaskFile(s)
1Add value to STRIPE_PAYMENT_PAY_FOR_VALUESpackages/types/src/types/StripePayment.ts
2aCreate product schema tablepackages/db/src/schema/myNewProduct.ts
2bCreate type interface with type compatibility checkpackages/types/src/types/MyNewProduct.ts
2cRun Drizzle migrationpnpm drizzle-kit generate && pnpm drizzle-kit migrate
3Create DB access functionspackages/db/src/access/myNewProduct.ts
4aCreate product-creation servicepackages/app/srv/src/my-new-product/
4bCreate product-creation API routeapps/zooly-app/app/api/my-new-product/create/route.ts
5Build UI: call product API → display price → create intentClient component