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:
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:
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:
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:
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:
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:
import { createBillingApp } from '@vinenastudio/recurr-nextjs'
import { adapter } from './recurr-adapter'
export const billingApp = createBillingApp({
// ...diğer seçenekler
adapter,
})Referans Implementasyonlar
| Adapter | Konum | Açıklama |
|---|---|---|
PrismaAdapter | examples/saas-subscription/lib/prisma-adapter.ts | Production örneği |
JsonFileAdapter | apps/web/lib/adapter.ts | Demo amaçlı, flat JSON dosyası |