recurr.
Getting Started

Adapter Pattern

Kütüphaneyi kendi veritabanınıza bağlamak için adapter arayüzünü uygulayın

Neden Adapter Pattern?

@vinenastudio/recurr hiçbir veritabanı hakkında bilgi taşımaz. Yalnızca bir arayüz tanımlar — gerçek veri erişimini siz yazarsınız.

Bu sayede:

  • Mevcut ORM'inizi veya veritabanı istemcinizi kullanabilirsiniz
  • Şemanızı değiştirmek zorunda kalmazsınız
  • Aynı adapter'ı test ortamında sahte veriyle değiştirebilirsiniz

Tek yapmanız gereken BetterPurchaseAdapter arayüzünü implement etmek ve createBillingApp'e geçmek.

BetterPurchaseAdapter Arayüzü

Adapter'ın karşılaması gereken tam arayüz:

interface BetterPurchaseAdapter {
  // Plan
  findPlan(planId: string): Promise<Plan | null>
  listPlans(): Promise<Plan[]>
  createPlan(data: Omit<Plan, 'id' | 'createdAt'>): Promise<Plan>

  // Subscriber
  findSubscriberByEmail(email: string): Promise<Subscriber | null>
  findSubscriberById(id: string): Promise<Subscriber | null>
  listSubscribers(): Promise<Subscriber[]>
  findSubscribersDueToday(): Promise<Subscriber[]>
  createSubscriber(data: Omit<Subscriber, 'id' | 'createdAt'>): Promise<Subscriber>
  updateSubscriber(id: string, data: Partial<Subscriber>): Promise<Subscriber>

  // Payment
  findPaymentByMerchantOid(merchantOid: string): Promise<Payment | null>
  listPayments(): Promise<Payment[]>
  createPayment(data: Omit<Payment, 'id' | 'createdAt'>): Promise<Payment>
  updatePayment(id: string, data: Partial<Payment>): Promise<Payment>

  // PendingCheckout
  createPendingCheckout(data: Omit<PendingCheckout, 'createdAt'>): Promise<PendingCheckout>
  findPendingCheckout(merchantOid: string): Promise<PendingCheckout | null>
  deletePendingCheckout(merchantOid: string): Promise<void>
}

findSubscribersDueToday() cron işlemi için kritiktir. nextBillingDate <= şimdi ve status IN ('active', 'past_due', 'trial') koşulunu sağlayan aboneleri döndürmelidir.

Implementasyon Örnekleri

Önce prisma/schema.prisma dosyanıza gerekli modelleri ekleyin:

prisma/schema.prisma
model Plan {
  id          String       @id @default(cuid())
  name        String
  price       Float
  interval    String
  createdAt   DateTime     @default(now())
  subscribers Subscriber[]
}

model Subscriber {
  id              String    @id @default(cuid())
  email           String    @unique
  planId          String?
  status          String    @default("inactive")
  nextBillingDate DateTime?
  createdAt       DateTime  @default(now())
  plan            Plan?     @relation(fields: [planId], references: [id])
  payments        Payment[]
}

model Payment {
  id          String     @id @default(cuid())
  merchantOid String     @unique
  subscriberId String
  amount      Float
  currency    String     @default("TRY")
  status      String
  paidAt      DateTime?
  createdAt   DateTime   @default(now())
  subscriber  Subscriber @relation(fields: [subscriberId], references: [id])
}

model PendingCheckout {
  merchantOid String   @id
  email       String
  planId      String
  createdAt   DateTime @default(now())
}

Ardından adapter'ı yazın:

lib/recurr-adapter.ts
import type { BetterPurchaseAdapter, Plan, Subscriber, Payment, PendingCheckout } from '@vinenastudio/recurr'
import { prisma } from './prisma'

export const adapter: BetterPurchaseAdapter = {
  // Plan
  findPlan: (planId) => prisma.plan.findUnique({ where: { id: planId } }),
  listPlans: () => prisma.plan.findMany(),
  createPlan: (data) => prisma.plan.create({ data }),

  // Subscriber
  findSubscriberByEmail: (email) => prisma.subscriber.findUnique({ where: { email } }),
  findSubscriberById: (id) => prisma.subscriber.findUnique({ where: { id } }),
  listSubscribers: () => prisma.subscriber.findMany(),
  findSubscribersDueToday: () =>
    prisma.subscriber.findMany({
      where: {
        nextBillingDate: { lte: new Date() },
        status: { in: ['active', 'past_due', 'trial'] },
      },
    }),
  createSubscriber: (data) => prisma.subscriber.create({ data }),
  updateSubscriber: (id, data) => prisma.subscriber.update({ where: { id }, data }),

  // Payment
  findPaymentByMerchantOid: (merchantOid) => prisma.payment.findUnique({ where: { merchantOid } }),
  listPayments: () => prisma.payment.findMany(),
  createPayment: (data) => prisma.payment.create({ data }),
  updatePayment: (id, data) => prisma.payment.update({ where: { id }, data }),

  // PendingCheckout
  createPendingCheckout: (data) => prisma.pendingCheckout.create({ data }),
  findPendingCheckout: (merchantOid) => prisma.pendingCheckout.findUnique({ where: { merchantOid } }),
  deletePendingCheckout: async (merchantOid) => { await prisma.pendingCheckout.delete({ where: { merchantOid } }) },
}

