493ffa4097
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1249 lines
45 KiB
TypeScript
1249 lines
45 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'
|
|
}),
|
|
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'
|
|
]);
|
|
|
|
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'),
|
|
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)
|
|
]
|
|
);
|
|
|
|
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',
|
|
'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'
|
|
]);
|
|
|
|
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] })
|
|
}));
|