Lalabase
티켓·문서·근무시간·고객대화를 한 곳에 모아 KI로 검색, 소프트웨어 기업 고객 프로젝트용, 오픈소스로 자체 서버·독일 클라우드에 배포
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기Online booking for Turkish local businesses, with iyzico deposits and subscriptions. Next.js · Postgres · Drizzle.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Online booking for Turkish local businesses — salons, barbers, clinics. An
owner sets up services and hours, and gets a public page where customers book,
and can pay a deposit (kapora), without an account. The UI is in Turkish.README 전문 보기 ↗Online booking for Turkish local businesses — salons, barbers, clinics. An owner sets up services and hours, and gets a public page where customers book, and can pay a deposit (kapora), without an account. The UI is in Turkish.
Live: rezerve-iota.vercel.app
/r/demo.
Cilt Bakımı takes a ₺300 kapora through iyzico's sandbox: use a test card
such as 5311 5700 0000 0005 with any future expiry and any CVC./giris as
[email protected] / rezerve-demo (Pro), or [email protected] /
rezerve-demo (free plan: the same services, but the kapora is not
collected).EXCLUDE USING gist constraint over the time range decides,
pending holds included, and the race is a test. Run with the constraint
dropped, eight of the fifty got the slot: the app's own availability check
catches most of a race, never all of it.Z; the
payouts page does not take them for UTC, which would show every payment
three hours late./v2/subscription/* call returns 100001, and iyzico's
integration team confirmed it — so Rezerve runs its own renewals, from a card
taken in its own form: a PCI trade-off made knowingly, in the sandbox. The
payouts page was corrected once for counting money iyzico still held as the
owner's, and when iyzico's daily transaction listing stopped listing on
23 September, it moved to asking about each booking by its own id.Next.js 16 (App Router, Cache Components) · React 19 · TypeScript · Tailwind v4 + shadcn/ui (Base UI) · Postgres (Neon) + Drizzle ORM · Better Auth · iyzico · React Email + Nodemailer · Bun · Biome · Vitest · Playwright
Upgrading to Pro takes the card number in Rezerve's own form
(/panel/abonelik), and a server action passes it to iyzico's direct payment
API with registerCard: 1, without 3-D Secure. The number is never logged and
never stored; only the returned cardToken and cardUserKey are kept, for
renewals.
On an iyzico marketplace account that is the only way recurring billing
works: the hosted Checkout Form carries no card object, the hosted card vault
(/v2/ucs/init) answers 42205, and iyzico's integration team points
recurring payments at card storage on the direct API. (Why this is a
marketplace account at all is under
Why there is no iyzico Abonelik integration.)
Against the sandbox, with test cards, that costs nothing. With real cards it
would cost two things this build deliberately stops short of. A card number
passing through the server puts the whole deployment in PCI DSS scope — SAQ D,
not the SAQ A a hosted payment page allows. And the first payment would have to
go through threedsInitialize for 3-D Secure; renewals stay non-3DS either way,
since the customer is not there to authenticate.
iyzico does not offer Abonelik on a marketplace account. Their integration
team was explicit: "Pazaryeri iş modelimizde tekrarlı ödemeler abonelik
özelliği ile sağlanamamaktadır" — recurring payments in the marketplace
business model are done with card storage instead. The API agrees: every
/v2/subscription/* endpoint returns 100001, before and after the account
was switched to marketplace.
The two products are account modes, not independent flags — the enablement email says the account's iş modeli was "updated to" marketplace. And the SDK shows why they can't coexist: no subscription request model accepts a submerchant field, so iyzico's subscription engine has no way to route a recurring charge to a submerchant. On a marketplace account, it is switched off wholesale.
So Rezerve owns its own billing schedule:
registerCard lives
only inside the PaymentCard model, which only the direct payment API
sends — the Checkout Form and PayWithIyzico carry no card object at all,
and the hosted vault (/v2/ucs/init) returns 42205 Ucs müşteri için aktif değil. iyzico's integration team directs recurring billing through card
storage on the direct API, so /panel/abonelik collects the card and posts
it to a server action, which passes it straight to
payment.create with registerCard: 1. The number is never logged and
never persisted; only the returned cardToken/cardUserKey are kept. What
that would mean with real cards, for PCI scope and 3-D Secure, is under
Card handling, deliberately.vercel.json → /api/cron/abonelik,
bearer-authenticated with CRON_SECRET) charging the stored token. The check
is timing-safe and fails closed: an unset CRON_SECRET returns 503 and
runs nothing, rather than treating "no secret configured" as "allow
everyone". It lives in lib/billing/cron-auth.ts so that guarantee is unit
tested. There is no auth middleware in this project — every other route is
guarded in-handler by requireOwner() — so nothing needs excluding.subscription_charges is
UNIQUE (organization_id, period_start), so a replayed or concurrent run
loses the insert; retries then advance an attempt counter under an
optimistic lock. The period key is anchored to currentPeriodEndsAt, never
to nextChargeAt — keying on the latter would open a new billing period on
every dunning retry.past_due and retries daily for
RETRY_DAYS (3, matching PAST_DUE_GRACE_DAYS so entitlement and retries
expire together), then expires.checkoutFormInitializePreAuth) instead.A marketplace payment is held by iyzico until the platform approves the
item transaction (POST /payment/iyzipos/item/approve) — the "service was
delivered, release the funds" signal. Skip it and the kapora reaches the
business's submerchant and never leaves; iyzico's own panel shows
Alt Üye İşyeri Ödemeleri as empty.
Rezerve approves as soon as the payment succeeds, inside confirmPaidBooking.
Approving does not stand in the way of a refund, so there is nothing to wait
for: a kapora owed back — the business cancels, or the customer does with 24
hours' notice — is returned with refund, which works on a released payment as
well as a held one (see the notes on the API below). And doing it in the same
guarded transition means it happens exactly once, since a replayed callback
matches zero rows and never reaches the call.
If approval fails the booking is still confirmed: the customer has paid, and
refusing to confirm would be worse than a payout that needs releasing by hand.
bookings.payment_transaction_id is stored precisely so that is possible.
/panel/odemeler reports released and held money separately rather than as
one figure — an unapproved kapora is still iyzico's, and showing it as the
owner's would repeat an overstatement this page has already been corrected for
once. Approval state comes from transactionStatus on the payment's item
(2 = released, 1 = held), read live on every page load rather than cached, so
the state is never stale. iyzico's own panel surfaces the same thing as "Onay
Durumu", under Detayı Göster on a single payment.
The page asks iyzico about each deposit booking by its own id
(/v2/reporting/payment/details) rather than listing a day's transactions.
The daily listing (/v2/reporting/payment/transactions) is platform-wide, so
it hands back every business's payments to be filtered — and on 23 September
it listed none of that day's payments for hours, intermittently failing with
"Sistem hatası", while the details endpoint returned every one. Asking per
booking costs a round trip each, capped at the newest 40 in the range, and a
lookup that fails costs that row rather than the page.
A payment can also land on a booking that no longer exists — the owner
cancelled a pending hold while the customer was paying, or cancelExpiredHolds
swept the lapsed hold during someone else's booking attempt, which is the
likelier of the two. confirmPaidBooking's guard matches nothing in that case,
so nothing else would ever notice the money. It now stores the transaction id
and refunds instead. The id is stored before refunding: without it the
payment has no handle at all, so a refund that fails would leave money at
iyzico with nothing pointing at it.
Cancelling returns the kapora — when it should. When the business calls
the appointment off (cancelBooking, behind requireOwner()), it always does:
the customer should not be out of pocket, and a kapora only earns its keep
against a no-show, which this is not. When the customer cancels
(cancelBookingAsCustomer, from the booking's page), it does with 24 hours'
notice and stays with the business after that
(lib/booking/cancellation-policy.ts). The refund is idempotent
through bookings.deposit_refunded_at, claimed before iyzico is called and
released again on failure so a booking is never silently stuck as refunded.
The cancellation email tells the customer, since otherwise nothing would.
Two notes on the surrounding API, both verified rather than assumed.
Disapproval is not a cancellation tool — it only undoes an approval that
already happened, and returns 5103 Bu ödeme kırılımı onaylanmamıştır on a
held payment. refund is what returns money, and it works both on a held
payment and on one already approved and released — which is why approving at
payment time costs nothing in flexibility.
Probed directly against the sandbox keys in .env:
| Call | Result |
|---|---|
POST /payment/iyzipos/checkoutform/initialize/auth/ecom | works, returns paymentPageUrl |
the same with subMerchantKey + subMerchantPrice | works — the kapora split is real |
POST /onboarding/submerchant | works (after iyzico enabled Marketplace) |
POST /cardstorage/card → cardList → payment/auth on the stored card | works |
POST /v2/subscription/* | 100001 — unavailable on a marketplace account |
POST /v2/ucs/init | 42205 — hosted card storage not enabled |
POST /payment/iyzipos/item/approve | works — called on every paid kapora (see above) |
GET /v2/reporting/payment/details | works — has a payment within seconds |
GET /v2/reporting/payment/transactions | unreliable — see the payouts note above |
/r/[slug] booking page
(Better Auth organizations, one business per owner)@date-fns/tz)EXCLUDE USING gist constraint makes
double-booking impossible at the database level, even under concurrent
requestspending for 30 minutes and confirmed by an idempotent payment
callback (server-side token verification); abandoned checkouts release the
hold automatically.ics file. The file is written by hand to RFC 5545 (UTC times, escaping,
folding that never splits a ğ), and keeps the booking's id as its UID, so the
copy sent after a move replaces the event instead of adding a secondlib/booking/slots.test.ts — pure slot-engine tests incl. DST
spring-forward/fall-back dayslib/actions/bookings.integration.test.ts — real-DB tests incl. a
parallel double-submit race (exactly one booking wins). It also caught
Postgres resolving that race as a deadlock (40P01) rather than an
exclusion violation, which is why isExclusionConflict treats both as
"the slot is taken"lib/actions/booking-race.integration.test.ts — fifty simultaneous
reservations for one slot: one booking, and forty-nine visitors told the
slot is gone. The pool is widened so all fifty are inside Postgres at once
(the peak is recorded, about 57 connections), each arrives from its own
address so the throttle stays out of it, and with the exclusion constraint
dropped the test fails (eight of the fifty got the slot in that run)lib/booking/exceptions.test.ts — holidays and one-off hours resolved
before the slot engine: a closed date has no slots, a Sunday opening doeslib/actions/owner-bookings.integration.test.ts — an owner booking or
moving an appointment: outside the hours only after asking, never over
another appointment, never another business's bookinglib/demo/reset.integration.test.ts — the nightly demo reset, playing the
visitor who renames, strips and closes the demo: one run puts every piece
back, and touches no other businesslib/payments/handle-payment-result.integration.test.ts — payment
callback idempotency: duplicate callbacks, wrong tokens, out-of-order
success-after-cancellib/billing/entitlements.test.ts — plan resolution with an injected clock,
including the cancelled-but-still-paid-for windowlib/billing/validators.test.ts — real TCKN/VKN check digits and IBAN mod-97lib/actions/bookings-billing.integration.test.ts — the enforcement proof:
an unentitled org books free with depositCents = null and never starts a
payment; an entitled one holds the slot and passes a subMerchantKeylib/billing/activate-subscription.integration.test.ts — turning a payment
into an active subscription: the mandate is stored, a double-submitted form
does not slide the renewal date, and a payment that returns no card leaves
the renewal cron disarmed rather than failing dailylib/panel/payouts.test.ts — the payouts view: only a successful payment
answering for one of our own bookings becomes a row, a retried checkout
counts once, held money is never shown as released, and iyzico's
Istanbul-time timestamps are not read as UTClib/billing/charge-subscription.integration.test.ts — renewal billing: a
replayed run and two concurrent runs each charge exactly once, dunning
retries then expires, and a trial lapses instead of chargingrequireOwner(), which resolves the plan once;
the public booking path reads the business's own billing rows. Nothing in
the browser decides what a business may do.lib/booking/ — slots.ts for the slot engine, opening-hours.ts for
times an owner types in.lib/booking/exceptions.ts) are resolved into a day's
intervals before the slot engine runs, so the engine and its DST tests
are unchanged. One-off hours are stamped with the date's weekday, which is
what lets a Sunday opening survive the engine's weekday filter. A second
exclusion constraint (0010_*.sql, daterange … WITH &&) keeps each date
answering to at most one exception.lib/db/migrations/0001_*.sql) covers
pending holds too, so a held slot can't be double-sold; expired holds
are released by the payment callback, by a lazy cleanup on the next
booking attempt and by the daily cron's sweep, and filtered out of
availability queries.cacheComponents): every page's static
part is prerendered and served at once — the landing page's shell is the
whole signed-out page — and only what depends on the request streams in.
The price, accepted: inside a streamed boundary the response has already
begun as a 200, so a booking page for an unknown business is a 200 marked
noindex rather than a 404, and an old address or a signed-out /panel
redirects from the browser rather than with a 307. Link previewers do not
run that redirect, so an old address's tags and card follow the move.attachDatabasePool lets Fluid Compute close idle connections before it
suspends an instance. A pasted sslmode=require is rewritten to
verify-full, so the certificate is always checked.Rezerve Assistant, a chat receptionist (in progress), books through a Model
Context Protocol endpoint rather than the database. It runs the same booking
rules the booking page does — lib/booking/create-booking-core.ts,
customer-cancel-core.ts and customer-reschedule-core.ts, which the
page's server actions now wrap — so the exclusion constraint, the throttle,
the entitlements and the kapora rules hold for a chat customer exactly as
for a visitor.
Authorization: Bearer <ASSISTANT_MCP_TOKEN> (fail-closed, constant-time — the same check
as the cron's), X-Rezerve-Business: <slug> and X-Rezerve-Customer: <customer ref>. No tool takes a business or a customer, so the model
cannot name someone else's; lib/assistant/tool-schema.test.ts keeps it so.create_booking takes the
kapora the customer was shown and cancel_booking what they were told
about the refund; if either changed since, nothing is written
(terms_changed, refund_window_closed).assistant_enabled. The demos have it on.The chat widget (components/assistant/chat-widget.tsx) puts the
assistant on /r/[slug] for a business with assistant_enabled, when
ASSISTANT_URL says where rezerve-assistant runs; it is read per request, so one
build can point anywhere. Closed, the widget has sent nothing. Its visitor id,
a random uuid kept in localStorage, is the conversation's only credential. It
posts a message, then polls every second while an answer is coming, for at
most a minute, and a kapora link arrives as a button.
| Tool | Writes | What it does |
|---|---|---|
get_business_info | — | Contact details, notes, today's date in the business timezone |
list_services | — | Active services, with the kapora actually collected |
find_slots | — | Free starts across up to 14 days, leaving out the booking being moved |
list_my_bookings | — | The customer's upcoming bookings and live holds |
get_booking | — | One of their bookings, e.g. whether it is paid |
create_booking | ✓ | Book, or hold for 30 minutes with a payment page |
reschedule_booking | ✓ | Move to another free start, keeping the length; never a paid booking inside its last day |
cancel_booking | ✓ | Cancel under the 24-hour kapora rule |
Everything is Turkish, including the URLs:
| Route | Purpose |
|---|---|
/ | Marketing landing page |
/giris · /kayit · /kurulum | Login, signup, business onboarding |
/panel (+ randevular hizmetler saatler ayarlar) | Owner dashboard |
/panel/odemeler | Kapora over 7/30/90 days, with iyzico's cut itemised |
/panel/abonelik (+ odeme-hesabi) | Plan, and the submerchant payout form |
/r/[slug] | Public booking page (no customer account) |
/r/[slug]/onay/[bookingId] | Booking confirmation |
/r/[slug]/onay/[bookingId]/takvim | The booking as an .ics file |
/api/r/[slug]/slots | Availability API |
/api/mcp | The chat assistant's MCP endpoint (bearer token) |
/api/odeme/iyzico | iyzico deposit callback |
/api/cron/abonelik | Daily: renewals, hold sweep, demo reset (bearer) |
bun install
cp .env.example .env # fill in DATABASE_URL etc.
bun run db:migrate
bun dev
Useful scripts: bun run check (Biome), bun run typecheck,
bun run test (unit + DB integration tests), bun run e2e (Playwright
against a production build — it refuses any DATABASE_URL that is not on
this machine, since it books appointments and resets the demos),
bun run db:studio,
bun run demo:submerchant (creates the demo business's iyzico submerchant),
bun run seed:demo (creates two demo businesses: /r/demo on Pro, and
/r/demo-ucretsiz on the free plan with the same services — the only way to
see the downgrade rule rather than read about it). It is a reset, not an
additive seed: both demos go back to exactly their published state, with a
fresh booking history, and the daily cron runs the same reset
(lib/demo/reset.ts) because their logins are public.
bun run seed:eval creates or resets /r/eval — the Pro demo's services
and hours with no bookings, for the rezerve-assistant eval harness; it refuses
any DATABASE_URL not on this machine. Its owner is never mailed, but its
scenario customers would be: run the evals with EMAIL_DELIVERY=off.
bun run mcp:schema prints the assistant's tools exactly as /api/mcp lists
them — the contract rezerve-assistant keeps a copy of and tests itself against.
Payments use the iyzico sandbox by default (IYZICO_BASE_URL); create
sandbox keys at sandbox-merchant.iyzipay.com.
There is one payment code path. An in-app simulation used to stand in while iyzico had neither Marketplace nor a usable recurring path, but a second path that production never exercises is somewhere the real one can rot unnoticed — so it was removed. Every payment path needs iyzico credentials; the rest of the app runs without them, which is how the end-to-end suite books on the free demo in CI.
Sandbox versus production is a separate axis: IYZICO_BASE_URL decides which
iyzico you reach. Sandbox gives real API calls, real submerchants, real hosted
payment pages and real callbacks, with test cards and no money moving. That is
the default, and IS_TEST_MODE is derived from it rather than carried as its
own flag, so the "Test modu" banner can never disagree with reality.
First-time setup for the demo business:
bun run demo:submerchant # creates its iyzico submerchant, prints the key
# put DEMO_SUBMERCHANT_KEY=... in .env
bun run seed:demo
demo:submerchant runs under Node, not Bun.
The iyzico SDK hangs under Bun (it uses postman-request), so any script
touching it must run under Node. The app is unaffected: next.config.ts marks
iyzipay as a server-external package, so it runs in Node there too.
Each of these is a real requirement for a product, and not for this one. They are named because a gap a reader finds named reads as judgment, and the same gap unexplained reads as an oversight.
bookings_no_overlap excludes on
(organization_id, tstzrange), so one business is deliberately one chair.
Concurrent staff means a resource dimension through the constraint, the
availability rules, the slot engine and the booking flow — a different
product.console.error and the platform's runtime logs are
proportionate./r/<old>requireOwner() and enforced
server-side; a lapsed plan keeps its stored kapora amounts but stops
charging them, so nothing is lost on re-upgradesubMerchantPrice === price: no commission)lib/actions/customer-cancel.integration.test.ts — a customer cancelling:
refunded with a day's notice and kept inside it, nothing cancelled if the
deadline passed while the dialog was open, an unpaid hold let go, another
business's booking refused even with its real id, one refund for two
cancels, and a failed refund that still cancels and says solib/actions/business-identity.integration.test.ts — moving an address:
old addresses lead to the current one through any number of moves, stay
reserved for their business, and can be taken backlib/calendar/ics.test.ts — RFC 5545 escaping, UTC times, and folding at
75 octets without splitting a multi-byte Turkish characterlib/booking/create-booking-core.integration.test.ts,
customer-cancel-core.integration.test.ts,
customer-reschedule-core.integration.test.ts — the shared booking rules
as the assistant calls them: no email without a kapora, a kapora that
changed since the customer agreed, another chat customer's booking refused
even with its real id, and two moves into one slot of which exactly one winslib/assistant/request-auth.integration.test.ts — the endpoint's door:
fail-closed token, a customer ref taken exactly or not at all, an unknown
business and a switched-off one answered alikelib/assistant/mcp-route.integration.test.ts — the endpoint through the
real MCP client: tools listed, a slot found and booked, a refusal returned
as a tool error with its codee2e/ — Playwright against the production build, in CI: a booking on the
free demo; the slot-loading race, with the API stubbed so the previous
day's answer arrives last (it fails if the fix is reverted); the demo login;
not-found pages; security headers; link previews; old addresses; the
calendar file; and a customer cancelling, after which the same time can be
booked againX-Frame-Options: DENY,
frame-ancestors 'none') and carries nosniff, a strict referrer policy
and a Permissions-Policy; a script-src policy would need per-request nonces
and is not attempted.