Files
buildfor_life_budget/src/lib/server/db/schema.ts
T
grabowski 283f0d4dd1
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 32s
Add invoice linking on expenses: optional FK, dropdown on add form, clickable chip
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 15:46:20 +07:00

1356 lines
49 KiB
TypeScript

import { relations, sql } from 'drizzle-orm';
import {
pgTable,
pgEnum,
text,
boolean,
timestamp,
uniqueIndex,
uuid,
numeric,
date,
index,
primaryKey,
integer,
varchar
} from 'drizzle-orm/pg-core';
// ── Enums ──────────────────────────────────────────────
export const companyRoleEnum = pgEnum('company_role', ['admin', 'manager', 'user', 'viewer', 'hr', 'accountant']);
export const expenseStatusEnum = pgEnum('expense_status', ['pending', 'approved', 'rejected']);
// ── Users ──────────────────────────────────────────────
export const users = pgTable(
'users',
{
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
username: text('username').unique(),
displayName: text('display_name'),
passwordHash: text('password_hash'),
oidcProvider: text('oidc_provider'),
oidcSubject: text('oidc_subject'),
isSystemAdmin: boolean('is_system_admin').notNull().default(false),
disabledAt: timestamp('disabled_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('users_oidc_idx')
.on(table.oidcProvider, table.oidcSubject)
.where(sql`${table.oidcProvider} IS NOT NULL AND ${table.oidcSubject} IS NOT NULL`)
]
);
// ── Sessions ───────────────────────────────────────────
export const sessions = pgTable('sessions', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull()
});
// ── Companies ──────────────────────────────────────────
export const companies = pgTable('companies', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
description: text('description'),
totalBudget: numeric('total_budget', { precision: 15, scale: 2 }).notNull().default('0'),
currency: text('currency').notNull().default('THB'),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
});
// ── Company Members ────────────────────────────────────
export const companyMembers = pgTable(
'company_members',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
roles: companyRoleEnum('roles').array().notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('company_members_user_company_idx').on(table.userId, table.companyId)]
);
// ── Projects ───────────────────────────────────────────
export const projects = pgTable('projects', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
allocatedBudget: numeric('allocated_budget', { precision: 15, scale: 2 }).notNull().default('0'),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
});
// ── Categories ─────────────────────────────────────────
export const categories = pgTable(
'categories',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
color: text('color'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('categories_company_name_idx').on(table.companyId, table.name)]
);
// ── Expenses ───────────────────────────────────────────
export const expenses = pgTable(
'expenses',
{
id: uuid('id').primaryKey().defaultRandom(),
projectId: uuid('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
partyId: uuid('party_id').references((): any => parties.id, { onDelete: 'set null' }),
accountId: uuid('account_id').references((): any => companyAccounts.id, {
onDelete: 'set null'
}),
invoiceId: uuid('invoice_id').references((): any => invoices.id, {
onDelete: 'set null'
}),
submittedBy: text('submitted_by')
.notNull()
.references(() => users.id),
approvedBy: text('approved_by').references(() => users.id),
title: text('title').notNull(),
description: text('description'),
amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
currency: text('currency').notNull(),
receiptUrl: text('receipt_url'),
expenseDate: date('expense_date').notNull(),
status: expenseStatusEnum('status').notNull().default('pending'),
reviewedAt: timestamp('reviewed_at', { withTimezone: true }),
rejectionReason: text('rejection_reason'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
index('expenses_project_status_idx').on(table.projectId, table.status),
index('expenses_submitted_by_idx').on(table.submittedBy),
index('expenses_date_idx').on(table.expenseDate)
]
);
// ── Tags ───────────────────────────────────────────────
export const tags = pgTable(
'tags',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
name: text('name').notNull()
},
(table) => [uniqueIndex('tags_company_name_idx').on(table.companyId, table.name)]
);
export const expenseTags = pgTable(
'expense_tags',
{
expenseId: uuid('expense_id')
.notNull()
.references(() => expenses.id, { onDelete: 'cascade' }),
tagId: uuid('tag_id')
.notNull()
.references(() => tags.id, { onDelete: 'cascade' })
},
(table) => [primaryKey({ columns: [table.expenseId, table.tagId] })]
);
// ── Budget Allocations ─────────────────────────────────
export const budgetAllocations = pgTable('budget_allocations', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
projectId: uuid('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
allocatedBy: text('allocated_by')
.notNull()
.references(() => users.id),
note: text('note'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
});
// ── Employees ──────────────────────────────────────────
export const employees = pgTable('employees', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
firstName: text('first_name').notNull(),
lastName: text('last_name').notNull(),
displayName: text('display_name'),
email: text('email'),
phone: text('phone'),
employeeCode: text('employee_code'),
position: text('position'),
department: text('department'),
hireDate: date('hire_date').notNull(),
terminationDate: date('termination_date'),
nationalId: text('national_id'),
taxId: text('tax_id'),
bankName: text('bank_name'),
bankAccount: text('bank_account'),
// Personal
dateOfBirth: date('date_of_birth'),
gender: text('gender'),
nationality: text('nationality'),
maritalStatus: text('marital_status'),
// Address (Thai-specific)
addressLine1: text('address_line_1'),
addressLine2: text('address_line_2'),
subdistrict: text('subdistrict'),
district: text('district'),
province: text('province'),
postalCode: text('postal_code'),
country: text('country'),
// Emergency contact
emergencyContactName: text('emergency_contact_name'),
emergencyContactPhone: text('emergency_contact_phone'),
emergencyContactRelationship: text('emergency_contact_relationship'),
isActive: boolean('is_active').notNull().default(true),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
});
export const salaryHistory = pgTable('salary_history', {
id: uuid('id').primaryKey().defaultRandom(),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'cascade' }),
effectiveFrom: date('effective_from').notNull(),
grossSalary: numeric('gross_salary', { precision: 15, scale: 2 }).notNull(),
currency: text('currency').notNull().default('THB'),
note: text('note'),
setBy: text('set_by')
.notNull()
.references(() => users.id),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
});
// ── Public Holidays ────────────────────────────────────
export const publicHolidays = pgTable(
'public_holidays',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
date: date('date').notNull(),
name: text('name').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('public_holidays_company_date_idx').on(table.companyId, table.date)]
);
// ── Leave ──────────────────────────────────────────────
export const leaveStatusEnum = pgEnum('leave_status', ['pending', 'approved', 'rejected']);
export const leaveTypes = pgTable(
'leave_types',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
defaultDaysPerYear: numeric('default_days_per_year', { precision: 5, scale: 2 }),
isPaid: boolean('is_paid').notNull().default(true),
color: text('color'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('leave_types_company_name_idx').on(table.companyId, table.name)]
);
export const leaveBalances = pgTable(
'leave_balances',
{
id: uuid('id').primaryKey().defaultRandom(),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'cascade' }),
leaveTypeId: uuid('leave_type_id')
.notNull()
.references(() => leaveTypes.id, { onDelete: 'cascade' }),
year: numeric('year', { precision: 4, scale: 0 }).notNull(),
allocated: numeric('allocated', { precision: 5, scale: 2 }).notNull().default('0'),
used: numeric('used', { precision: 5, scale: 2 }).notNull().default('0'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('leave_balances_emp_type_year_idx').on(
table.employeeId,
table.leaveTypeId,
table.year
)
]
);
export const leaveRequests = pgTable('leave_requests', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'cascade' }),
leaveTypeId: uuid('leave_type_id')
.notNull()
.references(() => leaveTypes.id, { onDelete: 'cascade' }),
startDate: date('start_date').notNull(),
endDate: date('end_date').notNull(),
days: numeric('days', { precision: 5, scale: 2 }).notNull(),
reason: text('reason'),
status: leaveStatusEnum('status').notNull().default('pending'),
reviewedBy: text('reviewed_by').references(() => users.id),
reviewedAt: timestamp('reviewed_at', { withTimezone: true }),
rejectionReason: text('rejection_reason'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
});
// ── Payslips ───────────────────────────────────────────
export const payslipStatusEnum = pgEnum('payslip_status', ['draft', 'finalized', 'paid']);
export const payslipLineTypeEnum = pgEnum('payslip_line_type', ['earning', 'deduction']);
export const payslips = pgTable(
'payslips',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'cascade' }),
periodYear: numeric('period_year', { precision: 4, scale: 0 }).notNull(),
periodMonth: numeric('period_month', { precision: 2, scale: 0 }).notNull(),
grossSalary: numeric('gross_salary', { precision: 15, scale: 2 }).notNull(),
overtime: numeric('overtime', { precision: 15, scale: 2 }).notNull().default('0'),
bonus: numeric('bonus', { precision: 15, scale: 2 }).notNull().default('0'),
otherEarnings: numeric('other_earnings', { precision: 15, scale: 2 }).notNull().default('0'),
ssoEmployee: numeric('sso_employee', { precision: 15, scale: 2 }).notNull().default('0'),
ssoEmployer: numeric('sso_employer', { precision: 15, scale: 2 }).notNull().default('0'),
incomeTax: numeric('income_tax', { precision: 15, scale: 2 }).notNull().default('0'),
otherDeductions: numeric('other_deductions', { precision: 15, scale: 2 }).notNull().default('0'),
netPay: numeric('net_pay', { precision: 15, scale: 2 }).notNull(),
currency: text('currency').notNull().default('THB'),
status: payslipStatusEnum('status').notNull().default('draft'),
finalizedAt: timestamp('finalized_at', { withTimezone: true }),
paidAt: timestamp('paid_at', { withTimezone: true }),
generatedBy: text('generated_by')
.notNull()
.references(() => users.id),
pdfPath: text('pdf_path'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('payslips_emp_period_idx').on(table.employeeId, table.periodYear, table.periodMonth)
]
);
export const payslipLineItems = pgTable('payslip_line_items', {
id: uuid('id').primaryKey().defaultRandom(),
payslipId: uuid('payslip_id')
.notNull()
.references(() => payslips.id, { onDelete: 'cascade' }),
type: payslipLineTypeEnum('type').notNull(),
label: text('label').notNull(),
amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
isStatutory: boolean('is_statutory').notNull().default(false)
});
// ── Parties (Customers / Suppliers) ────────────────────
export const partyTypeEnum = pgEnum('party_type', ['customer', 'supplier', 'both']);
export const parties = pgTable('parties', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
type: partyTypeEnum('type').notNull().default('customer'),
name: text('name').notNull(),
contactPerson: text('contact_person'),
email: text('email'),
phone: text('phone'),
website: text('website'),
taxId: text('tax_id'),
addressLine1: text('address_line_1'),
addressLine2: text('address_line_2'),
city: text('city'),
postalCode: text('postal_code'),
country: text('country'),
paymentTerms: text('payment_terms'),
notes: text('notes'),
isActive: boolean('is_active').notNull().default(true),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
});
// ── Invoices ───────────────────────────────────────────
export const invoiceDirectionEnum = pgEnum('invoice_direction', ['incoming', 'outgoing']);
export const invoiceStatusEnum = pgEnum('invoice_status', [
'draft',
'sent',
'paid',
'overdue',
'cancelled',
'voided'
]);
export const invoices = pgTable(
'invoices',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
partyId: uuid('party_id')
.notNull()
.references(() => parties.id, { onDelete: 'restrict' }),
direction: invoiceDirectionEnum('direction').notNull(),
invoiceNumber: text('invoice_number').notNull(),
issueDate: date('issue_date').notNull(),
dueDate: date('due_date'),
subtotal: numeric('subtotal', { precision: 15, scale: 2 }).notNull(),
vat: numeric('vat', { precision: 15, scale: 2 }).notNull().default('0'),
total: numeric('total', { precision: 15, scale: 2 }).notNull(),
currency: text('currency').notNull().default('THB'),
status: invoiceStatusEnum('status').notNull().default('draft'),
expenseId: uuid('expense_id').references(() => expenses.id, { onDelete: 'set null' }),
paymentAccountId: uuid('payment_account_id').references((): any => companyAccounts.id, {
onDelete: 'set null'
}),
notes: text('notes'),
pdfPath: text('pdf_path'),
voidedAt: timestamp('voided_at', { withTimezone: true }),
voidReason: text('void_reason'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('invoices_company_direction_number_idx').on(
table.companyId,
table.direction,
table.invoiceNumber
)
]
);
export const invoiceLineItems = pgTable('invoice_line_items', {
id: uuid('id').primaryKey().defaultRandom(),
invoiceId: uuid('invoice_id')
.notNull()
.references(() => invoices.id, { onDelete: 'cascade' }),
description: text('description').notNull(),
quantity: numeric('quantity', { precision: 10, scale: 2 }).notNull().default('1'),
unitPrice: numeric('unit_price', { precision: 15, scale: 2 }).notNull(),
total: numeric('total', { precision: 15, scale: 2 }).notNull()
});
// ── External Integrations ──────────────────────────────
export const integrationProviderEnum = pgEnum('integration_provider', [
'kasikorn_kbiz',
'etherfi',
'manual'
]);
export const txDirectionEnum = pgEnum('tx_direction', ['credit', 'debit']);
export const externalAccounts = pgTable('external_accounts', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
provider: integrationProviderEnum('provider').notNull(),
displayName: text('display_name').notNull(),
accountIdentifier: text('account_identifier').notNull(),
credentialsEncrypted: text('credentials_encrypted'),
isActive: boolean('is_active').notNull().default(true),
lastSyncedAt: timestamp('last_synced_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
});
export const externalTransactions = pgTable(
'external_transactions',
{
id: uuid('id').primaryKey().defaultRandom(),
accountId: uuid('account_id')
.notNull()
.references(() => externalAccounts.id, { onDelete: 'cascade' }),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
externalId: text('external_id').notNull(),
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
currency: text('currency').notNull(),
direction: txDirectionEnum('direction').notNull(),
description: text('description'),
counterparty: text('counterparty'),
matchedExpenseId: uuid('matched_expense_id').references(() => expenses.id, {
onDelete: 'set null'
}),
rawPayload: text('raw_payload'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('external_tx_account_extid_idx').on(table.accountId, table.externalId)]
);
// ── Package Tracking ───────────────────────────────────
export const packageDirectionEnum = pgEnum('package_direction', ['incoming', 'outgoing']);
export const packageStatusEnum = pgEnum('package_status', [
'pending',
'in_transit',
'out_for_delivery',
'delivered',
'exception',
'returned',
'cancelled'
]);
export const carrierEnum = pgEnum('carrier', [
'ups',
'fedex',
'dhl',
'usps',
'flash_express',
'kerry_th',
'jnt_express',
'thailand_post',
'other'
]);
export const packages = pgTable(
'packages',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
direction: packageDirectionEnum('direction').notNull(),
carrier: carrierEnum('carrier').notNull(),
trackingNumber: text('tracking_number').notNull(),
status: packageStatusEnum('status').notNull().default('pending'),
currentLocation: text('current_location'),
description: text('description'),
recipientName: text('recipient_name'),
estimatedDelivery: date('estimated_delivery'),
shippedAt: timestamp('shipped_at', { withTimezone: true }),
deliveredAt: timestamp('delivered_at', { withTimezone: true }),
weightKg: numeric('weight_kg', { precision: 10, scale: 3 }),
shippingCost: numeric('shipping_cost', { precision: 15, scale: 2 }),
currency: text('currency').notNull().default('THB'),
invoiceId: uuid('invoice_id').references(() => invoices.id, { onDelete: 'set null' }),
customsInvoiceId: uuid('customs_invoice_id').references(() => invoices.id, { onDelete: 'set null' }),
expenseId: uuid('expense_id').references(() => expenses.id, { onDelete: 'set null' }),
partyId: uuid('party_id').references(() => parties.id, { onDelete: 'set null' }),
notes: text('notes'),
lastRefreshedAt: timestamp('last_refreshed_at', { withTimezone: true }),
createdBy: text('created_by')
.notNull()
.references(() => users.id),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('packages_company_carrier_tracking_idx').on(
table.companyId,
table.carrier,
table.trackingNumber
),
index('packages_company_status_idx').on(table.companyId, table.status)
]
);
export const packageEvents = pgTable(
'package_events',
{
id: uuid('id').primaryKey().defaultRandom(),
packageId: uuid('package_id')
.notNull()
.references(() => packages.id, { onDelete: 'cascade' }),
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
status: packageStatusEnum('status'),
location: text('location'),
description: text('description'),
source: text('source').notNull().default('manual'),
rawPayload: text('raw_payload'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [index('package_events_package_idx').on(table.packageId, table.occurredAt)]
);
export const shippingAccounts = pgTable(
'shipping_accounts',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
carrier: carrierEnum('carrier').notNull(),
displayName: text('display_name'),
credentialsEncrypted: text('credentials_encrypted'),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('shipping_accounts_company_carrier_idx').on(table.companyId, table.carrier)]
);
// ── Feature Requests ───────────────────────────────────
export const featureRequestStatusEnum = pgEnum('feature_request_status', [
'open',
'in_review',
'waiting_for_checks',
'in_progress',
'resolved',
'closed'
]);
export const featureRequests = pgTable(
'feature_requests',
{
id: uuid('id').primaryKey().defaultRandom(),
title: text('title').notNull(),
description: text('description'),
status: featureRequestStatusEnum('status').notNull().default('open'),
submittedBy: text('submitted_by')
.notNull()
.references(() => users.id, { onDelete: 'set null' }),
statusChangedBy: text('status_changed_by').references(() => users.id, { onDelete: 'set null' }),
statusChangedAt: timestamp('status_changed_at', { withTimezone: true }),
statusNote: text('status_note'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [index('feature_requests_status_idx').on(table.status)]
);
export const featureRequestVotes = pgTable(
'feature_request_votes',
{
id: uuid('id').primaryKey().defaultRandom(),
requestId: uuid('request_id')
.notNull()
.references(() => featureRequests.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('feature_request_votes_request_user_idx').on(table.requestId, table.userId)]
);
// ── Company Documents ──────────────────────────────────
export const companyDocumentCategoryEnum = pgEnum('company_document_category', [
'dbd_registration',
'affidavit',
'memorandum',
'articles_of_association',
'vat_registration',
'tax_id_document',
'bank_document',
'director_id',
'director_signature_card',
'shareholder_list',
'annual_filing',
'contract',
'license',
'insurance',
'other'
]);
export const companyDocuments = pgTable(
'company_documents',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
category: companyDocumentCategoryEnum('category').notNull(),
customLabel: text('custom_label'),
title: text('title').notNull(),
description: text('description'),
expiresAt: date('expires_at'),
notes: text('notes'),
createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [index('company_documents_company_category_idx').on(table.companyId, table.category)]
);
export const companyDocumentVersions = pgTable(
'company_document_versions',
{
id: uuid('id').primaryKey().defaultRandom(),
documentId: uuid('document_id')
.notNull()
.references(() => companyDocuments.id, { onDelete: 'cascade' }),
versionNumber: integer('version_number').notNull(),
fileName: text('file_name').notNull(),
storedPath: text('stored_path').notNull(),
mimeType: text('mime_type').notNull(),
sizeBytes: integer('size_bytes').notNull(),
uploadedBy: text('uploaded_by').references(() => users.id, { onDelete: 'set null' }),
uploadedAt: timestamp('uploaded_at', { withTimezone: true }).notNull().defaultNow(),
comment: text('comment')
},
(table) => [
uniqueIndex('company_document_versions_doc_version_idx').on(table.documentId, table.versionNumber)
]
);
// ── Company Links (internal tool & bookmark hub) ──────
export const companyLinkCategoryEnum = pgEnum('company_link_category', [
'internal_tool',
'communication',
'social_media',
'analytics',
'banking',
'government',
'storage',
'marketing',
'development',
'website',
'other'
]);
export const companyLinks = pgTable(
'company_links',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
category: companyLinkCategoryEnum('category').notNull(),
customLabel: text('custom_label'),
title: text('title').notNull(),
url: text('url').notNull(),
description: text('description'),
faviconUrl: text('favicon_url'),
faviconFetchedAt: timestamp('favicon_fetched_at', { withTimezone: true }),
sortOrder: integer('sort_order').notNull().default(0),
createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
index('company_links_company_cat_sort_idx').on(table.companyId, table.category, table.sortOrder)
]
);
export const userCompanyLinks = pgTable(
'user_company_links',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
category: companyLinkCategoryEnum('category').notNull(),
customLabel: text('custom_label'),
title: text('title').notNull(),
url: text('url').notNull(),
description: text('description'),
faviconUrl: text('favicon_url'),
faviconFetchedAt: timestamp('favicon_fetched_at', { withTimezone: true }),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
index('user_company_links_user_company_idx').on(
table.userId,
table.companyId,
table.category,
table.sortOrder
)
]
);
// ── Company Profile (bank accounts, cards, addresses) ──
export const companyAddressTypeEnum = pgEnum('company_address_type', [
'legal',
'shipping',
'billing',
'other'
]);
export const cardBrandEnum = pgEnum('card_brand', [
'visa',
'mastercard',
'amex',
'jcb',
'unionpay',
'discover',
'other'
]);
// ── Company Accounts (unified ledger) ──────────────────
export const companyAccountTypeEnum = pgEnum('company_account_type', [
'bank',
'credit_card',
'cash',
'mobile_money',
'petty_cash',
'loan',
'other'
]);
export const companyAccountTxnTypeEnum = pgEnum('company_account_txn_type', [
'opening_balance',
'expense',
'invoice_payment',
'transfer_in',
'transfer_out',
'deposit',
'adjustment',
'reconciliation'
]);
export const companyAccounts = pgTable(
'company_accounts',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
accountType: companyAccountTypeEnum('account_type').notNull(),
name: text('name').notNull(),
currency: text('currency').notNull().default('THB'),
isActive: boolean('is_active').notNull().default(true),
isArchived: boolean('is_archived').notNull().default(false),
notes: text('notes'),
sortOrder: integer('sort_order').notNull().default(0),
createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
// Bank-specific
bankName: text('bank_name'),
accountNumber: text('account_number'),
branch: text('branch'),
swiftBic: text('swift_bic'),
iban: text('iban'),
accountHolderName: text('account_holder_name'),
// Card-specific
cardBrand: cardBrandEnum('card_brand'),
last4: varchar('last4', { length: 4 }),
cardholderName: text('cardholder_name'),
expiryMonth: integer('expiry_month'),
expiryYear: integer('expiry_year'),
creditLimit: numeric('credit_limit', { precision: 15, scale: 2 }),
statementCloseDay: integer('statement_close_day'),
paymentDueDay: integer('payment_due_day'),
// Banking integration link
externalAccountId: uuid('external_account_id').references(() => externalAccounts.id, {
onDelete: 'set null'
}),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
index('company_accounts_company_type_idx').on(table.companyId, table.accountType),
index('company_accounts_company_archived_idx').on(table.companyId, table.isArchived)
]
);
export const companyAccountTransactions = pgTable(
'company_account_transactions',
{
id: uuid('id').primaryKey().defaultRandom(),
accountId: uuid('account_id')
.notNull()
.references(() => companyAccounts.id, { onDelete: 'cascade' }),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
type: companyAccountTxnTypeEnum('type').notNull(),
amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
currency: text('currency').notNull(),
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
description: text('description'),
reference: text('reference'),
counterpartyAccountId: uuid('counterparty_account_id').references(
(): any => companyAccounts.id,
{ onDelete: 'set null' }
),
sourceExpenseId: uuid('source_expense_id').references(() => expenses.id, {
onDelete: 'set null'
}),
sourceInvoiceId: uuid('source_invoice_id').references(() => invoices.id, {
onDelete: 'set null'
}),
sourceExternalTransactionId: uuid('source_external_transaction_id').references(
() => externalTransactions.id,
{ onDelete: 'set null' }
),
fxRate: numeric('fx_rate', { precision: 18, scale: 8 }),
fxAmount: numeric('fx_amount', { precision: 15, scale: 2 }),
createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
index('company_account_txns_account_occurred_idx').on(table.accountId, table.occurredAt),
index('company_account_txns_company_occurred_idx').on(table.companyId, table.occurredAt),
index('company_account_txns_expense_idx').on(table.sourceExpenseId),
index('company_account_txns_invoice_idx').on(table.sourceInvoiceId)
]
);
// ── Recurring Bills ────────────────────────────────────
export const recurringBillCycleEnum = pgEnum('recurring_bill_cycle', [
'weekly',
'monthly',
'quarterly',
'yearly'
]);
export const recurringBillStatusEnum = pgEnum('recurring_bill_status', [
'active',
'paused',
'ended'
]);
export const recurringBills = pgTable(
'recurring_bills',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
projectId: uuid('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'restrict' }),
accountId: uuid('account_id')
.notNull()
.references(() => companyAccounts.id, { onDelete: 'restrict' }),
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
partyId: uuid('party_id').references(() => parties.id, { onDelete: 'set null' }),
serviceAccountId: uuid('service_account_id').references(
(): any => companyServiceAccounts.id,
{ onDelete: 'set null' }
),
name: text('name').notNull(),
description: text('description'),
cycle: recurringBillCycleEnum('cycle').notNull(),
defaultAmount: numeric('default_amount', { precision: 15, scale: 2 }).notNull(),
nextCycleAmount: numeric('next_cycle_amount', { precision: 15, scale: 2 }),
currency: text('currency').notNull().default('THB'),
dayOfCycle: integer('day_of_cycle'),
startDate: date('start_date').notNull(),
endDate: date('end_date'),
nextDueDate: date('next_due_date').notNull(),
lastPostedDate: date('last_posted_date'),
status: recurringBillStatusEnum('status').notNull().default('active'),
pausedAt: timestamp('paused_at', { withTimezone: true }),
skipNext: boolean('skip_next').notNull().default(false),
createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
index('recurring_bills_company_next_due_idx').on(table.companyId, table.nextDueDate),
index('recurring_bills_company_status_idx').on(table.companyId, table.status)
]
);
// ── Service Accounts ───────────────────────────────────
export const serviceAccountTypeEnum = pgEnum('service_account_type', [
'electricity',
'water',
'gas',
'internet',
'phone',
'shipping',
'insurance',
'tax_registration',
'social_security',
'customs',
'other'
]);
export const companyServiceAccounts = pgTable(
'company_service_accounts',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
type: serviceAccountTypeEnum('type').notNull(),
providerName: text('provider_name').notNull(),
accountNumber: text('account_number').notNull(),
customLabel: text('custom_label'),
contactPhone: text('contact_phone'),
websiteUrl: text('website_url'),
notes: text('notes'),
isActive: boolean('is_active').notNull().default(true),
createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
index('company_service_accounts_company_type_idx').on(table.companyId, table.type)
]
);
// ── Procedures & Checklists ────────────────────────────
export const procedureInstanceStatusEnum = pgEnum('procedure_instance_status', [
'in_progress',
'completed',
'cancelled'
]);
export const procedureTemplates = pgTable(
'procedure_templates',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
description: text('description'),
category: text('category'),
isPublished: boolean('is_published').notNull().default(false),
createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [index('procedure_templates_company_idx').on(table.companyId)]
);
export const procedureSteps = pgTable(
'procedure_steps',
{
id: uuid('id').primaryKey().defaultRandom(),
templateId: uuid('template_id')
.notNull()
.references(() => procedureTemplates.id, { onDelete: 'cascade' }),
stepNumber: integer('step_number').notNull(),
title: text('title').notNull(),
description: text('description'),
assigneeRole: text('assignee_role'),
estimatedMinutes: integer('estimated_minutes'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('procedure_steps_template_step_idx').on(table.templateId, table.stepNumber)
]
);
export const procedureInstances = pgTable(
'procedure_instances',
{
id: uuid('id').primaryKey().defaultRandom(),
templateId: uuid('template_id')
.notNull()
.references(() => procedureTemplates.id, { onDelete: 'restrict' }),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
status: procedureInstanceStatusEnum('status').notNull().default('in_progress'),
startedBy: text('started_by').references(() => users.id, { onDelete: 'set null' }),
completedAt: timestamp('completed_at', { withTimezone: true }),
cancelledAt: timestamp('cancelled_at', { withTimezone: true }),
notes: text('notes'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
index('procedure_instances_company_status_idx').on(table.companyId, table.status)
]
);
export const procedureInstanceSteps = pgTable(
'procedure_instance_steps',
{
id: uuid('id').primaryKey().defaultRandom(),
instanceId: uuid('instance_id')
.notNull()
.references(() => procedureInstances.id, { onDelete: 'cascade' }),
stepId: uuid('step_id')
.notNull()
.references(() => procedureSteps.id, { onDelete: 'restrict' }),
stepNumber: integer('step_number').notNull(),
title: text('title').notNull(),
description: text('description'),
isCompleted: boolean('is_completed').notNull().default(false),
completedBy: text('completed_by').references(() => users.id, { onDelete: 'set null' }),
completedAt: timestamp('completed_at', { withTimezone: true }),
notes: text('notes')
},
(table) => [
index('procedure_instance_steps_instance_idx').on(table.instanceId, table.stepNumber)
]
);
export const companyAddresses = pgTable(
'company_addresses',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
type: companyAddressTypeEnum('type').notNull(),
label: text('label'),
recipient: text('recipient'),
addressLine1: text('address_line_1'),
addressLine2: text('address_line_2'),
subdistrict: text('subdistrict'),
district: text('district'),
province: text('province'),
postalCode: text('postal_code'),
country: text('country').notNull().default('Thailand'),
contactPerson: text('contact_person'),
contactPhone: text('contact_phone'),
isDefault: boolean('is_default').notNull().default(false),
notes: text('notes'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [index('company_addresses_company_type_idx').on(table.companyId, table.type)]
);
// ── Company Log (Audit Trail) ──────────────────────────
export const companyLogEventEnum = pgEnum('company_log_event', [
'company_created',
'company_updated',
'budget_initial',
'budget_added',
'budget_allocated',
'budget_deallocated',
'project_created',
'project_updated',
'member_added',
'member_removed',
'member_role_changed',
'expense_submitted',
'expense_approved',
'expense_rejected',
'category_created',
'import_completed',
'employee_created',
'employee_updated',
'employee_terminated',
'salary_changed',
'holiday_added',
'leave_type_created',
'leave_submitted',
'leave_approved',
'leave_rejected',
'payslip_generated',
'payslip_finalized',
'payslip_paid',
'party_created',
'invoice_created',
'invoice_sent',
'invoice_paid',
'invoice_voided',
'integration_connected',
'integration_disconnected',
'transaction_matched',
'package_created',
'package_updated',
'package_delivered',
'package_status_refreshed',
'shipping_account_added',
'shipping_account_removed',
'financial_exported',
'bank_account_added',
'bank_account_updated',
'bank_account_removed',
'card_added',
'card_removed',
'address_added',
'address_updated',
'address_removed',
'document_uploaded',
'document_version_added',
'document_metadata_updated',
'document_deleted',
'link_added',
'link_updated',
'link_deleted',
'account_created',
'account_updated',
'account_archived',
'account_deleted',
'account_transaction_added',
'account_transfer_posted',
'account_reconciled',
'recurring_bill_created',
'recurring_bill_updated',
'recurring_bill_deleted',
'recurring_bill_paused',
'recurring_bill_resumed',
'recurring_bill_skipped',
'recurring_bill_posted',
'service_account_created',
'service_account_updated',
'service_account_deleted',
'procedure_template_created',
'procedure_template_updated',
'procedure_template_deleted',
'procedure_instance_started',
'procedure_step_completed',
'procedure_instance_completed',
'procedure_instance_cancelled'
]);
export const companyLog = pgTable(
'company_log',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
userId: text('user_id').references(() => users.id),
event: companyLogEventEnum('event').notNull(),
description: text('description').notNull(),
metadata: text('metadata'), // JSON string for extra context (amounts, names, etc.)
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [index('company_log_company_idx').on(table.companyId, table.createdAt)]
);
// ── Relations ──────────────────────────────────────────
export const usersRelations = relations(users, ({ many }) => ({
sessions: many(sessions),
companyMemberships: many(companyMembers),
submittedExpenses: many(expenses, { relationName: 'submittedExpenses' }),
approvedExpenses: many(expenses, { relationName: 'approvedExpenses' })
}));
export const sessionsRelations = relations(sessions, ({ one }) => ({
user: one(users, { fields: [sessions.userId], references: [users.id] })
}));
export const companiesRelations = relations(companies, ({ many }) => ({
members: many(companyMembers),
projects: many(projects),
categories: many(categories),
tags: many(tags),
budgetAllocations: many(budgetAllocations),
logs: many(companyLog)
}));
export const companyMembersRelations = relations(companyMembers, ({ one }) => ({
user: one(users, { fields: [companyMembers.userId], references: [users.id] }),
company: one(companies, { fields: [companyMembers.companyId], references: [companies.id] })
}));
export const projectsRelations = relations(projects, ({ one, many }) => ({
company: one(companies, { fields: [projects.companyId], references: [companies.id] }),
expenses: many(expenses),
budgetAllocations: many(budgetAllocations)
}));
export const categoriesRelations = relations(categories, ({ one, many }) => ({
company: one(companies, { fields: [categories.companyId], references: [companies.id] }),
expenses: many(expenses)
}));
export const expensesRelations = relations(expenses, ({ one, many }) => ({
project: one(projects, { fields: [expenses.projectId], references: [projects.id] }),
category: one(categories, { fields: [expenses.categoryId], references: [categories.id] }),
submitter: one(users, {
fields: [expenses.submittedBy],
references: [users.id],
relationName: 'submittedExpenses'
}),
approver: one(users, {
fields: [expenses.approvedBy],
references: [users.id],
relationName: 'approvedExpenses'
}),
expenseTags: many(expenseTags)
}));
export const tagsRelations = relations(tags, ({ one, many }) => ({
company: one(companies, { fields: [tags.companyId], references: [companies.id] }),
expenseTags: many(expenseTags)
}));
export const expenseTagsRelations = relations(expenseTags, ({ one }) => ({
expense: one(expenses, { fields: [expenseTags.expenseId], references: [expenses.id] }),
tag: one(tags, { fields: [expenseTags.tagId], references: [tags.id] })
}));
export const companyLogRelations = relations(companyLog, ({ one }) => ({
company: one(companies, { fields: [companyLog.companyId], references: [companies.id] }),
user: one(users, { fields: [companyLog.userId], references: [users.id] })
}));
export const budgetAllocationsRelations = relations(budgetAllocations, ({ one }) => ({
company: one(companies, {
fields: [budgetAllocations.companyId],
references: [companies.id]
}),
project: one(projects, {
fields: [budgetAllocations.projectId],
references: [projects.id]
}),
allocator: one(users, { fields: [budgetAllocations.allocatedBy], references: [users.id] })
}));