Önce db/schema.ts dosyanıza tabloları tanımlayın:

db/schema.ts
import { text, real, sqliteTable } from 'drizzle-orm/sqlite-core'

export const plans = sqliteTable('plans', {
  id:        text('id').primaryKey(),
  name:      text('name').notNull(),
  price:     real('price').notNull(),
  interval:  text('interval').notNull(),
  createdAt: text('created_at').notNull(),
})

export const subscribers = sqliteTable('subscribers', {
  id:              text('id').primaryKey(),
  email:           text('email').notNull().unique(),
  planId:          text('plan_id'),
  status:          text('status').notNull().default('inactive'),
  nextBillingDate: text('next_billing_date'),
  createdAt:       text('created_at').notNull(),
})

export const payments = sqliteTable('payments', {
  id:           text('id').primaryKey(),
  merchantOid:  text('merchant_oid').notNull().unique(),
  subscriberId: text('subscriber_id').notNull(),
  amount:       real('amount').notNull(),
  currency:     text('currency').notNull().default('TRY'),
  status:       text('status').notNull(),
  paidAt:       text('paid_at'),
  createdAt:    text('created_at').notNull(),
})

export const pendingCheckouts = sqliteTable('pending_checkouts', {
  merchantOid: text('merchant_oid').primaryKey(),
  email:       text('email').notNull(),
  planId:      text('plan_id').notNull(),
  createdAt:   text('created_at').notNull(),
})

Ardından adapter'ı yazın:

lib/recurr-adapter.ts
import type { BetterPurchaseAdapter } from '@vinenastudio/recurr'
import { eq, lte, inArray } from 'drizzle-orm'
import { db } from './db'
import { plans, subscribers, payments, pendingCheckouts } from '@/db/schema'

export const adapter: BetterPurchaseAdapter = {
  // Plan
  findPlan: (id) => db.select().from(plans).where(eq(plans.id, id)).get() ?? null,
  listPlans: () => db.select().from(plans).all(),
  createPlan: (data) => db.insert(plans).values({ ...data, id: crypto.randomUUID(), createdAt: new Date().toISOString() }).returning().get(),

  // Subscriber
  findSubscriberByEmail: (email) => db.select().from(subscribers).where(eq(subscribers.email, email)).get() ?? null,
  findSubscriberById: (id) => db.select().from(subscribers).where(eq(subscribers.id, id)).get() ?? null,
  listSubscribers: () => db.select().from(subscribers).all(),
  findSubscribersDueToday: () =>
    db.select().from(subscribers).where(
      lte(subscribers.nextBillingDate, new Date().toISOString()),
    ).all().filter((s) => ['active', 'past_due', 'trial'].includes(s.status)),
  createSubscriber: (data) => db.insert(subscribers).values({ ...data, id: crypto.randomUUID(), createdAt: new Date().toISOString() }).returning().get(),
  updateSubscriber: (id, data) => db.update(subscribers).set(data).where(eq(subscribers.id, id)).returning().get(),

  // Payment
  findPaymentByMerchantOid: (merchantOid) => db.select().from(payments).where(eq(payments.merchantOid, merchantOid)).get() ?? null,
  listPayments: () => db.select().from(payments).all(),
  createPayment: (data) => db.insert(payments).values({ ...data, id: crypto.randomUUID(), createdAt: new Date().toISOString() }).returning().get(),
  updatePayment: (id, data) => db.update(payments).set(data).where(eq(payments.id, id)).returning().get(),

  // PendingCheckout
  createPendingCheckout: (data) => db.insert(pendingCheckouts).values({ ...data, createdAt: new Date().toISOString() }).returning().get(),
  findPendingCheckout: (merchantOid) => db.select().from(pendingCheckouts).where(eq(pendingCheckouts.merchantOid, merchantOid)).get() ?? null,
  deletePendingCheckout: async (merchantOid) => { await db.delete(pendingCheckouts).where(eq(pendingCheckouts.merchantOid, merchantOid)) },
}

pg, mysql2, better-sqlite3 gibi herhangi bir SQL sürücüsüyle çalışır. Aşağıdaki örnek pg (PostgreSQL) kullanmaktadır:

