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)
|
# Company Links favicon fetching (set false to disable outbound fetches in offline dev)
|
||||||
FAVICON_FETCH_ENABLED=true
|
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'),
|
status: expenseStatusEnum('status').notNull().default('pending'),
|
||||||
reviewedAt: timestamp('reviewed_at', { withTimezone: true }),
|
reviewedAt: timestamp('reviewed_at', { withTimezone: true }),
|
||||||
rejectionReason: text('rejection_reason'),
|
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(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_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 ───────────────────────────────────────────────
|
// ── Tags ───────────────────────────────────────────────
|
||||||
|
|
||||||
export const tags = pgTable(
|
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(
|
export const companyAddresses = pgTable(
|
||||||
'company_addresses',
|
'company_addresses',
|
||||||
{
|
{
|
||||||
@@ -1208,6 +1297,10 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
|
|||||||
'invoice_sent',
|
'invoice_sent',
|
||||||
'invoice_paid',
|
'invoice_paid',
|
||||||
'invoice_voided',
|
'invoice_voided',
|
||||||
|
'expense_invoice_uploaded',
|
||||||
|
'sale_created',
|
||||||
|
'sale_confirmed',
|
||||||
|
'sale_voided',
|
||||||
'integration_connected',
|
'integration_connected',
|
||||||
'integration_disconnected',
|
'integration_disconnected',
|
||||||
'transaction_matched',
|
'transaction_matched',
|
||||||
|
|||||||
Reference in New Issue
Block a user