Add expense invoice fields, sales tables, and Paperless env vars
Expenses now have invoiceFileUrl, invoiceFileName, paperlessUrl, paperlessDocumentId for supplier invoice attachment. New expense_packages junction links expenses to multiple packages. New sales + sale_line_items + sale_packages tables for income tracking with per-line tax rate and per-sale withholding rate. Added saleStatusEnum and 4 audit events: expense_invoice_uploaded, sale_created, sale_confirmed, sale_voided. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,3 +21,7 @@ BODY_SIZE_LIMIT=26214400
|
||||
|
||||
# Company Links favicon fetching (set false to disable outbound fetches in offline dev)
|
||||
FAVICON_FETCH_ENABLED=true
|
||||
|
||||
# Paperless-ngx integration (optional — leave blank to disable)
|
||||
PAPERLESS_URL=
|
||||
PAPERLESS_TOKEN=
|
||||
|
||||
@@ -146,6 +146,11 @@ export const expenses = pgTable(
|
||||
status: expenseStatusEnum('status').notNull().default('pending'),
|
||||
reviewedAt: timestamp('reviewed_at', { withTimezone: true }),
|
||||
rejectionReason: text('rejection_reason'),
|
||||
// Supplier invoice attachment
|
||||
invoiceFileUrl: text('invoice_file_url'),
|
||||
invoiceFileName: text('invoice_file_name'),
|
||||
paperlessUrl: text('paperless_url'),
|
||||
paperlessDocumentId: integer('paperless_document_id'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
@@ -156,6 +161,22 @@ export const expenses = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
// ── Expense ↔ Packages junction ────────────────────────
|
||||
|
||||
export const expensePackages = pgTable(
|
||||
'expense_packages',
|
||||
{
|
||||
expenseId: uuid('expense_id')
|
||||
.notNull()
|
||||
.references(() => expenses.id, { onDelete: 'cascade' }),
|
||||
packageId: uuid('package_id')
|
||||
.notNull()
|
||||
.references((): any => packages.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.expenseId, table.packageId] })]
|
||||
);
|
||||
|
||||
// ── Tags ───────────────────────────────────────────────
|
||||
|
||||
export const tags = pgTable(
|
||||
@@ -1145,6 +1166,74 @@ export const procedureInstanceSteps = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
// ── Sales / Income ─────────────────────────────────────
|
||||
|
||||
export const saleStatusEnum = pgEnum('sale_status', ['draft', 'confirmed', 'voided']);
|
||||
|
||||
export const sales = pgTable(
|
||||
'sales',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }),
|
||||
partyId: uuid('party_id').references(() => parties.id, { onDelete: 'set null' }),
|
||||
invoiceId: uuid('invoice_id').references((): any => invoices.id, { onDelete: 'set null' }),
|
||||
title: text('title').notNull(),
|
||||
saleDate: date('sale_date').notNull(),
|
||||
currency: text('currency').notNull().default('THB'),
|
||||
withholdingTaxRate: numeric('withholding_tax_rate', { precision: 5, scale: 4 })
|
||||
.notNull()
|
||||
.default('0'),
|
||||
notes: text('notes'),
|
||||
status: saleStatusEnum('status').notNull().default('draft'),
|
||||
voidedAt: timestamp('voided_at', { withTimezone: true }),
|
||||
voidReason: text('void_reason'),
|
||||
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('sales_company_status_idx').on(table.companyId, table.status),
|
||||
index('sales_project_idx').on(table.projectId),
|
||||
index('sales_date_idx').on(table.saleDate)
|
||||
]
|
||||
);
|
||||
|
||||
export const saleLineItems = pgTable(
|
||||
'sale_line_items',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
saleId: uuid('sale_id')
|
||||
.notNull()
|
||||
.references(() => sales.id, { onDelete: 'cascade' }),
|
||||
productName: text('product_name').notNull(),
|
||||
description: text('description'),
|
||||
quantity: numeric('quantity', { precision: 15, scale: 4 }).notNull().default('1'),
|
||||
unitPrice: numeric('unit_price', { precision: 15, scale: 2 }).notNull(),
|
||||
taxRate: numeric('tax_rate', { precision: 5, scale: 4 }).notNull().default('0'),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [index('sale_line_items_sale_idx').on(table.saleId, table.sortOrder)]
|
||||
);
|
||||
|
||||
export const salePackages = pgTable(
|
||||
'sale_packages',
|
||||
{
|
||||
saleId: uuid('sale_id')
|
||||
.notNull()
|
||||
.references(() => sales.id, { onDelete: 'cascade' }),
|
||||
packageId: uuid('package_id')
|
||||
.notNull()
|
||||
.references(() => packages.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.saleId, table.packageId] })]
|
||||
);
|
||||
|
||||
export const companyAddresses = pgTable(
|
||||
'company_addresses',
|
||||
{
|
||||
@@ -1208,6 +1297,10 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
|
||||
'invoice_sent',
|
||||
'invoice_paid',
|
||||
'invoice_voided',
|
||||
'expense_invoice_uploaded',
|
||||
'sale_created',
|
||||
'sale_confirmed',
|
||||
'sale_voided',
|
||||
'integration_connected',
|
||||
'integration_disconnected',
|
||||
'transaction_matched',
|
||||
|
||||
Reference in New Issue
Block a user