package db import ( "context" "encoding/json" "fmt" "sort" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) type Repository interface { GetTenantBySlug(ctx context.Context, slug string) (TenantRecord, error) GetTenantByStripeCustomerID(ctx context.Context, customerID string) (TenantRecord, error) EnsureUserIdentity(ctx context.Context, subject string, email string, displayName string) error CreateTenantForUser(ctx context.Context, params CreateTenantForUserParams) (TenantMembershipRecord, error) GetTenantMembershipByUserID(ctx context.Context, userID string) (TenantMembershipRecord, error) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error) ListClassSessionsByTenant(ctx context.Context, tenantID string, from time.Time, limit int) ([]ClassSessionRecord, error) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error) AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error CreateReminderJob(ctx context.Context, params ReminderJobParams) error ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error) MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error CreateNotificationDeliveryLog(ctx context.Context, params NotificationDeliveryLogParams) error GetDashboardMetrics(ctx context.Context, tenantID string, startsAt time.Time, endsAt time.Time) (DashboardMetrics, error) GetSubscriptionSnapshot(ctx context.Context, tenantID string) (BillingSnapshotRecord, error) UpsertSubscriptionSnapshot(ctx context.Context, params BillingSnapshotRecord) error UpdateTenantStripeCustomerID(ctx context.Context, tenantID string, customerID string) error UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error RecordStripeEvent(ctx context.Context, tenantID string, eventID string, eventType string, payload []byte) (bool, error) } type TenantRecord struct { ID string Slug string Name string Preset string Locale string Timezone string PlanCode string SubscriptionStatus string StripeCustomerID *string StripeSubscription *string } type TenantMembershipRecord struct { Tenant TenantRecord UserID string Role string } type CreateTenantForUserParams struct { Subject string Name string Slug string Preset string Locale string Timezone string } type ServiceRecord struct { ID string TenantID string Name string DurationMinutes int BufferBeforeMinutes int BufferAfterMinutes int PriceCents int } type AvailabilityRuleRecord struct { ID string TenantID string StaffID *string DayOfWeek int StartsLocal string EndsLocal string } type ClassSessionRecord struct { ID string TenantID string TemplateID string LocationID *string Title string StartsAt time.Time EndsAt time.Time Capacity int32 } type BookingRecord struct { ID string TenantID string ServiceID *string ClassSessionID *string StaffID *string LocationID *string CustomerName string CustomerEmail string StartsAt time.Time EndsAt time.Time Status string Reference string } type CreateBookingParams struct { TenantID string ServiceID *string ClassSessionID *string StaffID *string LocationID *string BookingMode string CustomerName string CustomerEmail string StartsAt time.Time EndsAt time.Time Status string Reference string Notes string } type CreatedBooking struct { ID string Reference string Status string } type WaitlistEntryParams struct { TenantID string ClassSessionID string CustomerName string CustomerEmail string Position int } type DashboardMetrics struct { BookingsCount int CancellationsCount int UtilizationPercent int } type ReminderJobParams struct { TenantID string BookingID string Channel string ScheduledFor time.Time } type ReminderJobRecord struct { ID string TenantID string TenantName string Locale string Timezone string BookingID string Channel string ScheduledFor time.Time CustomerName string CustomerEmail string Reference string StartsAt time.Time Status string } type NotificationDeliveryLogParams struct { TenantID string ReminderJobID string Channel string Provider string Recipient string Status string ExternalID string ErrorMessage string } type BillingSnapshotRecord struct { TenantID string StripeCustomerID string StripeSubscriptionID string Status string PlanCode string PriceID string CancelAtPeriodEnd bool CurrentPeriodStart *time.Time CurrentPeriodEnd *time.Time PaymentMethodBrand string PaymentMethodLast4 string LastSyncedAt *time.Time } type PGRepository struct { pool *pgxpool.Pool } func NewRepository(pools *Pools) Repository { if pools != nil && pools.App != nil { return &PGRepository{pool: pools.App} } return NewMemoryRepository() } func (r *PGRepository) GetTenantBySlug(ctx context.Context, slug string) (TenantRecord, error) { var record TenantRecord err := r.pool.QueryRow(ctx, ` SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, stripe_customer_id, stripe_subscription_id FROM tenants WHERE slug = $1 `, slug).Scan( &record.ID, &record.Slug, &record.Name, &record.Preset, &record.Locale, &record.Timezone, &record.PlanCode, &record.SubscriptionStatus, &record.StripeCustomerID, &record.StripeSubscription, ) if err != nil { return TenantRecord{}, err } return record, nil } func (r *PGRepository) GetTenantByStripeCustomerID(ctx context.Context, customerID string) (TenantRecord, error) { var record TenantRecord err := r.pool.QueryRow(ctx, ` SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, stripe_customer_id, stripe_subscription_id FROM tenants WHERE stripe_customer_id = $1 `, customerID).Scan( &record.ID, &record.Slug, &record.Name, &record.Preset, &record.Locale, &record.Timezone, &record.PlanCode, &record.SubscriptionStatus, &record.StripeCustomerID, &record.StripeSubscription, ) if err != nil { return TenantRecord{}, err } return record, nil } func (r *PGRepository) EnsureUserIdentity(ctx context.Context, subject string, email string, displayName string) error { _, err := r.pool.Exec(ctx, ` INSERT INTO users (id, neon_subject, email, display_name) VALUES (gen_random_uuid(), $1, COALESCE(NULLIF($2, ''), $1 || '@users.bookra.invalid'), NULLIF($3, '')) ON CONFLICT (neon_subject) DO UPDATE SET email = COALESCE(NULLIF(EXCLUDED.email, ''), users.email), display_name = COALESCE(NULLIF(EXCLUDED.display_name, ''), users.display_name), updated_at = now() `, subject, email, displayName) return err } func (r *PGRepository) CreateTenantForUser(ctx context.Context, params CreateTenantForUserParams) (TenantMembershipRecord, error) { tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{}) if err != nil { return TenantMembershipRecord{}, err } defer tx.Rollback(ctx) var userID string if err := tx.QueryRow(ctx, ` SELECT id::text FROM users WHERE neon_subject = $1 `, params.Subject).Scan(&userID); err != nil { return TenantMembershipRecord{}, err } record := TenantMembershipRecord{UserID: params.Subject, Role: "owner"} if err := tx.QueryRow(ctx, ` INSERT INTO tenants (slug, name, preset, locale, timezone, plan_code, subscription_status) VALUES ($1, $2, $3, $4, $5, 'starter', 'trialing') RETURNING id, slug, name, preset, locale, timezone, plan_code, subscription_status, stripe_customer_id, stripe_subscription_id `, params.Slug, params.Name, params.Preset, params.Locale, params.Timezone).Scan( &record.Tenant.ID, &record.Tenant.Slug, &record.Tenant.Name, &record.Tenant.Preset, &record.Tenant.Locale, &record.Tenant.Timezone, &record.Tenant.PlanCode, &record.Tenant.SubscriptionStatus, &record.Tenant.StripeCustomerID, &record.Tenant.StripeSubscription, ); err != nil { return TenantMembershipRecord{}, err } if _, err := tx.Exec(ctx, ` INSERT INTO tenant_users (tenant_id, user_id, role) VALUES ($1, $2::uuid, 'owner') `, record.Tenant.ID, userID); err != nil { return TenantMembershipRecord{}, err } if _, err := tx.Exec(ctx, ` INSERT INTO locations (tenant_id, name, timezone) VALUES ($1, 'Main location', $2) `, record.Tenant.ID, params.Timezone); err != nil { return TenantMembershipRecord{}, err } if err := tx.Commit(ctx); err != nil { return TenantMembershipRecord{}, err } return record, nil } func (r *PGRepository) GetTenantMembershipByUserID(ctx context.Context, userID string) (TenantMembershipRecord, error) { var record TenantMembershipRecord err := r.pool.QueryRow(ctx, ` SELECT t.id, t.slug, t.name, t.preset, t.locale, t.timezone, t.plan_code, t.subscription_status, t.stripe_customer_id, t.stripe_subscription_id, u.neon_subject, tu.role FROM tenant_users tu INNER JOIN users u ON u.id = tu.user_id INNER JOIN tenants t ON t.id = tu.tenant_id WHERE u.neon_subject = $1 OR tu.user_id::text = $1 ORDER BY tu.created_at ASC LIMIT 1 `, userID).Scan( &record.Tenant.ID, &record.Tenant.Slug, &record.Tenant.Name, &record.Tenant.Preset, &record.Tenant.Locale, &record.Tenant.Timezone, &record.Tenant.PlanCode, &record.Tenant.SubscriptionStatus, &record.Tenant.StripeCustomerID, &record.Tenant.StripeSubscription, &record.UserID, &record.Role, ) if err != nil { return TenantMembershipRecord{}, err } return record, nil } func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) { rows, err := r.pool.Query(ctx, ` SELECT id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents FROM services WHERE tenant_id = $1 ORDER BY created_at ASC `, tenantID) if err != nil { return nil, err } defer rows.Close() var records []ServiceRecord for rows.Next() { var record ServiceRecord if err := rows.Scan( &record.ID, &record.TenantID, &record.Name, &record.DurationMinutes, &record.BufferBeforeMinutes, &record.BufferAfterMinutes, &record.PriceCents, ); err != nil { return nil, err } records = append(records, record) } return records, rows.Err() } func (r *PGRepository) ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error) { rows, err := r.pool.Query(ctx, ` SELECT id, tenant_id, staff_id, day_of_week, starts_local, ends_local FROM availability_rules WHERE tenant_id = $1 ORDER BY day_of_week ASC, starts_local ASC `, tenantID) if err != nil { return nil, err } defer rows.Close() var records []AvailabilityRuleRecord for rows.Next() { var record AvailabilityRuleRecord if err := rows.Scan( &record.ID, &record.TenantID, &record.StaffID, &record.DayOfWeek, &record.StartsLocal, &record.EndsLocal, ); err != nil { return nil, err } records = append(records, record) } return records, rows.Err() } func (r *PGRepository) ListClassSessionsByTenant(ctx context.Context, tenantID string, from time.Time, limit int) ([]ClassSessionRecord, error) { rows, err := r.pool.Query(ctx, ` SELECT cs.id, cs.tenant_id, cs.template_id, cs.location_id, ct.title, cs.starts_at, cs.ends_at, cs.capacity FROM class_sessions cs INNER JOIN class_templates ct ON ct.id = cs.template_id WHERE cs.tenant_id = $1 AND cs.starts_at >= $2 ORDER BY cs.starts_at ASC LIMIT $3 `, tenantID, from, limit) if err != nil { return nil, err } defer rows.Close() var records []ClassSessionRecord for rows.Next() { var record ClassSessionRecord if err := rows.Scan( &record.ID, &record.TenantID, &record.TemplateID, &record.LocationID, &record.Title, &record.StartsAt, &record.EndsAt, &record.Capacity, ); err != nil { return nil, err } records = append(records, record) } return records, rows.Err() } func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) { rows, err := r.pool.Query(ctx, ` SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id, customer_name, customer_email, starts_at, ends_at, status, reference FROM bookings WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2 ORDER BY starts_at ASC `, tenantID, from, to) if err != nil { return nil, err } defer rows.Close() var records []BookingRecord for rows.Next() { var record BookingRecord if err := rows.Scan( &record.ID, &record.TenantID, &record.ServiceID, &record.ClassSessionID, &record.StaffID, &record.LocationID, &record.CustomerName, &record.CustomerEmail, &record.StartsAt, &record.EndsAt, &record.Status, &record.Reference, ); err != nil { return nil, err } records = append(records, record) } return records, rows.Err() } func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error) { var created CreatedBooking err := r.pool.QueryRow(ctx, ` INSERT INTO bookings ( tenant_id, service_id, class_session_id, staff_id, location_id, booking_mode, customer_name, customer_email, starts_at, ends_at, status, reference, notes ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id, reference, status `, params.TenantID, params.ServiceID, params.ClassSessionID, params.StaffID, params.LocationID, params.BookingMode, params.CustomerName, params.CustomerEmail, params.StartsAt, params.EndsAt, params.Status, params.Reference, params.Notes, ).Scan(&created.ID, &created.Reference, &created.Status) return created, err } func (r *PGRepository) AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error { _, err := r.pool.Exec(ctx, ` INSERT INTO waitlist_entries (tenant_id, class_session_id, customer_name, customer_email, position) VALUES ($1,$2,$3,$4,$5) `, params.TenantID, params.ClassSessionID, params.CustomerName, params.CustomerEmail, params.Position) return err } func (r *PGRepository) CreateReminderJob(ctx context.Context, params ReminderJobParams) error { _, err := r.pool.Exec(ctx, ` INSERT INTO reminder_jobs (tenant_id, booking_id, channel, scheduled_for) VALUES ($1, $2, $3, $4) `, params.TenantID, params.BookingID, params.Channel, params.ScheduledFor) return err } func (r *PGRepository) ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error) { rows, err := r.pool.Query(ctx, ` SELECT rj.id, rj.tenant_id, t.name, t.locale, t.timezone, rj.booking_id, rj.channel, rj.scheduled_for, b.customer_name, b.customer_email, b.reference, b.starts_at, rj.status FROM reminder_jobs rj INNER JOIN bookings b ON b.id = rj.booking_id INNER JOIN tenants t ON t.id = rj.tenant_id WHERE rj.status = 'pending' AND rj.scheduled_for <= $1 ORDER BY rj.scheduled_for ASC LIMIT $2 `, dueBefore, limit) if err != nil { return nil, err } defer rows.Close() var records []ReminderJobRecord for rows.Next() { var record ReminderJobRecord if err := rows.Scan( &record.ID, &record.TenantID, &record.TenantName, &record.Locale, &record.Timezone, &record.BookingID, &record.Channel, &record.ScheduledFor, &record.CustomerName, &record.CustomerEmail, &record.Reference, &record.StartsAt, &record.Status, ); err != nil { return nil, err } records = append(records, record) } return records, rows.Err() } func (r *PGRepository) MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error { _, err := r.pool.Exec(ctx, ` UPDATE reminder_jobs SET status = $2, dispatched_at = $3 WHERE id = $1 `, reminderJobID, status, dispatchedAt) return err } func (r *PGRepository) CreateNotificationDeliveryLog(ctx context.Context, params NotificationDeliveryLogParams) error { _, err := r.pool.Exec(ctx, ` INSERT INTO notification_delivery_logs ( tenant_id, reminder_job_id, channel, provider, recipient, delivery_status, external_id, error_message ) VALUES ($1, $2, $3, $4, $5, $6, $7, NULLIF($8, '')) `, params.TenantID, params.ReminderJobID, params.Channel, params.Provider, params.Recipient, params.Status, params.ExternalID, params.ErrorMessage) return err } func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string, startsAt time.Time, endsAt time.Time) (DashboardMetrics, error) { var metrics DashboardMetrics err := r.pool.QueryRow(ctx, ` SELECT COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3) AS bookings_count, COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'cancelled') AS cancellations_count, COALESCE( ROUND( 100.0 * COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'confirmed') / NULLIF(COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3), 0) ), 0 )::integer AS utilization_percent FROM bookings WHERE tenant_id = $1 `, tenantID, startsAt, endsAt).Scan( &metrics.BookingsCount, &metrics.CancellationsCount, &metrics.UtilizationPercent, ) return metrics, err } func (r *PGRepository) GetSubscriptionSnapshot(ctx context.Context, tenantID string) (BillingSnapshotRecord, error) { var record BillingSnapshotRecord err := r.pool.QueryRow(ctx, ` SELECT tenant_id, stripe_customer_id, stripe_subscription_id, status, plan_code, price_id, cancel_at_period_end, current_period_start, current_period_end, payment_method_brand, payment_method_last4, last_synced_at FROM billing_snapshots WHERE tenant_id = $1 `, tenantID).Scan( &record.TenantID, &record.StripeCustomerID, &record.StripeSubscriptionID, &record.Status, &record.PlanCode, &record.PriceID, &record.CancelAtPeriodEnd, &record.CurrentPeriodStart, &record.CurrentPeriodEnd, &record.PaymentMethodBrand, &record.PaymentMethodLast4, &record.LastSyncedAt, ) return record, err } func (r *PGRepository) UpsertSubscriptionSnapshot(ctx context.Context, params BillingSnapshotRecord) error { _, err := r.pool.Exec(ctx, ` INSERT INTO billing_snapshots ( tenant_id, stripe_customer_id, stripe_subscription_id, status, plan_code, price_id, cancel_at_period_end, current_period_start, current_period_end, payment_method_brand, payment_method_last4, last_synced_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT (tenant_id) DO UPDATE SET stripe_customer_id = EXCLUDED.stripe_customer_id, stripe_subscription_id = EXCLUDED.stripe_subscription_id, status = EXCLUDED.status, plan_code = EXCLUDED.plan_code, price_id = EXCLUDED.price_id, cancel_at_period_end = EXCLUDED.cancel_at_period_end, current_period_start = EXCLUDED.current_period_start, current_period_end = EXCLUDED.current_period_end, payment_method_brand = EXCLUDED.payment_method_brand, payment_method_last4 = EXCLUDED.payment_method_last4, last_synced_at = EXCLUDED.last_synced_at, updated_at = now() `, params.TenantID, params.StripeCustomerID, params.StripeSubscriptionID, params.Status, params.PlanCode, params.PriceID, params.CancelAtPeriodEnd, params.CurrentPeriodStart, params.CurrentPeriodEnd, params.PaymentMethodBrand, params.PaymentMethodLast4, params.LastSyncedAt) return err } func (r *PGRepository) UpdateTenantStripeCustomerID(ctx context.Context, tenantID string, customerID string) error { _, err := r.pool.Exec(ctx, ` UPDATE tenants SET stripe_customer_id = $2, updated_at = now() WHERE id = $1 `, tenantID, customerID) return err } func (r *PGRepository) UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error { _, err := r.pool.Exec(ctx, ` UPDATE tenants SET plan_code = $2, subscription_status = $3, stripe_subscription_id = $4, updated_at = now() WHERE id = $1 `, tenantID, planCode, subscriptionStatus, subscriptionID) return err } func (r *PGRepository) RecordStripeEvent(ctx context.Context, tenantID string, eventID string, eventType string, payload []byte) (bool, error) { result, err := r.pool.Exec(ctx, ` INSERT INTO subscription_events (tenant_id, stripe_event_id, event_type, payload, processed_at) VALUES ($1, $2, $3, $4::jsonb, now()) ON CONFLICT (stripe_event_id) DO NOTHING `, tenantID, eventID, eventType, payload) if err != nil { return false, err } return result.RowsAffected() == 1, nil } type MemoryRepository struct { tenant TenantRecord membership TenantMembershipRecord services []ServiceRecord rules []AvailabilityRuleRecord classSessions []ClassSessionRecord bookings []BookingRecord waitlistSize int reminderJobs []ReminderJobRecord deliveryLogs []NotificationDeliveryLogParams billingSnapshot BillingSnapshotRecord recordedEvents map[string]struct{} } func NewMemoryRepository() *MemoryRepository { tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148" locationID := "659f1cc0-a850-46d6-b3b8-cb15d55d8daf" staffID := "6936c444-c7d0-4a7d-b596-a9b72d2f4fc0" serviceID := "d5d76a61-3d49-467c-8dd4-bf61ee754e39" templateID := "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7" sessionID := "4bf74c12-44dd-45ca-86bb-b104f16f2435" now := time.Now().UTC() return &MemoryRepository{ tenant: TenantRecord{ ID: tenantID, Slug: "studio-atelier", Name: "Studio Atelier", Preset: "studio", Locale: "cs", Timezone: "Europe/Prague", PlanCode: "growth", SubscriptionStatus: "active", StripeCustomerID: stringPtr("cus_demo_bookra"), }, membership: TenantMembershipRecord{ Tenant: TenantRecord{ ID: tenantID, Slug: "studio-atelier", Name: "Studio Atelier", Preset: "studio", Locale: "cs", Timezone: "Europe/Prague", PlanCode: "growth", SubscriptionStatus: "active", StripeCustomerID: stringPtr("cus_demo_bookra"), }, UserID: "demo-owner", Role: "owner", }, services: []ServiceRecord{ { ID: serviceID, TenantID: tenantID, Name: "Signature treatment", DurationMinutes: 60, BufferBeforeMinutes: 0, BufferAfterMinutes: 15, PriceCents: 120000, }, }, rules: []AvailabilityRuleRecord{ {ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 1, StartsLocal: "09:00:00", EndsLocal: "17:00:00"}, {ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 2, StartsLocal: "09:00:00", EndsLocal: "17:00:00"}, {ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 3, StartsLocal: "09:00:00", EndsLocal: "17:00:00"}, {ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 4, StartsLocal: "09:00:00", EndsLocal: "17:00:00"}, {ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 5, StartsLocal: "09:00:00", EndsLocal: "17:00:00"}, }, classSessions: []ClassSessionRecord{ { ID: sessionID, TenantID: tenantID, TemplateID: templateID, LocationID: &locationID, Title: "Small group mobility class", StartsAt: now.Add(48 * time.Hour), EndsAt: now.Add(49 * time.Hour), Capacity: 4, }, }, bookings: []BookingRecord{}, reminderJobs: []ReminderJobRecord{}, deliveryLogs: []NotificationDeliveryLogParams{}, billingSnapshot: BillingSnapshotRecord{ TenantID: tenantID, StripeCustomerID: "cus_demo_bookra", StripeSubscriptionID: "", Status: "none", PlanCode: "growth", PriceID: "", }, recordedEvents: map[string]struct{}{}, } } func (r *MemoryRepository) GetTenantBySlug(_ context.Context, slug string) (TenantRecord, error) { if slug != r.tenant.Slug { return TenantRecord{}, pgx.ErrNoRows } return r.tenant, nil } func (r *MemoryRepository) GetTenantByStripeCustomerID(_ context.Context, customerID string) (TenantRecord, error) { if r.billingSnapshot.StripeCustomerID == customerID { return r.tenant, nil } return TenantRecord{}, pgx.ErrNoRows } func (r *MemoryRepository) EnsureUserIdentity(_ context.Context, subject string, email string, displayName string) error { if strings.TrimSpace(subject) == "" { return nil } r.membership.UserID = subject _ = email _ = displayName return nil } func (r *MemoryRepository) CreateTenantForUser(_ context.Context, params CreateTenantForUserParams) (TenantMembershipRecord, error) { tenantID := uuid.NewString() r.tenant = TenantRecord{ ID: tenantID, Slug: params.Slug, Name: params.Name, Preset: params.Preset, Locale: params.Locale, Timezone: params.Timezone, PlanCode: "starter", SubscriptionStatus: "trialing", } r.membership = TenantMembershipRecord{ Tenant: r.tenant, UserID: params.Subject, Role: "owner", } r.services = nil r.rules = nil r.classSessions = nil r.bookings = nil r.reminderJobs = nil return r.membership, nil } func (r *MemoryRepository) GetTenantMembershipByUserID(_ context.Context, userID string) (TenantMembershipRecord, error) { if userID == "" || userID == "demo-owner" { return r.membership, nil } if userID == r.membership.UserID { return r.membership, nil } return TenantMembershipRecord{}, pgx.ErrNoRows } func (r *MemoryRepository) ListServicesByTenant(_ context.Context, tenantID string) ([]ServiceRecord, error) { if tenantID != r.tenant.ID { return nil, nil } return append([]ServiceRecord(nil), r.services...), nil } func (r *MemoryRepository) ListAvailabilityRulesByTenant(_ context.Context, tenantID string) ([]AvailabilityRuleRecord, error) { if tenantID != r.tenant.ID { return nil, nil } return append([]AvailabilityRuleRecord(nil), r.rules...), nil } func (r *MemoryRepository) ListClassSessionsByTenant(_ context.Context, tenantID string, from time.Time, limit int) ([]ClassSessionRecord, error) { if tenantID != r.tenant.ID { return nil, nil } var out []ClassSessionRecord for _, session := range r.classSessions { if session.StartsAt.Before(from) { continue } out = append(out, session) } sort.Slice(out, func(i, j int) bool { return out[i].StartsAt.Before(out[j].StartsAt) }) if len(out) > limit { out = out[:limit] } return out, nil } func (r *MemoryRepository) ListBookingsByTenantBetween(_ context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) { var out []BookingRecord for _, booking := range r.bookings { if booking.TenantID != tenantID { continue } if booking.StartsAt.Before(to) && booking.EndsAt.After(from) { out = append(out, booking) } } sort.Slice(out, func(i, j int) bool { return out[i].StartsAt.Before(out[j].StartsAt) }) return out, nil } func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBookingParams) (CreatedBooking, error) { created := CreatedBooking{ ID: uuid.NewString(), Reference: params.Reference, Status: params.Status, } r.bookings = append(r.bookings, BookingRecord{ ID: created.ID, TenantID: params.TenantID, ServiceID: params.ServiceID, ClassSessionID: params.ClassSessionID, StaffID: params.StaffID, LocationID: params.LocationID, CustomerName: params.CustomerName, CustomerEmail: params.CustomerEmail, StartsAt: params.StartsAt, EndsAt: params.EndsAt, Status: params.Status, Reference: params.Reference, }) return created, nil } func (r *MemoryRepository) AppendWaitlistEntry(_ context.Context, _ WaitlistEntryParams) error { r.waitlistSize++ return nil } func (r *MemoryRepository) CreateReminderJob(_ context.Context, params ReminderJobParams) error { reference := params.BookingID startsAt := time.Now().UTC() customerName := "Demo Customer" customerEmail := "customer@bookra.dev" for _, booking := range r.bookings { if booking.ID == params.BookingID { reference = booking.Reference startsAt = booking.StartsAt customerName = booking.CustomerName customerEmail = booking.CustomerEmail break } } r.reminderJobs = append(r.reminderJobs, ReminderJobRecord{ ID: uuid.NewString(), TenantID: params.TenantID, TenantName: r.tenant.Name, Locale: r.tenant.Locale, Timezone: r.tenant.Timezone, BookingID: params.BookingID, Channel: params.Channel, ScheduledFor: params.ScheduledFor, CustomerName: customerName, CustomerEmail: customerEmail, Reference: reference, StartsAt: startsAt, Status: "pending", }) return nil } func (r *MemoryRepository) ListDueReminderJobs(_ context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error) { var out []ReminderJobRecord for _, job := range r.reminderJobs { if job.Status == "pending" && !job.ScheduledFor.After(dueBefore) { out = append(out, job) } } sort.Slice(out, func(i, j int) bool { return out[i].ScheduledFor.Before(out[j].ScheduledFor) }) if len(out) > limit { out = out[:limit] } return out, nil } func (r *MemoryRepository) MarkReminderJobDispatched(_ context.Context, reminderJobID string, status string, _ time.Time) error { for index := range r.reminderJobs { if r.reminderJobs[index].ID == reminderJobID { r.reminderJobs[index].Status = status return nil } } return pgx.ErrNoRows } func (r *MemoryRepository) CreateNotificationDeliveryLog(_ context.Context, params NotificationDeliveryLogParams) error { r.deliveryLogs = append(r.deliveryLogs, params) return nil } func (r *MemoryRepository) GetDashboardMetrics(_ context.Context, tenantID string, startsAt time.Time, endsAt time.Time) (DashboardMetrics, error) { metrics := DashboardMetrics{} for _, booking := range r.bookings { if booking.TenantID != tenantID { continue } if booking.StartsAt.Before(startsAt) || !booking.StartsAt.Before(endsAt) { continue } metrics.BookingsCount++ if booking.Status == "cancelled" { metrics.CancellationsCount++ } if booking.Status == "confirmed" { metrics.UtilizationPercent++ } } if metrics.BookingsCount == 0 { metrics.UtilizationPercent = 0 } else { metrics.UtilizationPercent = int(float64(metrics.UtilizationPercent) / float64(metrics.BookingsCount) * 100) } return metrics, nil } func (r *MemoryRepository) GetSubscriptionSnapshot(_ context.Context, tenantID string) (BillingSnapshotRecord, error) { if tenantID != r.tenant.ID { return BillingSnapshotRecord{}, pgx.ErrNoRows } return r.billingSnapshot, nil } func (r *MemoryRepository) UpsertSubscriptionSnapshot(_ context.Context, params BillingSnapshotRecord) error { r.billingSnapshot = params return nil } func (r *MemoryRepository) UpdateTenantStripeCustomerID(_ context.Context, tenantID string, customerID string) error { if tenantID != r.tenant.ID { return pgx.ErrNoRows } r.tenant.StripeCustomerID = &customerID r.membership.Tenant.StripeCustomerID = &customerID r.billingSnapshot.StripeCustomerID = customerID return nil } func (r *MemoryRepository) UpdateTenantBillingState(_ context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error { if tenantID != r.tenant.ID { return pgx.ErrNoRows } r.tenant.PlanCode = planCode r.tenant.SubscriptionStatus = subscriptionStatus r.membership.Tenant.PlanCode = planCode r.membership.Tenant.SubscriptionStatus = subscriptionStatus r.billingSnapshot.PlanCode = planCode r.billingSnapshot.Status = subscriptionStatus r.billingSnapshot.StripeSubscriptionID = subscriptionID return nil } func (r *MemoryRepository) RecordStripeEvent(_ context.Context, _ string, eventID string, _ string, payload []byte) (bool, error) { if _, exists := r.recordedEvents[eventID]; exists { return false, nil } if len(payload) > 0 { var raw json.RawMessage if err := json.Unmarshal(payload, &raw); err != nil { return false, err } } r.recordedEvents[eventID] = struct{}{} return true, nil } func Reference(prefix string, at time.Time) string { return fmt.Sprintf("%s-%s", prefix, at.UTC().Format("20060102150405")) } func stringPtr(value string) *string { return &value }