mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-04 04:22:59 +00:00
1071 lines
32 KiB
Go
1071 lines
32 KiB
Go
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
|
|
}
|