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.
┌──────────────┐ ┌──────────────────┐ ┌────────────────────┐
│ 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).
payForAdd 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.
See Database Migrations for instructions on generating and applying migrations.
Create a new Drizzle schema for your product's domain-specific data and the corresponding TypeScript type with build-time type safety.
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:
| Field | Purpose |
|---|---|
id | Primary key — becomes payForId in stripe_payments |
accountId | The seller (talent) account |
amountCent | The price that will be charged |
priceData | ProductPriceData JSON with breakdown |
terms | ProductTerms[] JSON with legal text shown to buyer |
| Product | Table | Schema file |
|---|---|---|
| Voice-Over / Image / Likeness | ip_terms | packages/db/src/schema/ipTerms.ts |
| Merch | license_merch | packages/db/src/schema/licenseMerch.ts |
| Marketplace offer (checkout) | offers | packages/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.
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.
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.
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.
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,
};
}
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 });
}
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.
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;
}
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>
);
}
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();
import { loadStripe } from "@stripe/stripe-js";
const stripe = await loadStripe(stripePublishableKey);
const { error } = await stripe.confirmPayment({
clientSecret: stripeClientSecret,
confirmParams: { return_url: window.location.href },
});
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.
| # | Task | File(s) |
|---|---|---|
| 1 | Add value to STRIPE_PAYMENT_PAY_FOR_VALUES | packages/types/src/types/StripePayment.ts |
| 2a | Create product schema table | packages/db/src/schema/myNewProduct.ts |
| 2b | Create type interface with type compatibility check | packages/types/src/types/MyNewProduct.ts |
| 2c | Run Drizzle migration | pnpm drizzle-kit generate && pnpm drizzle-kit migrate |
| 3 | Create DB access functions | packages/db/src/access/myNewProduct.ts |
| 4a | Create product-creation service | packages/app/srv/src/my-new-product/ |
| 4b | Create product-creation API route | apps/zooly-app/app/api/my-new-product/create/route.ts |
| 5 | Build UI: call product API → display price → create intent | Client component |
On This Page
Architecture OverviewStep 1 — Add the Product Type to ,[object Object]1b. Generate a DB migrationStep 2 — Create the Product Database Table and Type2a. Create the schema tableExisting product tables for reference2b. Create the TypeScript typeStep 3 — Create the DB Access FunctionsStep 4 — Create the Product-Creation API4a. Service function4b. API route4c. Response shapeStep 5 — UI Integration5a. Call the product-creation API5b. Display price to the user5c. Create the payment intent5d. Confirm payment with Stripe Elements5e. Complete payment and get purchase codeChecklist