lib/recurr-adapter.ts
import type { BetterPurchaseAdapter, Plan, Subscriber, Payment, PendingCheckout } from '@vinenastudio/recurr'
import { pool } from './db'

function toDate(v: string | null): Date | null {
  return v ? new Date(v) : null
}

export const adapter: BetterPurchaseAdapter = {
  // Plan
  async findPlan(id) {
    const { rows } = await pool.query('SELECT * FROM plans WHERE id = $1', [id])
    return rows[0] ?? null
  },
  async listPlans() {
    const { rows } = await pool.query('SELECT * FROM plans ORDER BY created_at')
    return rows
  },
  async createPlan(data) {
    const id = crypto.randomUUID()
    const { rows } = await pool.query(
      'INSERT INTO plans (id, name, price, interval, created_at) VALUES ($1,$2,$3,$4,NOW()) RETURNING *',
      [id, data.name, data.price, data.interval],
    )
    return rows[0]
  },

  // Subscriber
  async findSubscriberByEmail(email) {
    const { rows } = await pool.query('SELECT * FROM subscribers WHERE email = $1', [email])
    return rows[0] ?? null
  },
  async findSubscriberById(id) {
    const { rows } = await pool.query('SELECT * FROM subscribers WHERE id = $1', [id])
    return rows[0] ?? null
  },
  async listSubscribers() {
    const { rows } = await pool.query('SELECT * FROM subscribers ORDER BY created_at DESC')
    return rows
  },
  async findSubscribersDueToday() {
    const { rows } = await pool.query(
      `SELECT * FROM subscribers
       WHERE next_billing_date <= NOW()
       AND status IN ('active', 'past_due', 'trial')`,
    )
    return rows
  },
  async createSubscriber(data) {
    const id = crypto.randomUUID()
    const { rows } = await pool.query(
      'INSERT INTO subscribers (id, email, plan_id, status, next_billing_date, created_at) VALUES ($1,$2,$3,$4,$5,NOW()) RETURNING *',
      [id, data.email, data.planId ?? null, data.status ?? 'inactive', data.nextBillingDate ?? null],
    )
    return rows[0]
  },
  async updateSubscriber(id, data) {
    const { rows } = await pool.query(
      'UPDATE subscribers SET plan_id=$2, status=$3, next_billing_date=$4 WHERE id=$1 RETURNING *',
      [id, data.planId, data.status, data.nextBillingDate ?? null],
    )
    return rows[0]
  },

  // Payment
  async findPaymentByMerchantOid(merchantOid) {
    const { rows } = await pool.query('SELECT * FROM payments WHERE merchant_oid = $1', [merchantOid])
    return rows[0] ?? null
  },
  async listPayments() {
    const { rows } = await pool.query('SELECT * FROM payments ORDER BY created_at DESC')
    return rows
  },
  async createPayment(data) {
    const id = crypto.randomUUID()
    const { rows } = await pool.query(
      'INSERT INTO payments (id, merchant_oid, subscriber_id, amount, currency, status, paid_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,NOW()) RETURNING *',
      [id, data.merchantOid, data.subscriberId, data.amount, data.currency ?? 'TRY', data.status, data.paidAt ?? null],
    )
    return rows[0]
  },
  async updatePayment(id, data) {
    const { rows } = await pool.query(
      'UPDATE payments SET status=$2, paid_at=$3 WHERE id=$1 RETURNING *',
      [id, data.status, data.paidAt ?? null],
    )
    return rows[0]
  },

  // PendingCheckout
  async createPendingCheckout(data) {
    const { rows } = await pool.query(
      'INSERT INTO pending_checkouts (merchant_oid, email, plan_id, created_at) VALUES ($1,$2,$3,NOW()) RETURNING *',
      [data.merchantOid, data.email, data.planId],
    )
    return rows[0]
  },
  async findPendingCheckout(merchantOid) {
    const { rows } = await pool.query('SELECT * FROM pending_checkouts WHERE merchant_oid = $1', [merchantOid])
    return rows[0] ?? null
  },
  async deletePendingCheckout(merchantOid) {
    await pool.query('DELETE FROM pending_checkouts WHERE merchant_oid = $1', [merchantOid])
  },
}

Adapter'ı Bağlamak

Adapter'ı yazdıktan sonra createBillingApp'e geçin:

lib/recurr.ts
import { createBillingApp } from '@vinenastudio/recurr-nextjs'
import { adapter } from './recurr-adapter'

export const billingApp = createBillingApp({
  // ...diğer seçenekler
  adapter,
})

Referans Implementasyonlar

AdapterKonumAçıklama
PrismaAdapterexamples/saas-subscription/lib/prisma-adapter.tsProduction örneği
JsonFileAdapterapps/web/lib/adapter.tsDemo amaçlı, flat JSON dosyası

On this page