Compare commits

..

6 Commits

Author SHA1 Message Date
grabowski 77c5d72e43 Reconciliation link, account CSVs in export, drop legacy bank/card tables
Validate / validate (push) Successful in 31s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 14:06:53 +07:00
grabowski 0d4fdb6fd7 Add account detail page with transaction history, filters, and CSV export
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:58:44 +07:00
grabowski 3a095851e9 Auto-post expenses and invoice payments to accounts ledger
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:04:15 +07:00
grabowski d75fe6ed95 Add opening balance, manual transactions, and cross-currency transfers
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:54:10 +07:00
grabowski aea6dbc06e Add accounts list page with CRUD, Accounts nav tab, profile deprecation banner
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:50:40 +07:00
grabowski 57e72e5b6c Add companyAccounts schema, ledger helper, legacy migration script
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:34:57 +07:00
21 changed files with 3747 additions and 655 deletions
+354
View File
@@ -0,0 +1,354 @@
import { db } from '$lib/server/db/index.js';
import {
companyAccounts,
companyAccountTransactions,
expenses,
invoices,
externalTransactions
} from '$lib/server/db/schema.js';
import { and, eq, sql } from 'drizzle-orm';
/**
* Drizzle's tx inside db.transaction() has the same methods as db.
* Use `any` to avoid importing the internal PgTransaction generic type.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Dbx = typeof db | any;
export type CompanyAccountTxnType =
| 'opening_balance'
| 'expense'
| 'invoice_payment'
| 'transfer_in'
| 'transfer_out'
| 'deposit'
| 'adjustment'
| 'reconciliation';
export interface PostTxnInput {
accountId: string;
companyId: string;
type: CompanyAccountTxnType;
amount: string | number; // signed decimal; positive = credit, negative = debit
currency: string;
occurredAt: Date;
description?: string | null;
reference?: string | null;
counterpartyAccountId?: string | null;
sourceExpenseId?: string | null;
sourceInvoiceId?: string | null;
sourceExternalTransactionId?: string | null;
fxRate?: string | number | null;
fxAmount?: string | number | null;
createdBy?: string | null;
}
function toDecimalString(v: string | number | null | undefined): string | null {
if (v === null || v === undefined) return null;
return typeof v === 'number' ? v.toString() : v;
}
export async function postTransaction(dbx: Dbx, input: PostTxnInput): Promise<{ id: string }> {
const amountStr = toDecimalString(input.amount);
if (amountStr === null) throw new Error('postTransaction: amount is required');
const [row] = await dbx
.insert(companyAccountTransactions)
.values({
accountId: input.accountId,
companyId: input.companyId,
type: input.type,
amount: amountStr,
currency: input.currency,
occurredAt: input.occurredAt,
description: input.description ?? null,
reference: input.reference ?? null,
counterpartyAccountId: input.counterpartyAccountId ?? null,
sourceExpenseId: input.sourceExpenseId ?? null,
sourceInvoiceId: input.sourceInvoiceId ?? null,
sourceExternalTransactionId: input.sourceExternalTransactionId ?? null,
fxRate: toDecimalString(input.fxRate),
fxAmount: toDecimalString(input.fxAmount),
createdBy: input.createdBy ?? null
})
.returning({ id: companyAccountTransactions.id });
return row;
}
export interface PostTransferInput {
fromAccountId: string;
toAccountId: string;
companyId: string;
amount: string | number; // always positive — the sending side (debit)
occurredAt: Date;
description?: string | null;
reference?: string | null;
/** Cross-currency: set exactly one of fxRate OR destinationAmount. */
fxRate?: string | number | null;
destinationAmount?: string | number | null;
createdBy?: string | null;
}
export async function postTransfer(
input: PostTransferInput
): Promise<{ fromTxnId: string; toTxnId: string }> {
if (input.fromAccountId === input.toAccountId) {
throw new Error('postTransfer: fromAccountId and toAccountId must differ');
}
const absAmount = Number(input.amount);
if (!Number.isFinite(absAmount) || absAmount <= 0) {
throw new Error('postTransfer: amount must be a positive number');
}
return await db.transaction(async (tx: Dbx) => {
const accts = await tx
.select({
id: companyAccounts.id,
companyId: companyAccounts.companyId,
currency: companyAccounts.currency
})
.from(companyAccounts)
.where(
sql`${companyAccounts.id} IN (${input.fromAccountId}, ${input.toAccountId}) AND ${companyAccounts.companyId} = ${input.companyId} AND ${companyAccounts.deletedAt} IS NULL`
);
const fromAcct = accts.find((a: { id: string }) => a.id === input.fromAccountId);
const toAcct = accts.find((a: { id: string }) => a.id === input.toAccountId);
if (!fromAcct || !toAcct) throw new Error('postTransfer: account not found or mismatched company');
const sameCurrency = fromAcct.currency === toAcct.currency;
let fxRateStr: string | null = null;
let destAmount: number;
if (sameCurrency) {
destAmount = absAmount;
} else if (input.destinationAmount != null && input.destinationAmount !== '') {
destAmount = Number(input.destinationAmount);
if (!Number.isFinite(destAmount) || destAmount <= 0) {
throw new Error('postTransfer: destinationAmount must be positive');
}
fxRateStr = (destAmount / absAmount).toFixed(8);
} else if (input.fxRate != null && input.fxRate !== '') {
const rate = Number(input.fxRate);
if (!Number.isFinite(rate) || rate <= 0) {
throw new Error('postTransfer: fxRate must be positive');
}
fxRateStr = rate.toFixed(8);
destAmount = +(absAmount * rate).toFixed(2);
} else {
throw new Error(
'postTransfer: cross-currency transfer requires fxRate or destinationAmount'
);
}
const fxAmountStr = sameCurrency ? null : destAmount.toFixed(2);
const fromTxn = await postTransaction(tx, {
accountId: input.fromAccountId,
companyId: input.companyId,
type: 'transfer_out',
amount: (-absAmount).toFixed(2),
currency: fromAcct.currency,
occurredAt: input.occurredAt,
description: input.description ?? null,
reference: input.reference ?? null,
counterpartyAccountId: input.toAccountId,
fxRate: fxRateStr,
fxAmount: fxAmountStr,
createdBy: input.createdBy ?? null
});
const toTxn = await postTransaction(tx, {
accountId: input.toAccountId,
companyId: input.companyId,
type: 'transfer_in',
amount: destAmount.toFixed(2),
currency: toAcct.currency,
occurredAt: input.occurredAt,
description: input.description ?? null,
reference: input.reference ?? null,
counterpartyAccountId: input.fromAccountId,
fxRate: fxRateStr,
fxAmount: fxAmountStr,
createdBy: input.createdBy ?? null
});
return { fromTxnId: fromTxn.id, toTxnId: toTxn.id };
});
}
export async function getBalance(accountId: string): Promise<{ balance: string; currency: string }> {
const [acct] = await db
.select({ currency: companyAccounts.currency })
.from(companyAccounts)
.where(eq(companyAccounts.id, accountId))
.limit(1);
if (!acct) throw new Error(`getBalance: account ${accountId} not found`);
const [row] = await db
.select({
total: sql<string>`coalesce(sum(${companyAccountTransactions.amount}), '0')::text`
})
.from(companyAccountTransactions)
.where(eq(companyAccountTransactions.accountId, accountId));
return { balance: row?.total ?? '0', currency: acct.currency };
}
export async function postExpenseTransaction(
expenseId: string,
accountId: string,
userId: string,
dbx?: Dbx
): Promise<void> {
const dbOrTx = dbx ?? db;
const [exp] = await dbOrTx
.select({
id: expenses.id,
amount: expenses.amount,
currency: expenses.currency,
expenseDate: expenses.expenseDate,
title: expenses.title
})
.from(expenses)
.where(eq(expenses.id, expenseId))
.limit(1);
if (!exp) throw new Error(`postExpenseTransaction: expense ${expenseId} not found`);
const [acct] = await dbOrTx
.select({ companyId: companyAccounts.companyId, currency: companyAccounts.currency })
.from(companyAccounts)
.where(eq(companyAccounts.id, accountId))
.limit(1);
if (!acct) throw new Error(`postExpenseTransaction: account ${accountId} not found`);
// Idempotent: replace any prior post for this expense.
await dbOrTx
.delete(companyAccountTransactions)
.where(eq(companyAccountTransactions.sourceExpenseId, expenseId));
await postTransaction(dbOrTx, {
accountId,
companyId: acct.companyId,
type: 'expense',
amount: (-Number(exp.amount)).toFixed(2),
currency: exp.currency,
occurredAt: new Date(exp.expenseDate),
description: exp.title,
sourceExpenseId: expenseId,
createdBy: userId
});
}
export async function removeExpenseTransaction(expenseId: string, dbx?: Dbx): Promise<void> {
const dbOrTx = dbx ?? db;
await dbOrTx
.delete(companyAccountTransactions)
.where(eq(companyAccountTransactions.sourceExpenseId, expenseId));
}
export async function postInvoicePaymentTransaction(
invoiceId: string,
paymentAccountId: string,
userId: string,
dbx?: Dbx
): Promise<void> {
const dbOrTx = dbx ?? db;
const [inv] = await dbOrTx
.select({
id: invoices.id,
total: invoices.total,
currency: invoices.currency,
issueDate: invoices.issueDate,
invoiceNumber: invoices.invoiceNumber,
direction: invoices.direction
})
.from(invoices)
.where(eq(invoices.id, invoiceId))
.limit(1);
if (!inv) throw new Error(`postInvoicePaymentTransaction: invoice ${invoiceId} not found`);
const [acct] = await dbOrTx
.select({ companyId: companyAccounts.companyId, currency: companyAccounts.currency })
.from(companyAccounts)
.where(eq(companyAccounts.id, paymentAccountId))
.limit(1);
if (!acct) throw new Error(`postInvoicePaymentTransaction: account ${paymentAccountId} not found`);
// outgoing = we billed a customer → cash in (credit).
// incoming = we owe a supplier → cash out (debit).
const sign = inv.direction === 'outgoing' ? 1 : -1;
const signedAmount = sign * Number(inv.total);
await dbOrTx
.delete(companyAccountTransactions)
.where(eq(companyAccountTransactions.sourceInvoiceId, invoiceId));
await postTransaction(dbOrTx, {
accountId: paymentAccountId,
companyId: acct.companyId,
type: 'invoice_payment',
amount: signedAmount.toFixed(2),
currency: inv.currency,
occurredAt: new Date(inv.issueDate),
description: `Invoice ${inv.invoiceNumber}`,
sourceInvoiceId: invoiceId,
createdBy: userId
});
}
export async function removeInvoicePaymentTransaction(
invoiceId: string,
dbx?: Dbx
): Promise<void> {
const dbOrTx = dbx ?? db;
await dbOrTx
.delete(companyAccountTransactions)
.where(eq(companyAccountTransactions.sourceInvoiceId, invoiceId));
}
export async function postReconciliationTransaction(
externalTransactionId: string,
accountId: string,
companyId: string,
userId: string,
dbx?: Dbx
): Promise<void> {
const dbOrTx = dbx ?? db;
const [ext] = await dbOrTx
.select({
id: externalTransactions.id,
amount: externalTransactions.amount,
currency: externalTransactions.currency,
direction: externalTransactions.direction,
occurredAt: externalTransactions.occurredAt,
description: externalTransactions.description,
counterparty: externalTransactions.counterparty
})
.from(externalTransactions)
.where(
and(
eq(externalTransactions.id, externalTransactionId),
eq(externalTransactions.companyId, companyId)
)
)
.limit(1);
if (!ext) throw new Error(`postReconciliationTransaction: external txn ${externalTransactionId} not found`);
const signedAmount = ext.direction === 'credit' ? Number(ext.amount) : -Number(ext.amount);
await dbOrTx
.delete(companyAccountTransactions)
.where(eq(companyAccountTransactions.sourceExternalTransactionId, externalTransactionId));
await postTransaction(dbOrTx, {
accountId,
companyId,
type: 'reconciliation',
amount: signedAmount.toFixed(2),
currency: ext.currency,
occurredAt: ext.occurredAt,
description: ext.description ?? ext.counterparty ?? 'Bank reconciliation',
sourceExternalTransactionId: externalTransactionId,
createdBy: userId
});
}
+101 -24
View File
@@ -127,6 +127,9 @@ export const expenses = pgTable(
.references(() => projects.id, { onDelete: 'cascade' }), .references(() => projects.id, { onDelete: 'cascade' }),
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }), categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
partyId: uuid('party_id').references((): any => parties.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') submittedBy: text('submitted_by')
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
@@ -452,6 +455,9 @@ export const invoices = pgTable(
currency: text('currency').notNull().default('THB'), currency: text('currency').notNull().default('THB'),
status: invoiceStatusEnum('status').notNull().default('draft'), status: invoiceStatusEnum('status').notNull().default('draft'),
expenseId: uuid('expense_id').references(() => expenses.id, { onDelete: 'set null' }), 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'), notes: text('notes'),
pdfPath: text('pdf_path'), pdfPath: text('pdf_path'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
@@ -826,52 +832,116 @@ export const cardBrandEnum = pgEnum('card_brand', [
'other' 'other'
]); ]);
export const companyBankAccounts = pgTable( // ── Company Accounts (unified ledger) ──────────────────
'company_bank_accounts',
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(), id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id') companyId: uuid('company_id')
.notNull() .notNull()
.references(() => companies.id, { onDelete: 'cascade' }), .references(() => companies.id, { onDelete: 'cascade' }),
bankName: text('bank_name').notNull(), accountType: companyAccountTypeEnum('account_type').notNull(),
accountName: text('account_name').notNull(), name: text('name').notNull(),
accountNumber: text('account_number').notNull(), currency: text('currency').notNull().default('THB'),
accountType: text('account_type'), 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'), branch: text('branch'),
swiftBic: text('swift_bic'), swiftBic: text('swift_bic'),
iban: text('iban'), iban: text('iban'),
currency: text('currency').notNull().default('THB'), accountHolderName: text('account_holder_name'),
isPrimary: boolean('is_primary').notNull().default(false), // Card-specific
isActive: boolean('is_active').notNull().default(true), cardBrand: cardBrandEnum('card_brand'),
notes: text('notes'), 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(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow() updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
}, },
(table) => [index('company_bank_accounts_company_idx').on(table.companyId)] (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 companyCards = pgTable( export const companyAccountTransactions = pgTable(
'company_cards', 'company_account_transactions',
{ {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
accountId: uuid('account_id')
.notNull()
.references(() => companyAccounts.id, { onDelete: 'cascade' }),
companyId: uuid('company_id') companyId: uuid('company_id')
.notNull() .notNull()
.references(() => companies.id, { onDelete: 'cascade' }), .references(() => companies.id, { onDelete: 'cascade' }),
brand: cardBrandEnum('brand').notNull(), type: companyAccountTxnTypeEnum('type').notNull(),
last4: varchar('last4', { length: 4 }).notNull(), amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
cardholderName: text('cardholder_name').notNull(), currency: text('currency').notNull(),
expiryMonth: integer('expiry_month'), occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
expiryYear: integer('expiry_year'), description: text('description'),
nickname: text('nickname'), reference: text('reference'),
bankAccountId: uuid('bank_account_id').references(() => companyBankAccounts.id, { counterpartyAccountId: uuid('counterparty_account_id').references(
(): any => companyAccounts.id,
{ onDelete: 'set null' }
),
sourceExpenseId: uuid('source_expense_id').references(() => expenses.id, {
onDelete: 'set null' onDelete: 'set null'
}), }),
isActive: boolean('is_active').notNull().default(true), sourceInvoiceId: uuid('source_invoice_id').references(() => invoices.id, {
notes: text('notes'), 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(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow() updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
}, },
(table) => [index('company_cards_company_idx').on(table.companyId)] (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)
]
); );
export const companyAddresses = pgTable( export const companyAddresses = pgTable(
@@ -960,7 +1030,14 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
'document_deleted', 'document_deleted',
'link_added', 'link_added',
'link_updated', 'link_updated',
'link_deleted' 'link_deleted',
'account_created',
'account_updated',
'account_archived',
'account_deleted',
'account_transaction_added',
'account_transfer_posted',
'account_reconciled'
]); ]);
export const companyLog = pgTable( export const companyLog = pgTable(
+70 -47
View File
@@ -19,9 +19,9 @@ import {
externalAccounts, externalAccounts,
externalTransactions, externalTransactions,
users, users,
companyBankAccounts,
companyCards,
companyAddresses, companyAddresses,
companyAccounts,
companyAccountTransactions,
companyDocuments, companyDocuments,
companyDocumentVersions companyDocumentVersions
} from '../db/schema.js'; } from '../db/schema.js';
@@ -73,8 +73,8 @@ export async function buildFinancialExport(
``, ``,
`Files:`, `Files:`,
` company.csv — company record`, ` company.csv — company record`,
` company_bank_accounts.csv — company bank accounts`, ` company_accounts.csv — unified ledger accounts (bank, card, cash, etc.)`,
` company_cards.csv — company credit/debit cards (last 4 only)`, ` company_account_transactions.csv — ledger transactions in the selected year`,
` company_addresses.csv — legal/shipping/billing/other addresses`, ` company_addresses.csv — legal/shipping/billing/other addresses`,
` company_documents.csv — uploaded document metadata (files not bundled)`, ` company_documents.csv — uploaded document metadata (files not bundled)`,
` projects.csv — all projects (active + inactive)`, ` projects.csv — all projects (active + inactive)`,
@@ -119,70 +119,93 @@ export async function buildFinancialExport(
) )
); );
// ── company_bank_accounts.csv ────────────────────── // ── company_accounts.csv ───────────────────────────
{ {
const bankRows = await db const acctRows = await db
.select() .select()
.from(companyBankAccounts) .from(companyAccounts)
.where(eq(companyBankAccounts.companyId, companyId)) .where(eq(companyAccounts.companyId, companyId))
.orderBy(asc(companyBankAccounts.bankName)); .orderBy(asc(companyAccounts.accountType), asc(companyAccounts.name));
const rows: unknown[][] = [ const rows: unknown[][] = [
[ [
'id', 'bankName', 'accountName', 'accountNumber', 'accountType', 'branch', 'id', 'accountType', 'name', 'currency', 'isActive', 'isArchived',
'swiftBic', 'iban', 'currency', 'isPrimary', 'isActive', 'notes', 'bankName', 'accountNumber', 'branch', 'swiftBic', 'iban', 'accountHolderName',
'createdAt', 'updatedAt' 'cardBrand', 'last4', 'cardholderName', 'expiryMonth', 'expiryYear',
'creditLimit', 'statementCloseDay', 'paymentDueDay',
'externalAccountId', 'notes', 'deletedAt', 'createdAt', 'updatedAt'
] ]
]; ];
for (const b of bankRows) { for (const a of acctRows) {
rows.push([ rows.push([
b.id, b.bankName, b.accountName, b.accountNumber, b.accountType ?? '', a.id, a.accountType, a.name, a.currency, a.isActive, a.isArchived,
b.branch ?? '', b.swiftBic ?? '', b.iban ?? '', b.currency, a.bankName ?? '', a.accountNumber ?? '', a.branch ?? '', a.swiftBic ?? '',
b.isPrimary, b.isActive, b.notes ?? '', a.iban ?? '', a.accountHolderName ?? '',
b.createdAt.toISOString(), b.updatedAt.toISOString() a.cardBrand ?? '', a.last4 ?? '', a.cardholderName ?? '',
a.expiryMonth ?? '', a.expiryYear ?? '',
a.creditLimit ?? '', a.statementCloseDay ?? '', a.paymentDueDay ?? '',
a.externalAccountId ?? '', a.notes ?? '',
a.deletedAt ? a.deletedAt.toISOString() : '',
a.createdAt.toISOString(), a.updatedAt.toISOString()
]); ]);
} }
zip.file('company_bank_accounts.csv', withBom(csvBuild(rows))); zip.file('company_accounts.csv', withBom(csvBuild(rows)));
} }
// ── company_cards.csv ────────────────────────────── // ── company_account_transactions.csv ───────────────
{ {
const cardRows = await db const yearStartDate = new Date(`${year}-01-01T00:00:00Z`);
const yearEndDate = new Date(`${year}-12-31T23:59:59.999Z`);
const txRows = await db
.select({ .select({
id: companyCards.id, id: companyAccountTransactions.id,
brand: companyCards.brand, accountId: companyAccountTransactions.accountId,
last4: companyCards.last4, accountName: companyAccounts.name,
cardholderName: companyCards.cardholderName, type: companyAccountTransactions.type,
expiryMonth: companyCards.expiryMonth, amount: companyAccountTransactions.amount,
expiryYear: companyCards.expiryYear, currency: companyAccountTransactions.currency,
nickname: companyCards.nickname, occurredAt: companyAccountTransactions.occurredAt,
bankAccountId: companyCards.bankAccountId, description: companyAccountTransactions.description,
bankAccountName: companyBankAccounts.bankName, reference: companyAccountTransactions.reference,
isActive: companyCards.isActive, counterpartyAccountId: companyAccountTransactions.counterpartyAccountId,
notes: companyCards.notes, sourceExpenseId: companyAccountTransactions.sourceExpenseId,
createdAt: companyCards.createdAt, sourceInvoiceId: companyAccountTransactions.sourceInvoiceId,
updatedAt: companyCards.updatedAt sourceExternalTransactionId: companyAccountTransactions.sourceExternalTransactionId,
fxRate: companyAccountTransactions.fxRate,
fxAmount: companyAccountTransactions.fxAmount,
createdAt: companyAccountTransactions.createdAt
}) })
.from(companyCards) .from(companyAccountTransactions)
.leftJoin(companyBankAccounts, eq(companyCards.bankAccountId, companyBankAccounts.id)) .innerJoin(companyAccounts, eq(companyAccountTransactions.accountId, companyAccounts.id))
.where(eq(companyCards.companyId, companyId)) .where(
.orderBy(asc(companyCards.brand)); and(
eq(companyAccountTransactions.companyId, companyId),
sql`${companyAccountTransactions.occurredAt} >= ${yearStartDate}`,
sql`${companyAccountTransactions.occurredAt} <= ${yearEndDate}`
)
)
.orderBy(
asc(companyAccountTransactions.occurredAt),
asc(companyAccountTransactions.createdAt)
);
const rows: unknown[][] = [ const rows: unknown[][] = [
[ [
'id', 'brand', 'last4', 'cardholderName', 'expiryMonth', 'expiryYear', 'id', 'accountId', 'accountName', 'type', 'amount', 'currency',
'nickname', 'bankAccountId', 'bankAccountName', 'isActive', 'notes', 'occurredAt', 'description', 'reference',
'createdAt', 'updatedAt' 'counterpartyAccountId', 'sourceExpenseId', 'sourceInvoiceId',
'sourceExternalTransactionId', 'fxRate', 'fxAmount', 'createdAt'
] ]
]; ];
for (const c of cardRows) { for (const t of txRows) {
rows.push([ rows.push([
c.id, c.brand, c.last4, c.cardholderName, t.id, t.accountId, t.accountName, t.type, t.amount, t.currency,
c.expiryMonth ?? '', c.expiryYear ?? '', t.occurredAt.toISOString(), t.description ?? '', t.reference ?? '',
c.nickname ?? '', c.bankAccountId ?? '', c.bankAccountName ?? '', t.counterpartyAccountId ?? '', t.sourceExpenseId ?? '',
c.isActive, c.notes ?? '', t.sourceInvoiceId ?? '', t.sourceExternalTransactionId ?? '',
c.createdAt.toISOString(), c.updatedAt.toISOString() t.fxRate ?? '', t.fxAmount ?? '',
t.createdAt.toISOString()
]); ]);
} }
zip.file('company_cards.csv', withBom(csvBuild(rows))); zip.file('company_account_transactions.csv', withBom(csvBuild(rows)));
} }
// ── company_addresses.csv ────────────────────────── // ── company_addresses.csv ──────────────────────────
@@ -35,6 +35,7 @@
: []), : []),
...(data.companyRoles.some((r) => r === 'admin' || r === 'manager' || r === 'accountant') ...(data.companyRoles.some((r) => r === 'admin' || r === 'manager' || r === 'accountant')
? [ ? [
{ href: `/companies/${data.company.id}/accounts`, label: 'Accounts' },
{ href: `/companies/${data.company.id}/profile`, label: 'Profile' }, { href: `/companies/${data.company.id}/profile`, label: 'Profile' },
{ href: `/companies/${data.company.id}/documents`, label: 'Documents' } { href: `/companies/${data.company.id}/documents`, label: 'Documents' }
] ]
@@ -0,0 +1,695 @@
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
companyAccounts,
companyAccountTransactions,
externalAccounts
} from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import {
postTransaction,
postTransfer,
type CompanyAccountTxnType
} from '$lib/server/accounts/ledger.js';
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
const MANUAL_TXN_TYPES = ['deposit', 'adjustment'] as const;
type ManualTxnType = (typeof MANUAL_TXN_TYPES)[number];
function parseManualTxnType(v: FormDataEntryValue | null): ManualTxnType | null {
const s = v?.toString();
if (!s) return null;
return (MANUAL_TXN_TYPES as readonly string[]).includes(s) ? (s as ManualTxnType) : null;
}
function parseDate(v: FormDataEntryValue | null): Date | null {
const s = v?.toString();
if (!s) return null;
const d = new Date(s);
return Number.isNaN(d.getTime()) ? null : d;
}
function parsePositiveAmount(v: FormDataEntryValue | null): string | null {
const s = v?.toString();
if (!s) return null;
const n = Number(s);
if (!Number.isFinite(n) || n <= 0) return null;
return n.toFixed(2);
}
function parseSignedAmount(v: FormDataEntryValue | null): string | null {
const s = v?.toString();
if (!s) return null;
const n = Number(s);
if (!Number.isFinite(n) || n === 0) return null;
return n.toFixed(2);
}
const ACCOUNT_TYPES = [
'bank',
'credit_card',
'cash',
'mobile_money',
'petty_cash',
'loan',
'other'
] as const;
type AccountType = (typeof ACCOUNT_TYPES)[number];
const CARD_BRANDS = ['visa', 'mastercard', 'amex', 'jcb', 'unionpay', 'discover', 'other'] as const;
type CardBrand = (typeof CARD_BRANDS)[number];
function trimOrNull(v: FormDataEntryValue | null): string | null {
const s = v?.toString().trim();
return s ? s : null;
}
function parseIntOrNull(v: FormDataEntryValue | null): number | null {
const s = trimOrNull(v);
if (s === null) return null;
const n = Number.parseInt(s, 10);
return Number.isFinite(n) ? n : null;
}
function parseDecimalOrNull(v: FormDataEntryValue | null): string | null {
const s = trimOrNull(v);
if (s === null) return null;
const n = Number(s);
if (!Number.isFinite(n)) return null;
return n.toFixed(2);
}
function parseAccountType(v: FormDataEntryValue | null): AccountType | null {
const s = v?.toString();
if (!s) return null;
return (ACCOUNT_TYPES as readonly string[]).includes(s) ? (s as AccountType) : null;
}
function parseCardBrand(v: FormDataEntryValue | null): CardBrand | null {
const s = v?.toString();
if (!s) return null;
return (CARD_BRANDS as readonly string[]).includes(s) ? (s as CardBrand) : null;
}
type AccountFields = {
accountType: AccountType;
name: string;
currency: string;
notes: string | null;
bankName: string | null;
accountNumber: string | null;
branch: string | null;
swiftBic: string | null;
iban: string | null;
accountHolderName: string | null;
cardBrand: CardBrand | null;
last4: string | null;
cardholderName: string | null;
expiryMonth: number | null;
expiryYear: number | null;
creditLimit: string | null;
statementCloseDay: number | null;
paymentDueDay: number | null;
externalAccountId: string | null;
};
function extractAccountFields(fd: FormData):
| { ok: true; fields: AccountFields }
| { ok: false; error: string } {
const accountType = parseAccountType(fd.get('accountType'));
if (!accountType) return { ok: false, error: 'Account type is required' };
const name = trimOrNull(fd.get('name'));
if (!name) return { ok: false, error: 'Name is required' };
const currency = trimOrNull(fd.get('currency'))?.toUpperCase() ?? 'THB';
if (!/^[A-Z]{3}$/.test(currency)) return { ok: false, error: 'Currency must be a 3-letter code' };
const last4 = trimOrNull(fd.get('last4'));
if (last4 !== null && !/^\d{4}$/.test(last4)) {
return { ok: false, error: 'Last 4 must be exactly 4 digits' };
}
const expiryMonth = parseIntOrNull(fd.get('expiryMonth'));
if (expiryMonth !== null && (expiryMonth < 1 || expiryMonth > 12)) {
return { ok: false, error: 'Expiry month must be 1-12' };
}
const expiryYear = parseIntOrNull(fd.get('expiryYear'));
if (expiryYear !== null && (expiryYear < 2000 || expiryYear > 2100)) {
return { ok: false, error: 'Expiry year is out of range' };
}
const statementCloseDay = parseIntOrNull(fd.get('statementCloseDay'));
if (statementCloseDay !== null && (statementCloseDay < 1 || statementCloseDay > 31)) {
return { ok: false, error: 'Statement close day must be 1-31' };
}
const paymentDueDay = parseIntOrNull(fd.get('paymentDueDay'));
if (paymentDueDay !== null && (paymentDueDay < 1 || paymentDueDay > 31)) {
return { ok: false, error: 'Payment due day must be 1-31' };
}
return {
ok: true,
fields: {
accountType,
name,
currency,
notes: trimOrNull(fd.get('notes')),
bankName: trimOrNull(fd.get('bankName')),
accountNumber: trimOrNull(fd.get('accountNumber')),
branch: trimOrNull(fd.get('branch')),
swiftBic: trimOrNull(fd.get('swiftBic')),
iban: trimOrNull(fd.get('iban')),
accountHolderName: trimOrNull(fd.get('accountHolderName')),
cardBrand: parseCardBrand(fd.get('cardBrand')),
last4,
cardholderName: trimOrNull(fd.get('cardholderName')),
expiryMonth,
expiryYear,
creditLimit: parseDecimalOrNull(fd.get('creditLimit')),
statementCloseDay,
paymentDueDay,
externalAccountId: trimOrNull(fd.get('externalAccountId'))
}
};
}
type OrderPayload = { id: string; sortOrder: number };
function parseOrderPayload(raw: FormDataEntryValue | null): OrderPayload[] | null {
if (!raw) return null;
let parsed: unknown;
try {
parsed = JSON.parse(raw.toString());
} catch {
return null;
}
if (!Array.isArray(parsed)) return null;
const out: OrderPayload[] = [];
for (const row of parsed) {
if (!row || typeof row !== 'object') return null;
const r = row as Record<string, unknown>;
if (typeof r.id !== 'string' || typeof r.sortOrder !== 'number') return null;
out.push({ id: r.id, sortOrder: r.sortOrder });
}
return out;
}
export const load: PageServerLoad = async ({ locals, params, parent, url }) => {
const { roles } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
await parent();
const showArchived = url.searchParams.get('archived') === '1';
const accountsWithBalance = await db
.select({
id: companyAccounts.id,
accountType: companyAccounts.accountType,
name: companyAccounts.name,
currency: companyAccounts.currency,
isActive: companyAccounts.isActive,
isArchived: companyAccounts.isArchived,
notes: companyAccounts.notes,
sortOrder: companyAccounts.sortOrder,
bankName: companyAccounts.bankName,
accountNumber: companyAccounts.accountNumber,
branch: companyAccounts.branch,
swiftBic: companyAccounts.swiftBic,
iban: companyAccounts.iban,
accountHolderName: companyAccounts.accountHolderName,
cardBrand: companyAccounts.cardBrand,
last4: companyAccounts.last4,
cardholderName: companyAccounts.cardholderName,
expiryMonth: companyAccounts.expiryMonth,
expiryYear: companyAccounts.expiryYear,
creditLimit: companyAccounts.creditLimit,
statementCloseDay: companyAccounts.statementCloseDay,
paymentDueDay: companyAccounts.paymentDueDay,
externalAccountId: companyAccounts.externalAccountId,
createdAt: companyAccounts.createdAt,
balance: sql<string>`coalesce((
select sum(${companyAccountTransactions.amount})
from ${companyAccountTransactions}
where ${companyAccountTransactions.accountId} = ${companyAccounts.id}
), '0')::text`
})
.from(companyAccounts)
.where(
and(eq(companyAccounts.companyId, params.companyId), isNull(companyAccounts.deletedAt))
)
.orderBy(asc(companyAccounts.isArchived), asc(companyAccounts.sortOrder), asc(companyAccounts.name));
const visibleAccounts = showArchived
? accountsWithBalance
: accountsWithBalance.filter((a) => !a.isArchived);
const externalAccountsList = await db
.select({
id: externalAccounts.id,
displayName: externalAccounts.displayName,
provider: externalAccounts.provider
})
.from(externalAccounts)
.where(and(eq(externalAccounts.companyId, params.companyId), eq(externalAccounts.isActive, true)));
const canDelete = roles.includes('admin');
return {
accounts: visibleAccounts,
archivedCount: accountsWithBalance.filter((a) => a.isArchived).length,
showArchived,
externalAccounts: externalAccountsList,
canDelete
};
};
async function nextAccountSortOrder(companyId: string): Promise<number> {
const [row] = await db
.select({ max: sql<number>`coalesce(max(${companyAccounts.sortOrder}), -1)::int` })
.from(companyAccounts)
.where(and(eq(companyAccounts.companyId, companyId), isNull(companyAccounts.deletedAt)));
return (row?.max ?? -1) + 1;
}
export const actions: Actions = {
addAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const parsed = extractAccountFields(fd);
if (!parsed.ok) return fail(400, { action: 'addAccount', error: parsed.error });
const f = parsed.fields;
const sortOrder = await nextAccountSortOrder(params.companyId);
const openingBalance = parseSignedAmount(fd.get('openingBalance'));
const openingBalanceDate =
parseDate(fd.get('openingBalanceDate')) ?? new Date();
const inserted = await db.transaction(async (tx) => {
const [row] = await tx
.insert(companyAccounts)
.values({
companyId: params.companyId,
sortOrder,
createdBy: user.id,
accountType: f.accountType,
name: f.name,
currency: f.currency,
notes: f.notes,
bankName: f.bankName,
accountNumber: f.accountNumber,
branch: f.branch,
swiftBic: f.swiftBic,
iban: f.iban,
accountHolderName: f.accountHolderName,
cardBrand: f.cardBrand,
last4: f.last4,
cardholderName: f.cardholderName,
expiryMonth: f.expiryMonth,
expiryYear: f.expiryYear,
creditLimit: f.creditLimit,
statementCloseDay: f.statementCloseDay,
paymentDueDay: f.paymentDueDay,
externalAccountId: f.externalAccountId
})
.returning({ id: companyAccounts.id });
if (openingBalance !== null) {
await postTransaction(tx, {
accountId: row.id,
companyId: params.companyId,
type: 'opening_balance',
amount: openingBalance,
currency: f.currency,
occurredAt: openingBalanceDate,
description: 'Opening balance',
createdBy: user.id
});
}
return row;
});
await logCompanyEvent(
params.companyId,
user.id,
'account_created',
`Account "${parsed.fields.name}" created${openingBalance !== null ? ` with opening balance ${openingBalance} ${f.currency}` : ''}`,
{ accountId: inserted.id, accountType: parsed.fields.accountType, openingBalance }
);
return { success: true, action: 'addAccount' };
},
updateAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'updateAccount', error: 'Account id is required' });
const [existing] = await db
.select({ id: companyAccounts.id })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, id),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Account not found');
const parsed = extractAccountFields(fd);
if (!parsed.ok) return fail(400, { action: 'updateAccount', error: parsed.error });
const f = parsed.fields;
await db
.update(companyAccounts)
.set({
accountType: f.accountType,
name: f.name,
currency: f.currency,
notes: f.notes,
bankName: f.bankName,
accountNumber: f.accountNumber,
branch: f.branch,
swiftBic: f.swiftBic,
iban: f.iban,
accountHolderName: f.accountHolderName,
cardBrand: f.cardBrand,
last4: f.last4,
cardholderName: f.cardholderName,
expiryMonth: f.expiryMonth,
expiryYear: f.expiryYear,
creditLimit: f.creditLimit,
statementCloseDay: f.statementCloseDay,
paymentDueDay: f.paymentDueDay,
externalAccountId: f.externalAccountId,
updatedAt: new Date()
})
.where(eq(companyAccounts.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'account_updated',
`Account "${parsed.fields.name}" updated`,
{ accountId: id }
);
return { success: true, action: 'updateAccount' };
},
archiveAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'archiveAccount', error: 'Account id is required' });
const [existing] = await db
.select({ id: companyAccounts.id, name: companyAccounts.name })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, id),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Account not found');
await db
.update(companyAccounts)
.set({ isArchived: true, updatedAt: new Date() })
.where(eq(companyAccounts.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'account_archived',
`Account "${existing.name}" archived`,
{ accountId: id }
);
return { success: true, action: 'archiveAccount' };
},
unarchiveAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'unarchiveAccount', error: 'Account id is required' });
const [existing] = await db
.select({ id: companyAccounts.id, name: companyAccounts.name })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, id),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Account not found');
await db
.update(companyAccounts)
.set({ isArchived: false, updatedAt: new Date() })
.where(eq(companyAccounts.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'account_updated',
`Account "${existing.name}" unarchived`,
{ accountId: id }
);
return { success: true, action: 'unarchiveAccount' };
},
deleteAccount: async ({ request, locals, params }) => {
const { user, roles } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
if (!roles.includes('admin')) {
return fail(403, { action: 'deleteAccount', error: 'Only admins can delete accounts' });
}
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'deleteAccount', error: 'Account id is required' });
const [existing] = await db
.select({ id: companyAccounts.id, name: companyAccounts.name })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, id),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Account not found');
const [txnCount] = await db
.select({ count: sql<number>`count(*)::int` })
.from(companyAccountTransactions)
.where(eq(companyAccountTransactions.accountId, id));
if ((txnCount?.count ?? 0) > 0) {
return fail(409, {
action: 'deleteAccount',
error: 'Cannot delete an account that has transactions. Archive it instead.'
});
}
await db
.update(companyAccounts)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(eq(companyAccounts.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'account_deleted',
`Account "${existing.name}" deleted`,
{ accountId: id }
);
return { success: true, action: 'deleteAccount' };
},
reorderAccounts: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const fd = await request.formData();
const payload = parseOrderPayload(fd.get('orders'));
if (!payload) return fail(400, { action: 'reorderAccounts', error: 'Invalid order payload' });
await db.transaction(async (tx) => {
for (const { id, sortOrder } of payload) {
await tx
.update(companyAccounts)
.set({ sortOrder, updatedAt: new Date() })
.where(
and(
eq(companyAccounts.id, id),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
);
}
});
return { success: true, action: 'reorderAccounts' };
},
postTransfer: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const fromAccountId = trimOrNull(fd.get('fromAccountId'));
const toAccountId = trimOrNull(fd.get('toAccountId'));
const amount = parsePositiveAmount(fd.get('amount'));
const occurredAt = parseDate(fd.get('occurredAt'));
const description = trimOrNull(fd.get('description'));
const reference = trimOrNull(fd.get('reference'));
const fxRate = trimOrNull(fd.get('fxRate'));
const destinationAmount = trimOrNull(fd.get('destinationAmount'));
if (!fromAccountId || !toAccountId) {
return fail(400, { action: 'postTransfer', error: 'Both from and to accounts are required' });
}
if (fromAccountId === toAccountId) {
return fail(400, { action: 'postTransfer', error: 'From and to accounts must differ' });
}
if (!amount) {
return fail(400, { action: 'postTransfer', error: 'Amount must be a positive number' });
}
if (!occurredAt) {
return fail(400, { action: 'postTransfer', error: 'Valid date is required' });
}
try {
await postTransfer({
fromAccountId,
toAccountId,
companyId: params.companyId,
amount,
occurredAt,
description,
reference,
fxRate,
destinationAmount,
createdBy: user.id
});
} catch (err) {
const msg = err instanceof Error ? err.message : 'Transfer failed';
return fail(400, { action: 'postTransfer', error: msg });
}
await logCompanyEvent(
params.companyId,
user.id,
'account_transfer_posted',
`Transferred ${amount} from account ${fromAccountId} to ${toAccountId}`,
{ fromAccountId, toAccountId, amount, fxRate, destinationAmount }
);
return { success: true, action: 'postTransfer' };
},
addManualTransaction: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const accountId = trimOrNull(fd.get('accountId'));
const type = parseManualTxnType(fd.get('type'));
const amount = parseSignedAmount(fd.get('amount'));
const occurredAt = parseDate(fd.get('occurredAt'));
const description = trimOrNull(fd.get('description'));
const reference = trimOrNull(fd.get('reference'));
if (!accountId) {
return fail(400, { action: 'addManualTransaction', error: 'Account is required' });
}
if (!type) {
return fail(400, {
action: 'addManualTransaction',
error: 'Type must be deposit or adjustment'
});
}
if (!amount) {
return fail(400, {
action: 'addManualTransaction',
error: 'Amount must be a non-zero number'
});
}
if (!occurredAt) {
return fail(400, { action: 'addManualTransaction', error: 'Valid date is required' });
}
const [acct] = await db
.select({
id: companyAccounts.id,
currency: companyAccounts.currency,
name: companyAccounts.name
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, accountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!acct) error(404, 'Account not found');
const txnType: CompanyAccountTxnType = type;
await postTransaction(db, {
accountId,
companyId: params.companyId,
type: txnType,
amount,
currency: acct.currency,
occurredAt,
description,
reference,
createdBy: user.id
});
await logCompanyEvent(
params.companyId,
user.id,
'account_transaction_added',
`${type} of ${amount} ${acct.currency} recorded on "${acct.name}"`,
{ accountId, type, amount }
);
return { success: true, action: 'addManualTransaction' };
}
};
@@ -0,0 +1,928 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
type AccountType =
| 'bank'
| 'credit_card'
| 'cash'
| 'mobile_money'
| 'petty_cash'
| 'loan'
| 'other';
const ACCOUNT_TYPE_LABELS: Record<AccountType, string> = {
bank: 'Bank Account',
credit_card: 'Credit Card',
cash: 'Cash',
mobile_money: 'Mobile Money',
petty_cash: 'Petty Cash',
loan: 'Loan',
other: 'Other'
};
const ACCOUNT_TYPE_BADGE: Record<AccountType, string> = {
bank: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
credit_card: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
cash: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
mobile_money: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300',
petty_cash: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
loan: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300',
other: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
};
const CARD_BRANDS = ['visa', 'mastercard', 'amex', 'jcb', 'unionpay', 'discover', 'other'];
const ACCOUNT_TYPES = Object.keys(ACCOUNT_TYPE_LABELS) as AccountType[];
let showAddForm = $state(false);
let showTransferModal = $state(false);
let showManualTxnModal = $state(false);
let editingId = $state<string | null>(null);
let confirmDeleteId = $state<string | null>(null);
let addType = $state<AccountType>('bank');
let transferFrom = $state<string>('');
let transferTo = $state<string>('');
const activeAccounts = $derived(data.accounts.filter((a) => !a.isArchived));
const fromAccount = $derived(activeAccounts.find((a) => a.id === transferFrom));
const toAccount = $derived(activeAccounts.find((a) => a.id === transferTo));
const isCrossCurrency = $derived(
fromAccount && toAccount && fromAccount.currency !== toAccount.currency
);
function openAdd() {
showAddForm = !showAddForm;
showTransferModal = false;
showManualTxnModal = false;
editingId = null;
confirmDeleteId = null;
}
function openTransfer() {
showTransferModal = true;
showAddForm = false;
showManualTxnModal = false;
editingId = null;
confirmDeleteId = null;
if (activeAccounts.length >= 2) {
transferFrom = activeAccounts[0].id;
transferTo = activeAccounts[1].id;
}
}
function openManualTxn() {
showManualTxnModal = true;
showAddForm = false;
showTransferModal = false;
editingId = null;
confirmDeleteId = null;
}
function todayIso(): string {
const d = new Date();
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
function formatAmount(amount: string, currency: string): string {
const n = Number(amount);
const fmt = new Intl.NumberFormat(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
return `${fmt.format(n)} ${currency}`;
}
function balanceClass(amount: string): string {
const n = Number(amount);
if (n > 0) return 'text-emerald-600 dark:text-emerald-400';
if (n < 0) return 'text-red-600 dark:text-red-400';
return 'text-gray-500 dark:text-gray-400';
}
function utilisation(balance: string, limit: string | null): number | null {
if (!limit) return null;
const lim = Number(limit);
const bal = Number(balance);
if (!Number.isFinite(lim) || lim <= 0) return null;
const used = Math.max(0, -bal);
return Math.min(100, Math.round((used / lim) * 100));
}
const inputCls =
'w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white';
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
</script>
<svelte:head>
<title>Accounts - {data.company.name}</title>
</svelte:head>
{#snippet accountFields(
type: AccountType,
prefix: string,
prefill: {
name?: string;
currency?: string;
notes?: string | null;
bankName?: string | null;
accountNumber?: string | null;
branch?: string | null;
swiftBic?: string | null;
iban?: string | null;
accountHolderName?: string | null;
cardBrand?: string | null;
last4?: string | null;
cardholderName?: string | null;
expiryMonth?: number | null;
expiryYear?: number | null;
creditLimit?: string | null;
statementCloseDay?: number | null;
paymentDueDay?: number | null;
externalAccountId?: string | null;
} = {}
)}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="md:col-span-2">
<label for="{prefix}-name" class={labelCls}>Name <span class="text-red-500">*</span></label>
<input
id="{prefix}-name"
name="name"
type="text"
required
value={prefill.name ?? ''}
placeholder={type === 'bank'
? 'e.g. KBank Main'
: type === 'credit_card'
? 'e.g. SCB Platinum •••• 4242'
: 'Account name'}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-currency" class={labelCls}>Currency <span class="text-red-500">*</span></label>
<input
id="{prefix}-currency"
name="currency"
type="text"
required
maxlength="3"
value={prefill.currency ?? 'THB'}
placeholder="THB"
class={inputCls}
/>
</div>
<div></div>
{#if type === 'bank'}
<div>
<label for="{prefix}-bankName" class={labelCls}>Bank Name</label>
<input
id="{prefix}-bankName"
name="bankName"
type="text"
value={prefill.bankName ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-accountNumber" class={labelCls}>Account Number</label>
<input
id="{prefix}-accountNumber"
name="accountNumber"
type="text"
value={prefill.accountNumber ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-branch" class={labelCls}>Branch</label>
<input
id="{prefix}-branch"
name="branch"
type="text"
value={prefill.branch ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-holder" class={labelCls}>Account Holder</label>
<input
id="{prefix}-holder"
name="accountHolderName"
type="text"
value={prefill.accountHolderName ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-swift" class={labelCls}>SWIFT/BIC</label>
<input
id="{prefix}-swift"
name="swiftBic"
type="text"
value={prefill.swiftBic ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-iban" class={labelCls}>IBAN</label>
<input
id="{prefix}-iban"
name="iban"
type="text"
value={prefill.iban ?? ''}
class={inputCls}
/>
</div>
{#if data.externalAccounts.length > 0}
<div class="md:col-span-2">
<label for="{prefix}-extAccount" class={labelCls}>Linked Bank Integration</label>
<select
id="{prefix}-extAccount"
name="externalAccountId"
class={inputCls}
value={prefill.externalAccountId ?? ''}
>
<option value="">— none —</option>
{#each data.externalAccounts as ea (ea.id)}
<option value={ea.id}>{ea.displayName} ({ea.provider})</option>
{/each}
</select>
</div>
{/if}
{/if}
{#if type === 'credit_card'}
<div>
<label for="{prefix}-brand" class={labelCls}>Card Brand</label>
<select id="{prefix}-brand" name="cardBrand" class={inputCls} value={prefill.cardBrand ?? 'visa'}>
{#each CARD_BRANDS as b (b)}
<option value={b}>{b.toUpperCase()}</option>
{/each}
</select>
</div>
<div>
<label for="{prefix}-last4" class={labelCls}>Last 4 Digits</label>
<input
id="{prefix}-last4"
name="last4"
type="text"
maxlength="4"
pattern="[0-9]{'{'}4{'}'}"
value={prefill.last4 ?? ''}
class={inputCls}
/>
</div>
<div class="md:col-span-2">
<label for="{prefix}-cardholder" class={labelCls}>Cardholder Name</label>
<input
id="{prefix}-cardholder"
name="cardholderName"
type="text"
value={prefill.cardholderName ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-expMonth" class={labelCls}>Expiry Month</label>
<input
id="{prefix}-expMonth"
name="expiryMonth"
type="number"
min="1"
max="12"
value={prefill.expiryMonth ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-expYear" class={labelCls}>Expiry Year</label>
<input
id="{prefix}-expYear"
name="expiryYear"
type="number"
min="2000"
max="2100"
value={prefill.expiryYear ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-limit" class={labelCls}>Credit Limit</label>
<input
id="{prefix}-limit"
name="creditLimit"
type="number"
step="0.01"
min="0"
value={prefill.creditLimit ?? ''}
class={inputCls}
/>
</div>
<div></div>
<div>
<label for="{prefix}-stmtClose" class={labelCls}>Statement Close Day</label>
<input
id="{prefix}-stmtClose"
name="statementCloseDay"
type="number"
min="1"
max="31"
value={prefill.statementCloseDay ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-payDue" class={labelCls}>Payment Due Day</label>
<input
id="{prefix}-payDue"
name="paymentDueDay"
type="number"
min="1"
max="31"
value={prefill.paymentDueDay ?? ''}
class={inputCls}
/>
</div>
{/if}
<div class="md:col-span-2">
<label for="{prefix}-notes" class={labelCls}>Notes</label>
<textarea id="{prefix}-notes" name="notes" rows="2" class={inputCls}>{prefill.notes ?? ''}</textarea>
</div>
</div>
{/snippet}
<div class="space-y-6">
<header class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Accounts</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Bank accounts, cards, cash, and any other fund source. Balances update automatically from
expenses, invoice payments, transfers, and manual entries.
</p>
</div>
<div class="flex flex-wrap gap-2">
{#if activeAccounts.length >= 1}
<button
type="button"
onclick={openManualTxn}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Record Transaction
</button>
{/if}
{#if activeAccounts.length >= 2}
<button
type="button"
onclick={openTransfer}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Transfer
</button>
{/if}
<button
type="button"
onclick={openAdd}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
{showAddForm ? 'Cancel' : '+ New Account'}
</button>
</div>
</header>
{#if form?.error}
<div
class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300"
>
{form.error}
</div>
{/if}
{#if showAddForm}
<form
method="POST"
action="?/addAccount"
use:enhance={() => async ({ result, update, formElement }) => {
await update({ reset: false });
if (result.type === 'success') {
showAddForm = false;
formElement.reset();
}
}}
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<h2 class="mb-4 font-semibold text-gray-900 dark:text-white">New Account</h2>
<div class="mb-4">
<label for="add-accountType" class={labelCls}
>Account Type <span class="text-red-500">*</span></label
>
<select
id="add-accountType"
name="accountType"
required
bind:value={addType}
class={inputCls}
>
{#each ACCOUNT_TYPES as t (t)}
<option value={t}>{ACCOUNT_TYPE_LABELS[t]}</option>
{/each}
</select>
</div>
{@render accountFields(addType, 'add')}
<fieldset class="mt-4 rounded-md border border-gray-200 p-3 dark:border-gray-600">
<legend class="px-2 text-xs font-medium text-gray-500 dark:text-gray-400">
Opening balance (optional)
</legend>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="add-openingBalance" class={labelCls}>
Opening Balance
<span class="ml-1 text-xs text-gray-400">(negative for credit-card debt)</span>
</label>
<input
id="add-openingBalance"
name="openingBalance"
type="number"
step="0.01"
placeholder="0.00"
class={inputCls}
/>
</div>
<div>
<label for="add-openingBalanceDate" class={labelCls}>As of Date</label>
<input
id="add-openingBalanceDate"
name="openingBalanceDate"
type="date"
value={todayIso()}
class={inputCls}
/>
</div>
</div>
</fieldset>
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
onclick={() => (showAddForm = false)}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Create Account
</button>
</div>
</form>
{/if}
{#if showTransferModal}
<form
method="POST"
action="?/postTransfer"
use:enhance={() => async ({ result, update, formElement }) => {
await update({ reset: false });
if (result.type === 'success') {
showTransferModal = false;
formElement.reset();
}
}}
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<h2 class="mb-4 font-semibold text-gray-900 dark:text-white">Transfer Between Accounts</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="xfer-from" class={labelCls}>From <span class="text-red-500">*</span></label>
<select id="xfer-from" name="fromAccountId" required bind:value={transferFrom} class={inputCls}>
{#each activeAccounts as a (a.id)}
<option value={a.id}>{a.name} ({a.currency})</option>
{/each}
</select>
</div>
<div>
<label for="xfer-to" class={labelCls}>To <span class="text-red-500">*</span></label>
<select id="xfer-to" name="toAccountId" required bind:value={transferTo} class={inputCls}>
{#each activeAccounts as a (a.id)}
<option value={a.id}>{a.name} ({a.currency})</option>
{/each}
</select>
</div>
<div>
<label for="xfer-amount" class={labelCls}>
Amount <span class="text-red-500">*</span>
{#if fromAccount}<span class="ml-1 text-xs text-gray-400">({fromAccount.currency})</span>{/if}
</label>
<input
id="xfer-amount"
name="amount"
type="number"
step="0.01"
min="0.01"
required
class={inputCls}
/>
</div>
<div>
<label for="xfer-date" class={labelCls}>Date <span class="text-red-500">*</span></label>
<input
id="xfer-date"
name="occurredAt"
type="date"
value={todayIso()}
required
class={inputCls}
/>
</div>
{#if isCrossCurrency}
<div class="md:col-span-2">
<p class="mb-2 text-xs text-amber-600 dark:text-amber-400">
Cross-currency transfer: enter either an FX rate OR a destination amount.
</p>
</div>
<div>
<label for="xfer-rate" class={labelCls}>
FX Rate
{#if fromAccount && toAccount}
<span class="ml-1 text-xs text-gray-400"
>(1 {fromAccount.currency} = ? {toAccount.currency})</span
>
{/if}
</label>
<input
id="xfer-rate"
name="fxRate"
type="number"
step="0.00000001"
min="0"
placeholder="e.g. 36.5"
class={inputCls}
/>
</div>
<div>
<label for="xfer-destAmt" class={labelCls}>
— or — Destination Amount
{#if toAccount}<span class="ml-1 text-xs text-gray-400">({toAccount.currency})</span>{/if}
</label>
<input
id="xfer-destAmt"
name="destinationAmount"
type="number"
step="0.01"
min="0"
placeholder="e.g. 36500.00"
class={inputCls}
/>
</div>
{/if}
<div class="md:col-span-2">
<label for="xfer-description" class={labelCls}>Description</label>
<input
id="xfer-description"
name="description"
type="text"
placeholder="Optional note"
class={inputCls}
/>
</div>
<div class="md:col-span-2">
<label for="xfer-reference" class={labelCls}>Reference</label>
<input id="xfer-reference" name="reference" type="text" class={inputCls} />
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
onclick={() => (showTransferModal = false)}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Post Transfer
</button>
</div>
</form>
{/if}
{#if showManualTxnModal}
<form
method="POST"
action="?/addManualTransaction"
use:enhance={() => async ({ result, update, formElement }) => {
await update({ reset: false });
if (result.type === 'success') {
showManualTxnModal = false;
formElement.reset();
}
}}
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<h2 class="mb-4 font-semibold text-gray-900 dark:text-white">Record Transaction</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="mtxn-account" class={labelCls}>Account <span class="text-red-500">*</span></label>
<select id="mtxn-account" name="accountId" required class={inputCls}>
{#each activeAccounts as a (a.id)}
<option value={a.id}>{a.name} ({a.currency})</option>
{/each}
</select>
</div>
<div>
<label for="mtxn-type" class={labelCls}>Type <span class="text-red-500">*</span></label>
<select id="mtxn-type" name="type" required class={inputCls}>
<option value="deposit">Deposit (credit)</option>
<option value="adjustment">Adjustment (debit or credit)</option>
</select>
</div>
<div>
<label for="mtxn-amount" class={labelCls}>
Amount <span class="text-red-500">*</span>
<span class="ml-1 text-xs text-gray-400">(positive = credit, negative = debit)</span>
</label>
<input
id="mtxn-amount"
name="amount"
type="number"
step="0.01"
required
class={inputCls}
/>
</div>
<div>
<label for="mtxn-date" class={labelCls}>Date <span class="text-red-500">*</span></label>
<input
id="mtxn-date"
name="occurredAt"
type="date"
value={todayIso()}
required
class={inputCls}
/>
</div>
<div class="md:col-span-2">
<label for="mtxn-description" class={labelCls}>Description</label>
<input id="mtxn-description" name="description" type="text" class={inputCls} />
</div>
<div class="md:col-span-2">
<label for="mtxn-reference" class={labelCls}>Reference</label>
<input id="mtxn-reference" name="reference" type="text" class={inputCls} />
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
onclick={() => (showManualTxnModal = false)}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Record
</button>
</div>
</form>
{/if}
{#if data.accounts.length === 0}
<div
class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-sm text-gray-500 dark:text-gray-400">
No accounts yet. Click "+ New Account" to add one.
</p>
</div>
{:else}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each data.accounts as acct (acct.id)}
<div
class="flex flex-col gap-2 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 {acct.isArchived
? 'opacity-60'
: ''}"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<h3 class="truncate text-sm font-semibold text-gray-900 dark:text-white">
<a
href={`/companies/${data.company.id}/accounts/${acct.id}`}
class="hover:text-blue-600 dark:hover:text-blue-400"
>
{acct.name}
</a>
</h3>
<div class="mt-1 flex flex-wrap items-center gap-1.5">
<span
class="rounded-full px-2 py-0.5 text-xs font-medium {ACCOUNT_TYPE_BADGE[
acct.accountType
]}"
>
{ACCOUNT_TYPE_LABELS[acct.accountType]}
</span>
{#if acct.isArchived}
<span
class="rounded-full bg-gray-200 px-2 py-0.5 text-xs font-medium text-gray-700 dark:bg-gray-600 dark:text-gray-300"
>
Archived
</span>
{/if}
</div>
</div>
</div>
<p class="text-2xl font-bold {balanceClass(acct.balance)}">
{formatAmount(acct.balance, acct.currency)}
</p>
{#if acct.accountType === 'credit_card' && acct.creditLimit}
{@const pct = utilisation(acct.balance, acct.creditLimit)}
<div class="text-xs text-gray-500 dark:text-gray-400">
Limit {formatAmount(acct.creditLimit, acct.currency)}
{#if pct !== null}
· {pct}% used
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full {pct > 80 ? 'bg-red-500' : pct > 50 ? 'bg-amber-500' : 'bg-emerald-500'}"
style="width: {pct}%"
></div>
</div>
{/if}
</div>
{/if}
{#if acct.accountType === 'credit_card' && (acct.statementCloseDay || acct.paymentDueDay)}
<p class="text-xs text-gray-500 dark:text-gray-400">
{#if acct.statementCloseDay}Closes day {acct.statementCloseDay}{/if}
{#if acct.statementCloseDay && acct.paymentDueDay} · {/if}
{#if acct.paymentDueDay}Due day {acct.paymentDueDay}{/if}
</p>
{/if}
{#if acct.accountType === 'bank' && (acct.bankName || acct.accountNumber)}
<p class="truncate text-xs text-gray-500 dark:text-gray-400">
{acct.bankName ?? ''}{#if acct.accountNumber} · {acct.accountNumber}{/if}
</p>
{/if}
<div
class="mt-auto flex flex-wrap justify-end gap-2 border-t border-gray-100 pt-2 dark:border-gray-700"
>
<button
type="button"
onclick={() => {
editingId = editingId === acct.id ? null : acct.id;
confirmDeleteId = null;
}}
class="text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
Edit
</button>
{#if acct.isArchived}
<form method="POST" action="?/unarchiveAccount" use:enhance>
<input type="hidden" name="id" value={acct.id} />
<button
type="submit"
class="text-xs font-medium text-gray-600 hover:text-gray-800 dark:text-gray-300"
>
Unarchive
</button>
</form>
{:else}
<form method="POST" action="?/archiveAccount" use:enhance>
<input type="hidden" name="id" value={acct.id} />
<button
type="submit"
class="text-xs font-medium text-gray-600 hover:text-gray-800 dark:text-gray-300"
>
Archive
</button>
</form>
{/if}
{#if data.canDelete}
<button
type="button"
onclick={() => (confirmDeleteId = confirmDeleteId === acct.id ? null : acct.id)}
class="text-xs font-medium text-red-600 hover:text-red-700 dark:text-red-400"
>
Delete
</button>
{/if}
</div>
{#if confirmDeleteId === acct.id}
<form
method="POST"
action="?/deleteAccount"
use:enhance={() => async ({ update }) => {
await update({ reset: false });
confirmDeleteId = null;
}}
class="mt-2 rounded-md bg-red-50 p-2 text-xs dark:bg-red-900/30"
>
<input type="hidden" name="id" value={acct.id} />
<p class="mb-2 text-red-700 dark:text-red-300">
Delete "{acct.name}"? This only works if the account has zero transactions.
</p>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (confirmDeleteId = null)}
class="rounded border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
Cancel
</button>
<button
type="submit"
class="rounded bg-red-600 px-2 py-1 text-xs font-medium text-white hover:bg-red-700"
>
Delete
</button>
</div>
</form>
{/if}
{#if editingId === acct.id}
<form
method="POST"
action="?/updateAccount"
use:enhance={() => async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') editingId = null;
}}
class="mt-2 rounded-md bg-gray-50 p-3 dark:bg-gray-700/50"
>
<input type="hidden" name="id" value={acct.id} />
<input type="hidden" name="accountType" value={acct.accountType} />
{@render accountFields(acct.accountType, 'edit-' + acct.id, {
name: acct.name,
currency: acct.currency,
notes: acct.notes,
bankName: acct.bankName,
accountNumber: acct.accountNumber,
branch: acct.branch,
swiftBic: acct.swiftBic,
iban: acct.iban,
accountHolderName: acct.accountHolderName,
cardBrand: acct.cardBrand,
last4: acct.last4,
cardholderName: acct.cardholderName,
expiryMonth: acct.expiryMonth,
expiryYear: acct.expiryYear,
creditLimit: acct.creditLimit,
statementCloseDay: acct.statementCloseDay,
paymentDueDay: acct.paymentDueDay,
externalAccountId: acct.externalAccountId
})}
<div class="mt-3 flex justify-end gap-2">
<button
type="button"
onclick={() => (editingId = null)}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-600"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Save
</button>
</div>
</form>
{/if}
</div>
{/each}
</div>
{/if}
{#if data.archivedCount > 0}
<div class="flex justify-center">
<a
href={data.showArchived
? `/companies/${data.company.id}/accounts`
: `/companies/${data.company.id}/accounts?archived=1`}
class="text-xs text-gray-500 underline hover:text-gray-700 dark:text-gray-400"
>
{data.showArchived ? 'Hide' : 'Show'} archived ({data.archivedCount})
</a>
</div>
{/if}
</div>
@@ -0,0 +1,324 @@
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
companyAccounts,
companyAccountTransactions,
users
} from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { postTransaction } from '$lib/server/accounts/ledger.js';
import { alias } from 'drizzle-orm/pg-core';
import { and, desc, eq, gte, ilike, isNull, lte, or, sql } from 'drizzle-orm';
const ALL_TYPES = [
'opening_balance',
'expense',
'invoice_payment',
'transfer_in',
'transfer_out',
'deposit',
'adjustment',
'reconciliation'
] as const;
type TxnType = (typeof ALL_TYPES)[number];
const EDITABLE_TYPES: readonly TxnType[] = ['deposit', 'adjustment'];
function trimOrNull(v: FormDataEntryValue | null): string | null {
const s = v?.toString().trim();
return s ? s : null;
}
function parseSignedAmount(v: FormDataEntryValue | null): string | null {
const s = v?.toString();
if (!s) return null;
const n = Number(s);
if (!Number.isFinite(n) || n === 0) return null;
return n.toFixed(2);
}
function parseDate(v: FormDataEntryValue | null): Date | null {
const s = v?.toString();
if (!s) return null;
const d = new Date(s);
return Number.isNaN(d.getTime()) ? null : d;
}
function parseTxnType(v: FormDataEntryValue | null): TxnType | null {
const s = v?.toString();
if (!s) return null;
return (ALL_TYPES as readonly string[]).includes(s) ? (s as TxnType) : null;
}
const PAGE_SIZE = 50;
export const load: PageServerLoad = async ({ locals, params, parent, url }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
await parent();
const [account] = await db
.select()
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, params.accountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!account) error(404, 'Account not found');
const fromParam = url.searchParams.get('from');
const toParam = url.searchParams.get('to');
const typeParam = url.searchParams.get('type');
const qParam = url.searchParams.get('q');
const page = Math.max(1, Number(url.searchParams.get('page') ?? '1') || 1);
const conditions = [eq(companyAccountTransactions.accountId, params.accountId)];
if (fromParam) conditions.push(gte(companyAccountTransactions.occurredAt, new Date(fromParam)));
if (toParam) {
const toDate = new Date(toParam);
toDate.setHours(23, 59, 59, 999);
conditions.push(lte(companyAccountTransactions.occurredAt, toDate));
}
if (typeParam && (ALL_TYPES as readonly string[]).includes(typeParam)) {
conditions.push(eq(companyAccountTransactions.type, typeParam as TxnType));
}
if (qParam && qParam.trim()) {
const pattern = `%${qParam.trim()}%`;
conditions.push(
or(
ilike(companyAccountTransactions.description, pattern),
ilike(companyAccountTransactions.reference, pattern)
)!
);
}
const [totalRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(companyAccountTransactions)
.where(and(...conditions));
const totalCount = totalRow?.count ?? 0;
const counterparty = alias(companyAccounts, 'counterparty');
const transactions = await db
.select({
id: companyAccountTransactions.id,
type: companyAccountTransactions.type,
amount: companyAccountTransactions.amount,
currency: companyAccountTransactions.currency,
occurredAt: companyAccountTransactions.occurredAt,
description: companyAccountTransactions.description,
reference: companyAccountTransactions.reference,
counterpartyAccountId: companyAccountTransactions.counterpartyAccountId,
counterpartyName: counterparty.name,
sourceExpenseId: companyAccountTransactions.sourceExpenseId,
sourceInvoiceId: companyAccountTransactions.sourceInvoiceId,
sourceExternalTransactionId: companyAccountTransactions.sourceExternalTransactionId,
fxRate: companyAccountTransactions.fxRate,
fxAmount: companyAccountTransactions.fxAmount,
createdByName: users.displayName,
createdByEmail: users.email,
createdAt: companyAccountTransactions.createdAt
})
.from(companyAccountTransactions)
.leftJoin(counterparty, eq(companyAccountTransactions.counterpartyAccountId, counterparty.id))
.leftJoin(users, eq(companyAccountTransactions.createdBy, users.id))
.where(and(...conditions))
.orderBy(desc(companyAccountTransactions.occurredAt), desc(companyAccountTransactions.createdAt))
.limit(PAGE_SIZE)
.offset((page - 1) * PAGE_SIZE);
const [balanceRow] = await db
.select({
total: sql<string>`coalesce(sum(${companyAccountTransactions.amount}), '0')::text`
})
.from(companyAccountTransactions)
.where(eq(companyAccountTransactions.accountId, params.accountId));
return {
account,
transactions,
balance: balanceRow?.total ?? '0',
totalCount,
page,
pageSize: PAGE_SIZE,
filters: {
from: fromParam ?? '',
to: toParam ?? '',
type: typeParam ?? '',
q: qParam ?? ''
}
};
};
export const actions: Actions = {
addManualTransaction: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const type = parseTxnType(fd.get('type'));
const amount = parseSignedAmount(fd.get('amount'));
const occurredAt = parseDate(fd.get('occurredAt'));
const description = trimOrNull(fd.get('description'));
const reference = trimOrNull(fd.get('reference'));
if (!type || !(EDITABLE_TYPES as readonly string[]).includes(type)) {
return fail(400, {
action: 'addManualTransaction',
error: 'Type must be deposit or adjustment'
});
}
if (!amount) {
return fail(400, {
action: 'addManualTransaction',
error: 'Amount must be a non-zero number'
});
}
if (!occurredAt) {
return fail(400, { action: 'addManualTransaction', error: 'Valid date is required' });
}
const [acct] = await db
.select({
id: companyAccounts.id,
currency: companyAccounts.currency,
name: companyAccounts.name
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, params.accountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!acct) error(404, 'Account not found');
await postTransaction(db, {
accountId: params.accountId,
companyId: params.companyId,
type,
amount,
currency: acct.currency,
occurredAt,
description,
reference,
createdBy: user.id
});
await logCompanyEvent(
params.companyId,
user.id,
'account_transaction_added',
`${type} of ${amount} ${acct.currency} recorded on "${acct.name}"`,
{ accountId: params.accountId, type, amount }
);
return { success: true, action: 'addManualTransaction' };
},
editTransaction: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
const amount = parseSignedAmount(fd.get('amount'));
const occurredAt = parseDate(fd.get('occurredAt'));
const description = trimOrNull(fd.get('description'));
const reference = trimOrNull(fd.get('reference'));
if (!id) return fail(400, { action: 'editTransaction', error: 'Transaction id is required' });
if (!amount) {
return fail(400, { action: 'editTransaction', error: 'Amount must be a non-zero number' });
}
if (!occurredAt) {
return fail(400, { action: 'editTransaction', error: 'Valid date is required' });
}
const [existing] = await db
.select({ id: companyAccountTransactions.id, type: companyAccountTransactions.type })
.from(companyAccountTransactions)
.where(
and(
eq(companyAccountTransactions.id, id),
eq(companyAccountTransactions.accountId, params.accountId)
)
)
.limit(1);
if (!existing) error(404, 'Transaction not found');
if (!(EDITABLE_TYPES as readonly string[]).includes(existing.type)) {
return fail(400, {
action: 'editTransaction',
error: 'This transaction type cannot be edited (auto-posted from expense/invoice/transfer)'
});
}
await db
.update(companyAccountTransactions)
.set({ amount, occurredAt, description, reference, updatedAt: new Date() })
.where(eq(companyAccountTransactions.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'account_transaction_added',
`Transaction edited on account ${params.accountId}`,
{ accountId: params.accountId, transactionId: id }
);
return { success: true, action: 'editTransaction' };
},
deleteTransaction: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'deleteTransaction', error: 'Transaction id is required' });
const [existing] = await db
.select({ id: companyAccountTransactions.id, type: companyAccountTransactions.type })
.from(companyAccountTransactions)
.where(
and(
eq(companyAccountTransactions.id, id),
eq(companyAccountTransactions.accountId, params.accountId)
)
)
.limit(1);
if (!existing) error(404, 'Transaction not found');
if (!(EDITABLE_TYPES as readonly string[]).includes(existing.type)) {
return fail(400, {
action: 'deleteTransaction',
error: 'This transaction type cannot be deleted (auto-posted from expense/invoice/transfer)'
});
}
await db.delete(companyAccountTransactions).where(eq(companyAccountTransactions.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'account_transaction_added',
`Transaction deleted on account ${params.accountId}`,
{ accountId: params.accountId, transactionId: id }
);
return { success: true, action: 'deleteTransaction' };
}
};
@@ -0,0 +1,540 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
type TxnType =
| 'opening_balance'
| 'expense'
| 'invoice_payment'
| 'transfer_in'
| 'transfer_out'
| 'deposit'
| 'adjustment'
| 'reconciliation';
const TYPE_LABELS: Record<TxnType, string> = {
opening_balance: 'Opening',
expense: 'Expense',
invoice_payment: 'Invoice',
transfer_in: 'Transfer In',
transfer_out: 'Transfer Out',
deposit: 'Deposit',
adjustment: 'Adjustment',
reconciliation: 'Reconciliation'
};
const TYPE_BADGE: Record<TxnType, string> = {
opening_balance: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
expense: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
invoice_payment: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
transfer_in: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
transfer_out: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
deposit: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
adjustment: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
reconciliation: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300'
};
const EDITABLE_TYPES: TxnType[] = ['deposit', 'adjustment'];
let showRecord = $state(false);
let editingId = $state<string | null>(null);
let confirmDeleteId = $state<string | null>(null);
function formatAmount(amount: string, currency: string): string {
const n = Number(amount);
const fmt = new Intl.NumberFormat(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
return `${fmt.format(n)} ${currency}`;
}
function formatDate(d: Date | string): string {
const dt = typeof d === 'string' ? new Date(d) : d;
return dt.toISOString().split('T')[0];
}
function balanceClass(amount: string): string {
const n = Number(amount);
if (n > 0) return 'text-emerald-600 dark:text-emerald-400';
if (n < 0) return 'text-red-600 dark:text-red-400';
return 'text-gray-500 dark:text-gray-400';
}
function todayIso(): string {
const d = new Date();
return d.toISOString().split('T')[0];
}
function utilisation(balance: string, limit: string | null): number | null {
if (!limit) return null;
const lim = Number(limit);
const bal = Number(balance);
if (!Number.isFinite(lim) || lim <= 0) return null;
const used = Math.max(0, -bal);
return Math.min(100, Math.round((used / lim) * 100));
}
const totalPages = $derived(Math.max(1, Math.ceil(data.totalCount / data.pageSize)));
function pageHref(p: number): string {
const params = new URLSearchParams();
if (data.filters.from) params.set('from', data.filters.from);
if (data.filters.to) params.set('to', data.filters.to);
if (data.filters.type) params.set('type', data.filters.type);
if (data.filters.q) params.set('q', data.filters.q);
if (p > 1) params.set('page', String(p));
const qs = params.toString();
return qs ? `?${qs}` : '';
}
const exportHref = $derived.by(() => {
const params = new URLSearchParams();
if (data.filters.from) params.set('from', data.filters.from);
if (data.filters.to) params.set('to', data.filters.to);
if (data.filters.type) params.set('type', data.filters.type);
if (data.filters.q) params.set('q', data.filters.q);
const qs = params.toString();
return `/companies/${data.company.id}/accounts/${data.account.id}/export${qs ? '?' + qs : ''}`;
});
const inputCls =
'w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white';
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
</script>
<svelte:head>
<title>{data.account.name} - Accounts - {data.company.name}</title>
</svelte:head>
<div class="space-y-6">
<header>
<div class="mb-2">
<a
href={`/companies/${data.company.id}/accounts`}
class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
&larr; Accounts
</a>
</div>
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{data.account.name}</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{data.account.accountType} · {data.account.currency}
{#if data.account.isArchived} · <span class="font-medium">Archived</span>{/if}
</p>
</div>
<div class="text-right">
<p class="text-3xl font-bold {balanceClass(data.balance)}">
{formatAmount(data.balance, data.account.currency)}
</p>
<p class="text-xs text-gray-400">Current balance</p>
</div>
</div>
{#if data.account.accountType === 'credit_card' && data.account.creditLimit}
{@const pct = utilisation(data.balance, data.account.creditLimit)}
<div class="mt-4 rounded-md border border-gray-200 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-300">
Credit limit: {formatAmount(data.account.creditLimit, data.account.currency)}
</span>
{#if pct !== null}
<span class="text-xs text-gray-500">{pct}% used</span>
{/if}
</div>
{#if pct !== null}
<div class="mt-2 h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full {pct > 80 ? 'bg-red-500' : pct > 50 ? 'bg-amber-500' : 'bg-emerald-500'}"
style="width: {pct}%"
></div>
</div>
{/if}
{#if data.account.statementCloseDay || data.account.paymentDueDay}
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{#if data.account.statementCloseDay}Statement closes day {data.account.statementCloseDay}{/if}
{#if data.account.statementCloseDay && data.account.paymentDueDay} · {/if}
{#if data.account.paymentDueDay}Payment due day {data.account.paymentDueDay}{/if}
</p>
{/if}
</div>
{/if}
</header>
{#if form?.error}
<div
class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300"
>
{form.error}
</div>
{/if}
<div class="flex flex-wrap items-center justify-between gap-3">
<form method="GET" class="flex flex-wrap items-center gap-2">
<input
type="date"
name="from"
value={data.filters.from}
class="rounded-md border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
<span class="text-xs text-gray-400">to</span>
<input
type="date"
name="to"
value={data.filters.to}
class="rounded-md border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
<select
name="type"
value={data.filters.type}
class="rounded-md border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">All types</option>
{#each Object.entries(TYPE_LABELS) as [value, label]}
<option {value}>{label}</option>
{/each}
</select>
<input
type="search"
name="q"
value={data.filters.q}
placeholder="Search description / reference"
class="rounded-md border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
<button
type="submit"
class="rounded-md border border-gray-300 bg-white px-3 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
Apply
</button>
</form>
<div class="flex gap-2">
<a
href={exportHref}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Export CSV
</a>
<button
type="button"
onclick={() => (showRecord = !showRecord)}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
{showRecord ? 'Cancel' : '+ Record Transaction'}
</button>
</div>
</div>
{#if showRecord}
<form
method="POST"
action="?/addManualTransaction"
use:enhance={() => async ({ result, update, formElement }) => {
await update({ reset: false });
if (result.type === 'success') {
showRecord = false;
formElement.reset();
}
}}
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label for="rt-type" class={labelCls}>Type <span class="text-red-500">*</span></label>
<select id="rt-type" name="type" required class={inputCls}>
<option value="deposit">Deposit (credit)</option>
<option value="adjustment">Adjustment (debit or credit)</option>
</select>
</div>
<div>
<label for="rt-amount" class={labelCls}>
Amount <span class="text-red-500">*</span>
<span class="ml-1 text-xs text-gray-400">(positive = credit, negative = debit)</span>
</label>
<input
id="rt-amount"
name="amount"
type="number"
step="0.01"
required
class={inputCls}
/>
</div>
<div>
<label for="rt-date" class={labelCls}>Date <span class="text-red-500">*</span></label>
<input
id="rt-date"
name="occurredAt"
type="date"
value={todayIso()}
required
class={inputCls}
/>
</div>
<div></div>
<div class="md:col-span-2">
<label for="rt-description" class={labelCls}>Description</label>
<input id="rt-description" name="description" type="text" class={inputCls} />
</div>
<div class="md:col-span-2">
<label for="rt-reference" class={labelCls}>Reference</label>
<input id="rt-reference" name="reference" type="text" class={inputCls} />
</div>
</div>
<div class="mt-3 flex justify-end gap-2">
<button
type="button"
onclick={() => (showRecord = false)}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Record
</button>
</div>
</form>
{/if}
{#if data.transactions.length === 0}
<div
class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-sm text-gray-500 dark:text-gray-400">No transactions match the current filters.</p>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="w-full text-left text-sm">
<thead class="bg-gray-50 text-xs uppercase text-gray-500 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th class="px-3 py-2">Date</th>
<th class="px-3 py-2">Type</th>
<th class="px-3 py-2">Description</th>
<th class="px-3 py-2 text-right">Debit</th>
<th class="px-3 py-2 text-right">Credit</th>
<th class="px-3 py-2">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each data.transactions as txn (txn.id)}
{@const amt = Number(txn.amount)}
{@const isDebit = amt < 0}
<tr class="align-top">
<td class="px-3 py-2 whitespace-nowrap text-gray-700 dark:text-gray-200">
{formatDate(txn.occurredAt)}
</td>
<td class="px-3 py-2">
<span
class="rounded-full px-2 py-0.5 text-xs font-medium {TYPE_BADGE[txn.type as TxnType]}"
>
{TYPE_LABELS[txn.type as TxnType]}
</span>
</td>
<td class="px-3 py-2 text-gray-700 dark:text-gray-200">
<div>{txn.description ?? '—'}</div>
{#if txn.reference}
<div class="text-xs text-gray-400">Ref: {txn.reference}</div>
{/if}
{#if txn.counterpartyName}
<div class="text-xs text-gray-400">
Counterparty: {txn.counterpartyName}
</div>
{/if}
{#if txn.fxRate && txn.fxAmount}
<div class="text-xs text-amber-600 dark:text-amber-400">
FX: {txn.fxAmount} @ {Number(txn.fxRate).toFixed(4)}
</div>
{/if}
{#if txn.createdByName}
<div class="text-xs text-gray-400">By {txn.createdByName}</div>
{/if}
</td>
<td class="px-3 py-2 text-right font-mono text-red-600 dark:text-red-400">
{#if isDebit}{formatAmount(Math.abs(amt).toFixed(2), txn.currency)}{/if}
</td>
<td class="px-3 py-2 text-right font-mono text-emerald-600 dark:text-emerald-400">
{#if !isDebit}{formatAmount(amt.toFixed(2), txn.currency)}{/if}
</td>
<td class="px-3 py-2">
{#if EDITABLE_TYPES.includes(txn.type as TxnType)}
<div class="flex gap-2 text-xs">
<button
type="button"
onclick={() => {
editingId = editingId === txn.id ? null : txn.id;
confirmDeleteId = null;
}}
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
Edit
</button>
<button
type="button"
onclick={() =>
(confirmDeleteId = confirmDeleteId === txn.id ? null : txn.id)}
class="font-medium text-red-600 hover:text-red-700 dark:text-red-400"
>
Delete
</button>
</div>
{:else}
<span
class="text-xs text-gray-400"
title="Auto-posted from expense/invoice/transfer — cannot edit here"
>
Locked
</span>
{/if}
</td>
</tr>
{#if editingId === txn.id}
<tr class="bg-gray-50 dark:bg-gray-700/50">
<td colspan="6" class="px-3 py-3">
<form
method="POST"
action="?/editTransaction"
use:enhance={() => async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') editingId = null;
}}
class="grid grid-cols-1 gap-2 md:grid-cols-4"
>
<input type="hidden" name="id" value={txn.id} />
<div>
<label for="et-date-{txn.id}" class={labelCls}>Date</label>
<input
id="et-date-{txn.id}"
name="occurredAt"
type="date"
required
value={formatDate(txn.occurredAt)}
class={inputCls}
/>
</div>
<div>
<label for="et-amt-{txn.id}" class={labelCls}>Amount (signed)</label>
<input
id="et-amt-{txn.id}"
name="amount"
type="number"
step="0.01"
required
value={txn.amount}
class={inputCls}
/>
</div>
<div class="md:col-span-2">
<label for="et-desc-{txn.id}" class={labelCls}>Description</label>
<input
id="et-desc-{txn.id}"
name="description"
type="text"
value={txn.description ?? ''}
class={inputCls}
/>
</div>
<div class="md:col-span-4">
<label for="et-ref-{txn.id}" class={labelCls}>Reference</label>
<input
id="et-ref-{txn.id}"
name="reference"
type="text"
value={txn.reference ?? ''}
class={inputCls}
/>
</div>
<div class="md:col-span-4 flex justify-end gap-2">
<button
type="button"
onclick={() => (editingId = null)}
class="rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700"
>
Save
</button>
</div>
</form>
</td>
</tr>
{/if}
{#if confirmDeleteId === txn.id}
<tr class="bg-red-50 dark:bg-red-900/20">
<td colspan="6" class="px-3 py-3">
<form
method="POST"
action="?/deleteTransaction"
use:enhance={() => async ({ update }) => {
await update({ reset: false });
confirmDeleteId = null;
}}
class="flex items-center justify-between gap-2"
>
<input type="hidden" name="id" value={txn.id} />
<span class="text-sm text-red-700 dark:text-red-300">
Delete this {txn.type} transaction?
</span>
<div class="flex gap-2">
<button
type="button"
onclick={() => (confirmDeleteId = null)}
class="rounded border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
Cancel
</button>
<button
type="submit"
class="rounded bg-red-600 px-2 py-1 text-xs font-medium text-white hover:bg-red-700"
>
Delete
</button>
</div>
</form>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{#if totalPages > 1}
<div class="flex items-center justify-between text-sm">
<p class="text-gray-500 dark:text-gray-400">
Page {data.page} of {totalPages} · {data.totalCount} transactions
</p>
<div class="flex gap-2">
{#if data.page > 1}
<a
href={pageHref(data.page - 1)}
class="rounded-md border border-gray-300 px-3 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
← Prev
</a>
{/if}
{#if data.page < totalPages}
<a
href={pageHref(data.page + 1)}
class="rounded-md border border-gray-300 px-3 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Next →
</a>
{/if}
</div>
</div>
{/if}
{/if}
</div>
@@ -0,0 +1,133 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db/index.js';
import { companyAccounts, companyAccountTransactions, users } from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { csvBuild } from '$lib/utils/csv.js';
import { alias } from 'drizzle-orm/pg-core';
import { and, desc, eq, gte, ilike, isNull, lte, or } from 'drizzle-orm';
function withBom(s: string): string {
return '\uFEFF' + s;
}
export const GET: RequestHandler = async ({ locals, params, url }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const [account] = await db
.select({
id: companyAccounts.id,
name: companyAccounts.name,
currency: companyAccounts.currency
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, params.accountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!account) error(404, 'Account not found');
const fromParam = url.searchParams.get('from');
const toParam = url.searchParams.get('to');
const typeParam = url.searchParams.get('type');
const qParam = url.searchParams.get('q');
const conditions = [eq(companyAccountTransactions.accountId, params.accountId)];
if (fromParam) conditions.push(gte(companyAccountTransactions.occurredAt, new Date(fromParam)));
if (toParam) {
const toDate = new Date(toParam);
toDate.setHours(23, 59, 59, 999);
conditions.push(lte(companyAccountTransactions.occurredAt, toDate));
}
if (typeParam) {
conditions.push(eq(companyAccountTransactions.type, typeParam as never));
}
if (qParam && qParam.trim()) {
const pattern = `%${qParam.trim()}%`;
conditions.push(
or(
ilike(companyAccountTransactions.description, pattern),
ilike(companyAccountTransactions.reference, pattern)
)!
);
}
const counterparty = alias(companyAccounts, 'counterparty');
const rows = await db
.select({
id: companyAccountTransactions.id,
occurredAt: companyAccountTransactions.occurredAt,
type: companyAccountTransactions.type,
amount: companyAccountTransactions.amount,
currency: companyAccountTransactions.currency,
description: companyAccountTransactions.description,
reference: companyAccountTransactions.reference,
counterpartyName: counterparty.name,
fxRate: companyAccountTransactions.fxRate,
fxAmount: companyAccountTransactions.fxAmount,
sourceExpenseId: companyAccountTransactions.sourceExpenseId,
sourceInvoiceId: companyAccountTransactions.sourceInvoiceId,
sourceExternalTransactionId: companyAccountTransactions.sourceExternalTransactionId,
createdByName: users.displayName,
createdAt: companyAccountTransactions.createdAt
})
.from(companyAccountTransactions)
.leftJoin(counterparty, eq(companyAccountTransactions.counterpartyAccountId, counterparty.id))
.leftJoin(users, eq(companyAccountTransactions.createdBy, users.id))
.where(and(...conditions))
.orderBy(desc(companyAccountTransactions.occurredAt), desc(companyAccountTransactions.createdAt));
const header = [
'id',
'occurredAt',
'type',
'amount',
'currency',
'description',
'reference',
'counterparty',
'fxRate',
'fxAmount',
'sourceExpenseId',
'sourceInvoiceId',
'sourceExternalTransactionId',
'createdBy',
'createdAt'
];
const csvRows: unknown[][] = [header];
for (const r of rows) {
csvRows.push([
r.id,
r.occurredAt.toISOString(),
r.type,
r.amount,
r.currency,
r.description ?? '',
r.reference ?? '',
r.counterpartyName ?? '',
r.fxRate ?? '',
r.fxAmount ?? '',
r.sourceExpenseId ?? '',
r.sourceInvoiceId ?? '',
r.sourceExternalTransactionId ?? '',
r.createdByName ?? '',
r.createdAt.toISOString()
]);
}
const safeName = account.name.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 60) || 'account';
const filename = `${safeName}-transactions.csv`;
return new Response(withBom(csvBuild(csvRows)), {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
'Cache-Control': 'private, no-store'
}
});
};
@@ -1,18 +1,28 @@
import { fail } from '@sveltejs/kit'; import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { expenses, projects, users, categories } from '$lib/server/db/schema.js'; import {
import { eq, and, sql } from 'drizzle-orm'; expenses,
import { requireCompanyRole } from '$lib/server/authorization.js'; projects,
users,
categories,
companyAccounts
} from '$lib/server/db/schema.js';
import { eq, and, sql, isNull } from 'drizzle-orm';
import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js'; import { logCompanyEvent } from '$lib/server/audit.js';
import { formatCurrency } from '$lib/utils/currency.js'; import { formatCurrency } from '$lib/utils/currency.js';
import {
postExpenseTransaction,
removeExpenseTransaction
} from '$lib/server/accounts/ledger.js';
export const load: PageServerLoad = async ({ parent, params, url }) => { export const load: PageServerLoad = async ({ parent, params, url }) => {
await parent(); await parent();
const status = url.searchParams.get('status') || 'all'; const status = url.searchParams.get('status') || 'all';
let query = db const expenseList = await db
.select({ .select({
id: expenses.id, id: expenses.id,
title: expenses.title, title: expenses.title,
@@ -28,12 +38,15 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
projectId: projects.id, projectId: projects.id,
projectName: projects.name, projectName: projects.name,
categoryName: categories.name, categoryName: categories.name,
accountId: expenses.accountId,
accountName: companyAccounts.name,
createdAt: expenses.createdAt createdAt: expenses.createdAt
}) })
.from(expenses) .from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id)) .innerJoin(projects, eq(expenses.projectId, projects.id))
.innerJoin(users, eq(expenses.submittedBy, users.id)) .innerJoin(users, eq(expenses.submittedBy, users.id))
.leftJoin(categories, eq(expenses.categoryId, categories.id)) .leftJoin(categories, eq(expenses.categoryId, categories.id))
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
.where( .where(
status === 'all' status === 'all'
? eq(projects.companyId, params.companyId) ? eq(projects.companyId, params.companyId)
@@ -45,9 +58,24 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
.orderBy(sql`${expenses.createdAt} desc`) .orderBy(sql`${expenses.createdAt} desc`)
.limit(100); .limit(100);
const expenseList = await query; const accountsList = await db
.select({
id: companyAccounts.id,
name: companyAccounts.name,
currency: companyAccounts.currency,
accountType: companyAccounts.accountType
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.companyId, params.companyId),
eq(companyAccounts.isArchived, false),
isNull(companyAccounts.deletedAt)
)
)
.orderBy(companyAccounts.name);
return { expenses: expenseList, statusFilter: status }; return { expenses: expenseList, statusFilter: status, accounts: accountsList };
}; };
export const actions: Actions = { export const actions: Actions = {
@@ -58,27 +86,40 @@ export const actions: Actions = {
if (!expenseId) return fail(400, { error: 'Missing expense ID' }); if (!expenseId) return fail(400, { error: 'Missing expense ID' });
// Get expense details for the log
const [expense] = await db const [expense] = await db
.select({ title: expenses.title, amount: expenses.amount, currency: expenses.currency }) .select({
title: expenses.title,
amount: expenses.amount,
currency: expenses.currency,
accountId: expenses.accountId
})
.from(expenses) .from(expenses)
.where(eq(expenses.id, expenseId)) .where(eq(expenses.id, expenseId))
.limit(1); .limit(1);
await db await db.transaction(async (tx) => {
.update(expenses) await tx
.set({ .update(expenses)
status: 'approved', .set({
approvedBy: user.id, status: 'approved',
reviewedAt: new Date(), approvedBy: user.id,
updatedAt: new Date() reviewedAt: new Date(),
}) updatedAt: new Date()
.where(and(eq(expenses.id, expenseId), eq(expenses.status, 'pending'))); })
.where(and(eq(expenses.id, expenseId), eq(expenses.status, 'pending')));
if (expense?.accountId) {
await postExpenseTransaction(expenseId, expense.accountId, user.id, tx);
}
});
if (expense) { if (expense) {
await logCompanyEvent(params.companyId, user.id, 'expense_approved', await logCompanyEvent(
params.companyId,
user.id,
'expense_approved',
`Approved expense "${expense.title}" for ${formatCurrency(expense.amount, expense.currency)}`, `Approved expense "${expense.title}" for ${formatCurrency(expense.amount, expense.currency)}`,
{ expenseId, amount: expense.amount } { expenseId, amount: expense.amount, accountId: expense.accountId }
); );
} }
@@ -99,24 +140,99 @@ export const actions: Actions = {
.where(eq(expenses.id, expenseId)) .where(eq(expenses.id, expenseId))
.limit(1); .limit(1);
await db await db.transaction(async (tx) => {
.update(expenses) await tx
.set({ .update(expenses)
status: 'rejected', .set({
approvedBy: user.id, status: 'rejected',
reviewedAt: new Date(), approvedBy: user.id,
rejectionReason: reason, reviewedAt: new Date(),
updatedAt: new Date() rejectionReason: reason,
}) updatedAt: new Date()
.where(and(eq(expenses.id, expenseId), eq(expenses.status, 'pending'))); })
.where(and(eq(expenses.id, expenseId), eq(expenses.status, 'pending')));
// Defensive: remove any prior ledger post (e.g. if this expense was previously approved then reopened)
await removeExpenseTransaction(expenseId, tx);
});
if (expense) { if (expense) {
await logCompanyEvent(params.companyId, user.id, 'expense_rejected', await logCompanyEvent(
params.companyId,
user.id,
'expense_rejected',
`Rejected expense "${expense.title}" (${formatCurrency(expense.amount, expense.currency)})${reason ? `${reason}` : ''}`, `Rejected expense "${expense.title}" (${formatCurrency(expense.amount, expense.currency)})${reason ? `${reason}` : ''}`,
{ expenseId, amount: expense.amount, reason } { expenseId, amount: expense.amount, reason }
); );
} }
return { success: true };
},
updateExpenseAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const formData = await request.formData();
const expenseId = formData.get('expenseId')?.toString();
const rawAccountId = formData.get('accountId')?.toString().trim() ?? '';
const accountId = rawAccountId === '' ? null : rawAccountId;
if (!expenseId) return fail(400, { error: 'Missing expense ID' });
const [expense] = await db
.select({
id: expenses.id,
status: expenses.status,
title: expenses.title,
accountId: expenses.accountId,
projectCompanyId: projects.companyId
})
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(eq(expenses.id, expenseId))
.limit(1);
if (!expense) error(404, 'Expense not found');
if (expense.projectCompanyId !== params.companyId) error(403, 'Forbidden');
if (accountId) {
const [acct] = await db
.select({ id: companyAccounts.id })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, accountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!acct) return fail(400, { error: 'Invalid account' });
}
await db.transaction(async (tx) => {
await tx.update(expenses).set({ accountId, updatedAt: new Date() }).where(eq(expenses.id, expenseId));
// Only post to ledger if the expense is approved. Otherwise leave ledger untouched.
if (expense.status === 'approved') {
if (accountId) {
await postExpenseTransaction(expenseId, accountId, user.id, tx);
} else {
await removeExpenseTransaction(expenseId, tx);
}
}
});
await logCompanyEvent(
params.companyId,
user.id,
'account_transaction_added',
`Expense "${expense.title}" ${accountId ? 'assigned to account' : 'unassigned from account'}`,
{ expenseId, accountId, previousAccountId: expense.accountId }
);
return { success: true }; return { success: true };
} }
}; };
@@ -10,6 +10,11 @@
const canApprove = $derived( const canApprove = $derived(
data.companyRoles.includes('admin') || data.companyRoles.includes('manager') data.companyRoles.includes('admin') || data.companyRoles.includes('manager')
); );
const canAssignAccount = $derived(
data.companyRoles.includes('admin') ||
data.companyRoles.includes('manager') ||
data.companyRoles.includes('accountant')
);
</script> </script>
<svelte:head> <svelte:head>
@@ -57,6 +62,15 @@
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500"> <p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
By {expense.submitterName ?? expense.submitterEmail} · {expense.expenseDate} By {expense.submitterName ?? expense.submitterEmail} · {expense.expenseDate}
</p> </p>
{#if expense.accountName}
<p class="mt-1 text-xs">
<span
class="rounded-full bg-blue-100 px-2 py-0.5 font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"
>
Account: {expense.accountName}
</span>
</p>
{/if}
</div> </div>
<div class="text-right"> <div class="text-right">
<p class="text-lg font-semibold dark:text-white">{formatCurrency(expense.amount, expense.currency)}</p> <p class="text-lg font-semibold dark:text-white">{formatCurrency(expense.amount, expense.currency)}</p>
@@ -80,7 +94,7 @@
{/if} {/if}
{#if canApprove && expense.status === 'pending'} {#if canApprove && expense.status === 'pending'}
<div class="mt-3 flex gap-2 border-t border-gray-100 dark:border-gray-700 pt-3"> <div class="mt-3 flex flex-wrap gap-2 border-t border-gray-100 dark:border-gray-700 pt-3">
<form method="POST" action="?/approve" use:enhance> <form method="POST" action="?/approve" use:enhance>
<input type="hidden" name="expenseId" value={expense.id} /> <input type="hidden" name="expenseId" value={expense.id} />
<button <button
@@ -107,6 +121,37 @@
</form> </form>
</div> </div>
{/if} {/if}
{#if canAssignAccount && data.accounts.length > 0}
<form
method="POST"
action="?/updateExpenseAccount"
use:enhance
class="mt-3 flex flex-wrap items-center gap-2 border-t border-gray-100 pt-3 text-sm dark:border-gray-700"
>
<input type="hidden" name="expenseId" value={expense.id} />
<label class="text-xs text-gray-500 dark:text-gray-400" for="acct-{expense.id}">
Assign to account:
</label>
<select
id="acct-{expense.id}"
name="accountId"
value={expense.accountId ?? ''}
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">— none —</option>
{#each data.accounts as acct (acct.id)}
<option value={acct.id}>{acct.name} ({acct.currency})</option>
{/each}
</select>
<button
type="submit"
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
Save
</button>
</form>
{/if}
</div> </div>
{/each} {/each}
</div> </div>
@@ -1,10 +1,18 @@
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { externalAccounts, externalTransactions, expenses, projects } from '$lib/server/db/schema.js'; import {
import { eq, and, isNull, isNotNull, desc } from 'drizzle-orm'; externalAccounts,
externalTransactions,
expenses,
projects,
companyAccounts,
companyAccountTransactions
} from '$lib/server/db/schema.js';
import { eq, and, isNull, isNotNull, desc, inArray } from 'drizzle-orm';
import { requireCompanyRole } from '$lib/server/authorization.js'; import { requireCompanyRole } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js'; import { logCompanyEvent } from '$lib/server/audit.js';
import { postReconciliationTransaction } from '$lib/server/accounts/ledger.js';
export const load: PageServerLoad = async ({ locals, params, url }) => { export const load: PageServerLoad = async ({ locals, params, url }) => {
await requireCompanyRole(locals, params.companyId, 'admin'); await requireCompanyRole(locals, params.companyId, 'admin');
@@ -82,11 +90,53 @@ export const load: PageServerLoad = async ({ locals, params, url }) => {
.orderBy(desc(expenses.createdAt)) .orderBy(desc(expenses.createdAt))
.limit(200); .limit(200);
const accountsList = await db
.select({
id: companyAccounts.id,
name: companyAccounts.name,
currency: companyAccounts.currency,
accountType: companyAccounts.accountType
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.companyId, params.companyId),
eq(companyAccounts.isArchived, false),
isNull(companyAccounts.deletedAt)
)
)
.orderBy(companyAccounts.name);
// Determine which external transactions are already posted to a ledger account
const txIds = transactions.map((t) => t.id);
const postedMap: Record<string, { accountId: string; accountName: string | null }> = {};
if (txIds.length > 0) {
const postedRows = await db
.select({
sourceExternalTransactionId: companyAccountTransactions.sourceExternalTransactionId,
accountId: companyAccountTransactions.accountId,
accountName: companyAccounts.name
})
.from(companyAccountTransactions)
.leftJoin(companyAccounts, eq(companyAccountTransactions.accountId, companyAccounts.id))
.where(inArray(companyAccountTransactions.sourceExternalTransactionId, txIds));
for (const row of postedRows) {
if (row.sourceExternalTransactionId) {
postedMap[row.sourceExternalTransactionId] = {
accountId: row.accountId,
accountName: row.accountName
};
}
}
}
return { return {
transactions, transactions,
matchedExpenseTitles, matchedExpenseTitles,
matchableExpenses, matchableExpenses,
matchedFilter: matched matchedFilter: matched,
accounts: accountsList,
postedMap
}; };
}; };
@@ -144,6 +194,72 @@ export const actions: Actions = {
.set({ matchedExpenseId: null }) .set({ matchedExpenseId: null })
.where(eq(externalTransactions.id, txId)); .where(eq(externalTransactions.id, txId));
return { success: true };
},
postToAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const formData = await request.formData();
const txId = formData.get('txId')?.toString();
const accountId = formData.get('accountId')?.toString();
if (!txId) return fail(400, { error: 'Transaction ID is required' });
if (!accountId) return fail(400, { error: 'Account is required' });
const [tx] = await db
.select({ id: externalTransactions.id })
.from(externalTransactions)
.where(
and(eq(externalTransactions.id, txId), eq(externalTransactions.companyId, params.companyId))
)
.limit(1);
if (!tx) return fail(404, { error: 'Transaction not found' });
const [acct] = await db
.select({ id: companyAccounts.id })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, accountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!acct) return fail(400, { error: 'Invalid account' });
await postReconciliationTransaction(txId, accountId, params.companyId, user.id);
await logCompanyEvent(
params.companyId,
user.id,
'account_reconciled',
`External transaction posted to account`,
{ externalTransactionId: txId, accountId }
);
return { success: true };
},
unpostFromAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const formData = await request.formData();
const txId = formData.get('txId')?.toString();
if (!txId) return fail(400, { error: 'Transaction ID is required' });
await db
.delete(companyAccountTransactions)
.where(eq(companyAccountTransactions.sourceExternalTransactionId, txId));
await logCompanyEvent(
params.companyId,
user.id,
'account_reconciled',
`Reconciliation reversed for external transaction`,
{ externalTransactionId: txId }
);
return { success: true }; return { success: true };
} }
}; };
@@ -82,6 +82,7 @@
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Description</th> <th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Description</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Counterparty</th> <th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Counterparty</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Matched Expense</th> <th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Matched Expense</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Ledger Account</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -152,6 +153,44 @@
</form> </form>
{/if} {/if}
</td> </td>
<td class="px-4 py-3">
{#if data.postedMap[tx.id]}
<div class="flex items-center gap-2">
<span class="truncate max-w-[140px] text-xs text-blue-700 dark:text-blue-300 font-medium" title={data.postedMap[tx.id].accountName ?? ''}>
{data.postedMap[tx.id].accountName ?? 'Account'}
</span>
<form method="POST" action="?/unpostFromAccount" use:enhance>
<input type="hidden" name="txId" value={tx.id} />
<button
type="submit"
class="text-xs text-gray-400 dark:text-gray-500 hover:text-red-600 dark:hover:text-red-400 whitespace-nowrap"
>
Unpost
</button>
</form>
</div>
{:else if data.accounts.length === 0}
<span class="text-xs text-gray-400">No accounts</span>
{:else}
<form method="POST" action="?/postToAccount" use:enhance>
<input type="hidden" name="txId" value={tx.id} />
<select
name="accountId"
onchange={(e) => {
if ((e.target as HTMLSelectElement).value) {
(e.target as HTMLSelectElement).closest('form')?.requestSubmit();
}
}}
class="rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-1.5 py-1 text-xs text-gray-700 dark:text-gray-300"
>
<option value="">Post to account…</option>
{#each data.accounts as acct}
<option value={acct.id}>{acct.name} ({acct.currency})</option>
{/each}
</select>
</form>
{/if}
</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
@@ -1,10 +1,14 @@
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { invoices, parties } from '$lib/server/db/schema.js'; import { invoices, parties, companyAccounts } from '$lib/server/db/schema.js';
import { eq, and, sql, gte, lte } from 'drizzle-orm'; import { eq, and, sql, gte, lte, isNull } from 'drizzle-orm';
import { requireCompanyRoleAny } from '$lib/server/authorization.js'; import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js'; import { logCompanyEvent } from '$lib/server/audit.js';
import {
postInvoicePaymentTransaction,
removeInvoicePaymentTransaction
} from '$lib/server/accounts/ledger.js';
export const load: PageServerLoad = async ({ locals, params, url }) => { export const load: PageServerLoad = async ({ locals, params, url }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'user']); await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'user']);
@@ -52,7 +56,31 @@ export const load: PageServerLoad = async ({ locals, params, url }) => {
.orderBy(sql`${invoices.issueDate} desc`) .orderBy(sql`${invoices.issueDate} desc`)
.limit(200); .limit(200);
return { invoices: invoiceList, directionFilter, statusFilter, fromDate, toDate }; const accountsList = await db
.select({
id: companyAccounts.id,
name: companyAccounts.name,
currency: companyAccounts.currency,
accountType: companyAccounts.accountType
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.companyId, params.companyId),
eq(companyAccounts.isArchived, false),
isNull(companyAccounts.deletedAt)
)
)
.orderBy(companyAccounts.name);
return {
invoices: invoiceList,
directionFilter,
statusFilter,
fromDate,
toDate,
accounts: accountsList
};
}; };
export const actions: Actions = { export const actions: Actions = {
@@ -90,7 +118,10 @@ export const actions: Actions = {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']); const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
const formData = await request.formData(); const formData = await request.formData();
const invoiceId = formData.get('invoiceId')?.toString(); const invoiceId = formData.get('invoiceId')?.toString();
const paymentAccountId = formData.get('paymentAccountId')?.toString() || null;
if (!invoiceId) return fail(400, { error: 'Missing invoice ID' }); if (!invoiceId) return fail(400, { error: 'Missing invoice ID' });
if (!paymentAccountId)
return fail(400, { error: 'Payment account is required to mark an invoice paid' });
const [inv] = await db const [inv] = await db
.select({ invoiceNumber: invoices.invoiceNumber, total: invoices.total, currency: invoices.currency }) .select({ invoiceNumber: invoices.invoiceNumber, total: invoices.total, currency: invoices.currency })
@@ -100,17 +131,34 @@ export const actions: Actions = {
if (!inv) return fail(404, { error: 'Invoice not found' }); if (!inv) return fail(404, { error: 'Invoice not found' });
await db const [acct] = await db
.update(invoices) .select({ id: companyAccounts.id })
.set({ status: 'paid', updatedAt: new Date() }) .from(companyAccounts)
.where(and(eq(invoices.id, invoiceId), eq(invoices.companyId, params.companyId))); .where(
and(
eq(companyAccounts.id, paymentAccountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!acct) return fail(400, { error: 'Invalid payment account' });
await db.transaction(async (tx) => {
await tx
.update(invoices)
.set({ status: 'paid', paymentAccountId, updatedAt: new Date() })
.where(and(eq(invoices.id, invoiceId), eq(invoices.companyId, params.companyId)));
await postInvoicePaymentTransaction(invoiceId, paymentAccountId, user.id, tx);
});
await logCompanyEvent( await logCompanyEvent(
params.companyId, params.companyId,
user.id, user.id,
'invoice_paid', 'invoice_paid',
`Marked invoice ${inv.invoiceNumber} as paid`, `Marked invoice ${inv.invoiceNumber} as paid`,
{ invoiceId } { invoiceId, paymentAccountId }
); );
return { success: true }; return { success: true };
@@ -141,13 +141,29 @@
</form> </form>
{/if} {/if}
{#if inv.status === 'sent' || inv.status === 'overdue'} {#if inv.status === 'sent' || inv.status === 'overdue'}
<form method="POST" action="?/markPaid" use:enhance> {#if data.accounts.length === 0}
<input type="hidden" name="invoiceId" value={inv.id} /> <span class="text-xs text-gray-400" title="Create an account first to mark invoices paid">
<button type="submit" No account
class="rounded px-2 py-1 text-xs font-medium bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/50"> </span>
Mark Paid {:else}
</button> <form method="POST" action="?/markPaid" use:enhance class="flex items-center gap-1">
</form> <input type="hidden" name="invoiceId" value={inv.id} />
<select
name="paymentAccountId"
required
class="rounded border border-gray-300 bg-white px-1 py-0.5 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">— Account —</option>
{#each data.accounts as acct (acct.id)}
<option value={acct.id}>{acct.name}</option>
{/each}
</select>
<button type="submit"
class="rounded px-2 py-1 text-xs font-medium bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/50">
Mark Paid
</button>
</form>
{/if}
{/if} {/if}
</div> </div>
</td> </td>
@@ -7,11 +7,16 @@ import {
parties, parties,
expenses, expenses,
projects, projects,
packages packages,
companyAccounts
} from '$lib/server/db/schema.js'; } from '$lib/server/db/schema.js';
import { eq, and, isNull } from 'drizzle-orm'; import { eq, and, isNull } from 'drizzle-orm';
import { requireCompanyRoleAny } from '$lib/server/authorization.js'; import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js'; import { logCompanyEvent } from '$lib/server/audit.js';
import {
postInvoicePaymentTransaction,
removeInvoicePaymentTransaction
} from '$lib/server/accounts/ledger.js';
export const load: PageServerLoad = async ({ locals, params }) => { export const load: PageServerLoad = async ({ locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'user']); await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'user']);
@@ -30,6 +35,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
currency: invoices.currency, currency: invoices.currency,
notes: invoices.notes, notes: invoices.notes,
expenseId: invoices.expenseId, expenseId: invoices.expenseId,
paymentAccountId: invoices.paymentAccountId,
createdAt: invoices.createdAt, createdAt: invoices.createdAt,
partyId: invoices.partyId, partyId: invoices.partyId,
partyName: parties.name, partyName: parties.name,
@@ -49,6 +55,23 @@ export const load: PageServerLoad = async ({ locals, params }) => {
if (!invoice) error(404, 'Invoice not found'); if (!invoice) error(404, 'Invoice not found');
const accountsList = await db
.select({
id: companyAccounts.id,
name: companyAccounts.name,
currency: companyAccounts.currency,
accountType: companyAccounts.accountType
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.companyId, params.companyId),
eq(companyAccounts.isArchived, false),
isNull(companyAccounts.deletedAt)
)
)
.orderBy(companyAccounts.name);
const lineItems = await db const lineItems = await db
.select() .select()
.from(invoiceLineItems) .from(invoiceLineItems)
@@ -72,7 +95,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
.where(eq(packages.invoiceId, params.invoiceId)) .where(eq(packages.invoiceId, params.invoiceId))
.orderBy(packages.createdAt); .orderBy(packages.createdAt);
return { invoice, lineItems, projects: projectList, linkedPackages }; return { invoice, lineItems, projects: projectList, linkedPackages, accounts: accountsList };
}; };
export const actions: Actions = { export const actions: Actions = {
@@ -86,6 +109,7 @@ export const actions: Actions = {
| 'overdue' | 'overdue'
| 'cancelled' | 'cancelled'
| undefined; | undefined;
const paymentAccountId = formData.get('paymentAccountId')?.toString() || null;
const validStatuses = ['draft', 'sent', 'paid', 'overdue', 'cancelled']; const validStatuses = ['draft', 'sent', 'paid', 'overdue', 'cancelled'];
if (!newStatus || !validStatuses.includes(newStatus)) { if (!newStatus || !validStatuses.includes(newStatus)) {
@@ -93,22 +117,57 @@ export const actions: Actions = {
} }
const [inv] = await db const [inv] = await db
.select({ invoiceNumber: invoices.invoiceNumber }) .select({
invoiceNumber: invoices.invoiceNumber,
status: invoices.status,
paymentAccountId: invoices.paymentAccountId
})
.from(invoices) .from(invoices)
.where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId))) .where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId)))
.limit(1); .limit(1);
if (!inv) return fail(404, { error: 'Invoice not found' }); if (!inv) return fail(404, { error: 'Invoice not found' });
await db if (newStatus === 'paid') {
.update(invoices) if (!paymentAccountId) {
.set({ status: newStatus, updatedAt: new Date() }) return fail(400, { error: 'Payment account is required to mark an invoice paid' });
.where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId))); }
const [acct] = await db
.select({ id: companyAccounts.id })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, paymentAccountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!acct) return fail(400, { error: 'Invalid payment account' });
}
await db.transaction(async (tx) => {
await tx
.update(invoices)
.set({
status: newStatus,
paymentAccountId: newStatus === 'paid' ? paymentAccountId : null,
updatedAt: new Date()
})
.where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId)));
if (newStatus === 'paid' && paymentAccountId) {
await postInvoicePaymentTransaction(params.invoiceId, paymentAccountId, user.id, tx);
} else if (inv.status === 'paid') {
// Status moved away from paid — remove ledger post
await removeInvoicePaymentTransaction(params.invoiceId, tx);
}
});
if (newStatus === 'sent') { if (newStatus === 'sent') {
await logCompanyEvent(params.companyId, user.id, 'invoice_sent', `Marked invoice ${inv.invoiceNumber} as sent`, { invoiceId: params.invoiceId }); await logCompanyEvent(params.companyId, user.id, 'invoice_sent', `Marked invoice ${inv.invoiceNumber} as sent`, { invoiceId: params.invoiceId });
} else if (newStatus === 'paid') { } else if (newStatus === 'paid') {
await logCompanyEvent(params.companyId, user.id, 'invoice_paid', `Marked invoice ${inv.invoiceNumber} as paid`, { invoiceId: params.invoiceId }); await logCompanyEvent(params.companyId, user.id, 'invoice_paid', `Marked invoice ${inv.invoiceNumber} as paid`, { invoiceId: params.invoiceId, paymentAccountId });
} }
return { success: true }; return { success: true };
@@ -202,17 +202,37 @@
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<!-- Status transitions --> <!-- Status transitions -->
{#each nextStatuses[inv.status] ?? [] as targetStatus} {#each nextStatuses[inv.status] ?? [] as targetStatus}
<form method="POST" action="?/updateStatus" use:enhance> <form method="POST" action="?/updateStatus" use:enhance class="flex items-center gap-1">
<input type="hidden" name="status" value={targetStatus} /> <input type="hidden" name="status" value={targetStatus} />
<button type="submit" {#if targetStatus === 'paid'}
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {#if data.accounts.length === 0}
{targetStatus === 'paid' ? 'bg-green-600 text-white hover:bg-green-700' : <span class="text-xs text-gray-400" title="Create an account first to mark as paid">
targetStatus === 'sent' ? 'bg-blue-600 text-white hover:bg-blue-700' : No account
targetStatus === 'cancelled' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/50' : </span>
targetStatus === 'overdue' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 hover:bg-amber-200' : {:else}
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}"> <select
Mark {targetStatus.charAt(0).toUpperCase() + targetStatus.slice(1)} name="paymentAccountId"
</button> required
class="rounded border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">— Account —</option>
{#each data.accounts as acct (acct.id)}
<option value={acct.id}>{acct.name} ({acct.currency})</option>
{/each}
</select>
{/if}
{/if}
{#if targetStatus !== 'paid' || data.accounts.length > 0}
<button type="submit"
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors
{targetStatus === 'paid' ? 'bg-green-600 text-white hover:bg-green-700' :
targetStatus === 'sent' ? 'bg-blue-600 text-white hover:bg-blue-700' :
targetStatus === 'cancelled' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/50' :
targetStatus === 'overdue' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 hover:bg-amber-200' :
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}">
Mark {targetStatus.charAt(0).toUpperCase() + targetStatus.slice(1)}
</button>
{/if}
</form> </form>
{/each} {/each}
@@ -1,300 +1,33 @@
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { import { companyAddresses } from '$lib/server/db/schema.js';
companyBankAccounts, import { eq, and, desc, asc } from 'drizzle-orm';
companyCards,
companyAddresses
} from '$lib/server/db/schema.js';
import { eq, and, desc, asc, sql } from 'drizzle-orm';
import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js'; import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js'; import { logCompanyEvent } from '$lib/server/audit.js';
const ALL_ADDRESS_TYPES = ['legal', 'shipping', 'billing', 'other'] as const; const ALL_ADDRESS_TYPES = ['legal', 'shipping', 'billing', 'other'] as const;
const ALL_CARD_BRANDS = [
'visa',
'mastercard',
'amex',
'jcb',
'unionpay',
'discover',
'other'
] as const;
type AddressType = (typeof ALL_ADDRESS_TYPES)[number]; type AddressType = (typeof ALL_ADDRESS_TYPES)[number];
type CardBrand = (typeof ALL_CARD_BRANDS)[number];
function trimOrNull(v: FormDataEntryValue | null): string | null { function trimOrNull(v: FormDataEntryValue | null): string | null {
const s = v?.toString().trim(); const s = v?.toString().trim();
return s ? s : null; return s ? s : null;
} }
function parseInt0(v: FormDataEntryValue | null): number | null {
const s = v?.toString().trim();
if (!s) return null;
const n = parseInt(s, 10);
return isNaN(n) ? null : n;
}
export const load: PageServerLoad = async ({ locals, params, parent }) => { export const load: PageServerLoad = async ({ locals, params, parent }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
await parent(); await parent();
const bankAccounts = await db
.select()
.from(companyBankAccounts)
.where(eq(companyBankAccounts.companyId, params.companyId))
.orderBy(desc(companyBankAccounts.isPrimary), asc(companyBankAccounts.bankName));
const cards = await db
.select({
id: companyCards.id,
brand: companyCards.brand,
last4: companyCards.last4,
cardholderName: companyCards.cardholderName,
expiryMonth: companyCards.expiryMonth,
expiryYear: companyCards.expiryYear,
nickname: companyCards.nickname,
isActive: companyCards.isActive,
notes: companyCards.notes,
bankAccountId: companyCards.bankAccountId,
bankAccountLabel: sql<string | null>`(
SELECT ${companyBankAccounts.bankName} || ' · ' || RIGHT(${companyBankAccounts.accountNumber}, 4)
FROM ${companyBankAccounts}
WHERE ${companyBankAccounts.id} = ${companyCards.bankAccountId}
)`,
createdAt: companyCards.createdAt
})
.from(companyCards)
.where(eq(companyCards.companyId, params.companyId))
.orderBy(asc(companyCards.brand));
const addresses = await db const addresses = await db
.select() .select()
.from(companyAddresses) .from(companyAddresses)
.where(eq(companyAddresses.companyId, params.companyId)) .where(eq(companyAddresses.companyId, params.companyId))
.orderBy(asc(companyAddresses.type), desc(companyAddresses.isDefault)); .orderBy(asc(companyAddresses.type), desc(companyAddresses.isDefault));
return { bankAccounts, cards, addresses }; return { addresses };
}; };
export const actions: Actions = { export const actions: Actions = {
addBankAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const fd = await request.formData();
const bankName = trimOrNull(fd.get('bankName'));
const accountName = trimOrNull(fd.get('accountName'));
const accountNumber = trimOrNull(fd.get('accountNumber'));
if (!bankName) return fail(400, { error: 'Bank name is required' });
if (!accountName) return fail(400, { error: 'Account name is required' });
if (!accountNumber) return fail(400, { error: 'Account number is required' });
const isPrimary = fd.get('isPrimary') === 'on';
// If marking primary, demote others first
if (isPrimary) {
await db
.update(companyBankAccounts)
.set({ isPrimary: false })
.where(eq(companyBankAccounts.companyId, params.companyId));
}
await db.insert(companyBankAccounts).values({
companyId: params.companyId,
bankName,
accountName,
accountNumber,
accountType: trimOrNull(fd.get('accountType')),
branch: trimOrNull(fd.get('branch')),
swiftBic: trimOrNull(fd.get('swiftBic')),
iban: trimOrNull(fd.get('iban')),
currency: trimOrNull(fd.get('currency')) ?? 'THB',
isPrimary,
notes: trimOrNull(fd.get('notes'))
});
await logCompanyEvent(
params.companyId,
user.id,
'bank_account_added',
`Bank account "${bankName}" added`
);
return { success: true };
},
updateBankAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const fd = await request.formData();
const id = fd.get('id')?.toString();
if (!id) return fail(400, { error: 'Missing ID' });
const bankName = trimOrNull(fd.get('bankName'));
const accountName = trimOrNull(fd.get('accountName'));
const accountNumber = trimOrNull(fd.get('accountNumber'));
if (!bankName || !accountName || !accountNumber) {
return fail(400, { error: 'Bank name, account name, and account number are required' });
}
await db
.update(companyBankAccounts)
.set({
bankName,
accountName,
accountNumber,
accountType: trimOrNull(fd.get('accountType')),
branch: trimOrNull(fd.get('branch')),
swiftBic: trimOrNull(fd.get('swiftBic')),
iban: trimOrNull(fd.get('iban')),
currency: trimOrNull(fd.get('currency')) ?? 'THB',
isActive: fd.get('isActive') === 'on',
notes: trimOrNull(fd.get('notes')),
updatedAt: new Date()
})
.where(
and(
eq(companyBankAccounts.id, id),
eq(companyBankAccounts.companyId, params.companyId)
)
);
await logCompanyEvent(
params.companyId,
user.id,
'bank_account_updated',
`Bank account "${bankName}" updated`
);
return { success: true };
},
setPrimaryBankAccount: async ({ request, locals, params }) => {
await requireCompanyRole(locals, params.companyId, 'admin');
const fd = await request.formData();
const id = fd.get('id')?.toString();
if (!id) return fail(400, { error: 'Missing ID' });
await db
.update(companyBankAccounts)
.set({ isPrimary: false })
.where(eq(companyBankAccounts.companyId, params.companyId));
await db
.update(companyBankAccounts)
.set({ isPrimary: true, updatedAt: new Date() })
.where(
and(
eq(companyBankAccounts.id, id),
eq(companyBankAccounts.companyId, params.companyId)
)
);
return { success: true };
},
removeBankAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const fd = await request.formData();
const id = fd.get('id')?.toString();
if (!id) return fail(400, { error: 'Missing ID' });
const [ba] = await db
.select({ bankName: companyBankAccounts.bankName })
.from(companyBankAccounts)
.where(
and(
eq(companyBankAccounts.id, id),
eq(companyBankAccounts.companyId, params.companyId)
)
)
.limit(1);
await db
.delete(companyBankAccounts)
.where(
and(
eq(companyBankAccounts.id, id),
eq(companyBankAccounts.companyId, params.companyId)
)
);
if (ba) {
await logCompanyEvent(
params.companyId,
user.id,
'bank_account_removed',
`Bank account "${ba.bankName}" removed`
);
}
return { success: true };
},
addCard: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const fd = await request.formData();
const brand = fd.get('brand')?.toString() as CardBrand | undefined;
const last4 = fd.get('last4')?.toString().trim();
const cardholderName = trimOrNull(fd.get('cardholderName'));
if (!brand || !ALL_CARD_BRANDS.includes(brand)) {
return fail(400, { error: 'Card brand is required' });
}
if (!last4 || !/^\d{4}$/.test(last4)) {
return fail(400, { error: 'Last 4 digits must be exactly 4 numbers' });
}
if (!cardholderName) return fail(400, { error: 'Cardholder name is required' });
const bankAccountId = trimOrNull(fd.get('bankAccountId'));
await db.insert(companyCards).values({
companyId: params.companyId,
brand,
last4,
cardholderName,
expiryMonth: parseInt0(fd.get('expiryMonth')),
expiryYear: parseInt0(fd.get('expiryYear')),
nickname: trimOrNull(fd.get('nickname')),
bankAccountId,
notes: trimOrNull(fd.get('notes'))
});
await logCompanyEvent(
params.companyId,
user.id,
'card_added',
`Card ${brand.toUpperCase()} •••• ${last4} added`
);
return { success: true };
},
removeCard: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const fd = await request.formData();
const id = fd.get('id')?.toString();
if (!id) return fail(400, { error: 'Missing ID' });
const [c] = await db
.select({ brand: companyCards.brand, last4: companyCards.last4 })
.from(companyCards)
.where(and(eq(companyCards.id, id), eq(companyCards.companyId, params.companyId)))
.limit(1);
await db
.delete(companyCards)
.where(and(eq(companyCards.id, id), eq(companyCards.companyId, params.companyId)));
if (c) {
await logCompanyEvent(
params.companyId,
user.id,
'card_removed',
`Card ${c.brand.toUpperCase()} •••• ${c.last4} removed`
);
}
return { success: true };
},
addAddress: async ({ request, locals, params }) => { addAddress: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin'); const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const fd = await request.formData(); const fd = await request.formData();
@@ -6,22 +6,9 @@
const isAdmin = $derived(data.companyRoles.includes('admin')); const isAdmin = $derived(data.companyRoles.includes('admin'));
let showAddBank = $state(false);
let editBankId = $state<string | null>(null);
let showAddCard = $state(false);
let showAddAddress = $state(false); let showAddAddress = $state(false);
let editAddressId = $state<string | null>(null); let editAddressId = $state<string | null>(null);
const BRAND_LABELS: Record<string, string> = {
visa: 'Visa',
mastercard: 'Mastercard',
amex: 'American Express',
jcb: 'JCB',
unionpay: 'UnionPay',
discover: 'Discover',
other: 'Other'
};
const ADDRESS_TYPE_LABELS: Record<string, string> = { const ADDRESS_TYPE_LABELS: Record<string, string> = {
legal: 'Legal', legal: 'Legal',
shipping: 'Shipping', shipping: 'Shipping',
@@ -36,18 +23,7 @@
other: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' other: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
}; };
function maskAccount(n: string): string { function fullAddress(a: (typeof data.addresses)[number]): string {
if (!n) return '';
if (n.length <= 4) return n;
return '••••' + n.slice(-4);
}
function formatExpiry(m: number | null, y: number | null): string {
if (!m || !y) return '—';
return `${String(m).padStart(2, '0')}/${String(y).slice(-2)}`;
}
function fullAddress(a: typeof data.addresses[number]): string {
return [ return [
a.addressLine1, a.addressLine1,
a.addressLine2, a.addressLine2,
@@ -87,7 +63,10 @@
<header> <header>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Company Profile</h1> <h1 class="text-2xl font-bold text-gray-900 dark:text-white">Company Profile</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Reference data for accounting, payments, and shipping. Visible to admin, manager, and accountant. Editing is admin-only. Legal and shipping addresses. For bank accounts, cards, cash, and anything with a balance, use the <a
href={`/companies/${data.company.id}/accounts`}
class="font-medium text-blue-600 underline hover:text-blue-700 dark:text-blue-400">Accounts</a
> tab.
</p> </p>
</header> </header>
@@ -97,234 +76,30 @@
</div> </div>
{/if} {/if}
<!-- ========== Bank Accounts ========== -->
<section class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-4 flex items-center justify-between">
<h2 class="font-semibold text-gray-900 dark:text-white">Bank Accounts</h2>
{#if isAdmin}
<button onclick={() => (showAddBank = !showAddBank)}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
{showAddBank ? 'Cancel' : '+ Add Bank Account'}
</button>
{/if}
</div>
{#if showAddBank && isAdmin}
<form method="POST" action="?/addBankAccount"
use:enhance={() => async ({ update }) => { await update(); showAddBank = false; }}
class="mb-4 grid grid-cols-1 sm:grid-cols-2 gap-3 rounded-md border border-blue-200 bg-blue-50 p-4 dark:border-blue-700/50 dark:bg-blue-900/20">
<div><label for="bankName" class={labelCls}>Bank Name *</label><input id="bankName" name="bankName" required class={inputCls} /></div>
<div><label for="accountName" class={labelCls}>Account Name *</label><input id="accountName" name="accountName" required class={inputCls} /></div>
<div><label for="accountNumber" class={labelCls}>Account Number *</label><input id="accountNumber" name="accountNumber" required class={inputCls} /></div>
<div><label for="accountType" class={labelCls}>Type</label><input id="accountType" name="accountType" placeholder="savings / current" class={inputCls} /></div>
<div><label for="branch" class={labelCls}>Branch</label><input id="branch" name="branch" class={inputCls} /></div>
<div><label for="currency" class={labelCls}>Currency</label><input id="currency" name="currency" value="THB" maxlength="3" class={inputCls} /></div>
<div><label for="swiftBic" class={labelCls}>SWIFT/BIC</label><input id="swiftBic" name="swiftBic" class={inputCls} /></div>
<div><label for="iban" class={labelCls}>IBAN</label><input id="iban" name="iban" class={inputCls} /></div>
<div class="sm:col-span-2"><label for="ba-notes" class={labelCls}>Notes</label><textarea id="ba-notes" name="notes" rows="2" class={inputCls}></textarea></div>
<label class="sm:col-span-2 flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" name="isPrimary" class="rounded" /> Mark as primary account
</label>
<div class="sm:col-span-2 flex justify-end">
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Save</button>
</div>
</form>
{/if}
{#if data.bankAccounts.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No bank accounts on file.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr class="text-left text-gray-500 dark:text-gray-400">
<th class="px-3 py-2 font-medium">Bank</th>
<th class="px-3 py-2 font-medium">Account Name</th>
<th class="px-3 py-2 font-medium">Account Number</th>
<th class="px-3 py-2 font-medium">Type</th>
<th class="px-3 py-2 font-medium">Currency</th>
<th class="px-3 py-2 font-medium">Status</th>
{#if isAdmin}<th class="px-3 py-2 font-medium">Actions</th>{/if}
</tr>
</thead>
<tbody>
{#each data.bankAccounts as ba}
<tr class="border-t border-gray-100 dark:border-gray-700">
<td class="px-3 py-2 font-medium text-gray-900 dark:text-white">
{ba.bankName}
{#if ba.branch}<span class="ml-1 text-xs text-gray-400">({ba.branch})</span>{/if}
</td>
<td class="px-3 py-2 text-gray-700 dark:text-gray-300">{ba.accountName}</td>
<td class="px-3 py-2 font-mono text-gray-700 dark:text-gray-300">{maskAccount(ba.accountNumber)}</td>
<td class="px-3 py-2 text-gray-500 dark:text-gray-400">{ba.accountType ?? '—'}</td>
<td class="px-3 py-2 text-gray-700 dark:text-gray-300">{ba.currency}</td>
<td class="px-3 py-2">
{#if ba.isPrimary}<span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">Primary</span>{/if}
{#if !ba.isActive}<span class="ml-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-400">Inactive</span>{/if}
</td>
{#if isAdmin}
<td class="px-3 py-2">
<div class="flex gap-2 text-xs">
<button type="button" onclick={() => (editBankId = editBankId === ba.id ? null : ba.id)}
class="text-blue-600 hover:text-blue-800 dark:text-blue-400">{editBankId === ba.id ? 'Close' : 'Edit'}</button>
{#if !ba.isPrimary}
<form method="POST" action="?/setPrimaryBankAccount" use:enhance={enhanceNoReset} class="inline">
<input type="hidden" name="id" value={ba.id} />
<button type="submit" class="text-gray-600 hover:text-gray-800 dark:text-gray-300">Set Primary</button>
</form>
{/if}
<form method="POST" action="?/removeBankAccount"
use:enhance={({ cancel }) => {
if (!confirm('Remove this bank account?')) { cancel(); return; }
return async ({ update }) => await update({ reset: false });
}} class="inline">
<input type="hidden" name="id" value={ba.id} />
<button type="submit" class="text-red-600 hover:text-red-800 dark:text-red-400">Remove</button>
</form>
</div>
</td>
{/if}
</tr>
{#if editBankId === ba.id && isAdmin}
<tr class="border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<td colspan="7" class="px-3 py-3">
<form method="POST" action="?/updateBankAccount"
use:enhance={() => async ({ update }) => { await update({ reset: false }); editBankId = null; }}
class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input type="hidden" name="id" value={ba.id} />
<div><span class={labelCls}>Bank Name *</span><input name="bankName" required value={ba.bankName} class={inputCls} /></div>
<div><span class={labelCls}>Account Name *</span><input name="accountName" required value={ba.accountName} class={inputCls} /></div>
<div><span class={labelCls}>Account Number *</span><input name="accountNumber" required value={ba.accountNumber} class={inputCls + ' font-mono'} /></div>
<div><span class={labelCls}>Type</span><input name="accountType" value={ba.accountType ?? ''} class={inputCls} /></div>
<div><span class={labelCls}>Branch</span><input name="branch" value={ba.branch ?? ''} class={inputCls} /></div>
<div><span class={labelCls}>Currency</span><input name="currency" value={ba.currency} maxlength="3" class={inputCls} /></div>
<div><span class={labelCls}>SWIFT/BIC</span><input name="swiftBic" value={ba.swiftBic ?? ''} class={inputCls} /></div>
<div><span class={labelCls}>IBAN</span><input name="iban" value={ba.iban ?? ''} class={inputCls} /></div>
<div class="sm:col-span-2"><span class={labelCls}>Notes</span><textarea name="notes" rows="2" class={inputCls}>{ba.notes ?? ''}</textarea></div>
<label class="sm:col-span-2 flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" name="isActive" checked={ba.isActive} class="rounded" /> Active
</label>
<div class="sm:col-span-2 flex justify-end gap-2">
<button type="button" onclick={() => (editBankId = null)} class="rounded-md px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">Cancel</button>
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Save</button>
</div>
</form>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<!-- ========== Cards ========== -->
<section class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-4 flex items-center justify-between">
<h2 class="font-semibold text-gray-900 dark:text-white">Credit / Debit Cards</h2>
{#if isAdmin}
<button onclick={() => (showAddCard = !showAddCard)}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
{showAddCard ? 'Cancel' : '+ Add Card'}
</button>
{/if}
</div>
<div class="mb-4 rounded-md bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:bg-amber-900/30 dark:text-amber-200">
<strong>Last 4 digits only.</strong> Never enter a full card number — this app does not store full PANs.
</div>
{#if showAddCard && isAdmin}
<form method="POST" action="?/addCard"
use:enhance={() => async ({ update }) => { await update(); showAddCard = false; }}
class="mb-4 grid grid-cols-1 sm:grid-cols-3 gap-3 rounded-md border border-blue-200 bg-blue-50 p-4 dark:border-blue-700/50 dark:bg-blue-900/20">
<div>
<label for="brand" class={labelCls}>Brand *</label>
<select id="brand" name="brand" required class={inputCls}>
{#each Object.entries(BRAND_LABELS) as [val, label]}<option value={val}>{label}</option>{/each}
</select>
</div>
<div><label for="last4" class={labelCls}>Last 4 *</label><input id="last4" name="last4" required maxlength="4" minlength="4" pattern="[0-9]{'{'}4{'}'}" inputmode="numeric" placeholder="1234" class={inputCls + ' font-mono'} /></div>
<div><label for="cardholderName" class={labelCls}>Cardholder *</label><input id="cardholderName" name="cardholderName" required class={inputCls} /></div>
<div><label for="expiryMonth" class={labelCls}>Expiry Month</label><input id="expiryMonth" name="expiryMonth" type="number" min="1" max="12" placeholder="MM" class={inputCls} /></div>
<div><label for="expiryYear" class={labelCls}>Expiry Year</label><input id="expiryYear" name="expiryYear" type="number" min="2024" max="2099" placeholder="YYYY" class={inputCls} /></div>
<div><label for="nickname" class={labelCls}>Nickname</label><input id="nickname" name="nickname" placeholder="e.g. Ops Visa" class={inputCls} /></div>
<div class="sm:col-span-3">
<label for="bankAccountId" class={labelCls}>Linked Bank Account</label>
<select id="bankAccountId" name="bankAccountId" class={inputCls}>
<option value="">— None —</option>
{#each data.bankAccounts as ba}<option value={ba.id}>{ba.bankName} · {maskAccount(ba.accountNumber)}</option>{/each}
</select>
</div>
<div class="sm:col-span-3"><label for="card-notes" class={labelCls}>Notes</label><textarea id="card-notes" name="notes" rows="2" class={inputCls}></textarea></div>
<div class="sm:col-span-3 flex justify-end">
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Save</button>
</div>
</form>
{/if}
{#if data.cards.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No cards on file.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr class="text-left text-gray-500 dark:text-gray-400">
<th class="px-3 py-2 font-medium">Brand</th>
<th class="px-3 py-2 font-medium">Card</th>
<th class="px-3 py-2 font-medium">Cardholder</th>
<th class="px-3 py-2 font-medium">Expiry</th>
<th class="px-3 py-2 font-medium">Linked Bank</th>
<th class="px-3 py-2 font-medium">Nickname</th>
{#if isAdmin}<th class="px-3 py-2 font-medium"></th>{/if}
</tr>
</thead>
<tbody>
{#each data.cards as c}
<tr class="border-t border-gray-100 dark:border-gray-700">
<td class="px-3 py-2 font-medium text-gray-900 dark:text-white">{BRAND_LABELS[c.brand] ?? c.brand}</td>
<td class="px-3 py-2 font-mono text-gray-700 dark:text-gray-300">•••• {c.last4}</td>
<td class="px-3 py-2 text-gray-700 dark:text-gray-300">{c.cardholderName}</td>
<td class="px-3 py-2 text-gray-500 dark:text-gray-400">{formatExpiry(c.expiryMonth, c.expiryYear)}</td>
<td class="px-3 py-2 text-gray-500 dark:text-gray-400">{c.bankAccountLabel ?? '—'}</td>
<td class="px-3 py-2 text-gray-500 dark:text-gray-400">{c.nickname ?? '—'}</td>
{#if isAdmin}
<td class="px-3 py-2 text-right">
<form method="POST" action="?/removeCard"
use:enhance={({ cancel }) => {
if (!confirm('Remove this card?')) { cancel(); return; }
return async ({ update }) => await update({ reset: false });
}} class="inline">
<input type="hidden" name="id" value={c.id} />
<button type="submit" class="text-xs text-red-600 hover:text-red-800 dark:text-red-400">Remove</button>
</form>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<!-- ========== Addresses ========== --> <!-- ========== Addresses ========== -->
<section class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"> <section class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<h2 class="font-semibold text-gray-900 dark:text-white">Addresses</h2> <h2 class="font-semibold text-gray-900 dark:text-white">Addresses</h2>
{#if isAdmin} {#if isAdmin}
<button onclick={() => (showAddAddress = !showAddAddress)} <button
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"> onclick={() => (showAddAddress = !showAddAddress)}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
{showAddAddress ? 'Cancel' : '+ Add Address'} {showAddAddress ? 'Cancel' : '+ Add Address'}
</button> </button>
{/if} {/if}
</div> </div>
{#if showAddAddress && isAdmin} {#if showAddAddress && isAdmin}
<form method="POST" action="?/addAddress" <form
use:enhance={() => async ({ update }) => { await update(); showAddAddress = false; }} method="POST"
class="mb-4 grid grid-cols-1 sm:grid-cols-2 gap-3 rounded-md border border-blue-200 bg-blue-50 p-4 dark:border-blue-700/50 dark:bg-blue-900/20"> action="?/addAddress"
use:enhance={() => async ({ update }) => {
await update();
showAddAddress = false;
}}
class="mb-4 grid grid-cols-1 sm:grid-cols-2 gap-3 rounded-md border border-blue-200 bg-blue-50 p-4 dark:border-blue-700/50 dark:bg-blue-900/20"
>
<div> <div>
<label for="addr-type" class={labelCls}>Type *</label> <label for="addr-type" class={labelCls}>Type *</label>
<select id="addr-type" name="type" required class={inputCls}> <select id="addr-type" name="type" required class={inputCls}>
@@ -1,8 +1,15 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { expenses, categories, tags, expenseTags, projects } from '$lib/server/db/schema.js'; import {
import { eq, and } from 'drizzle-orm'; expenses,
categories,
tags,
expenseTags,
projects,
companyAccounts
} from '$lib/server/db/schema.js';
import { eq, and, isNull } from 'drizzle-orm';
import { requireCompanyRole } from '$lib/server/authorization.js'; import { requireCompanyRole } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js'; import { logCompanyEvent } from '$lib/server/audit.js';
import { formatCurrency } from '$lib/utils/currency.js'; import { formatCurrency } from '$lib/utils/currency.js';
@@ -22,6 +29,23 @@ export const load: PageServerLoad = async ({ locals, params }) => {
.where(eq(tags.companyId, params.companyId)) .where(eq(tags.companyId, params.companyId))
.orderBy(tags.name); .orderBy(tags.name);
const accountList = await db
.select({
id: companyAccounts.id,
name: companyAccounts.name,
currency: companyAccounts.currency,
accountType: companyAccounts.accountType
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.companyId, params.companyId),
eq(companyAccounts.isArchived, false),
isNull(companyAccounts.deletedAt)
)
)
.orderBy(companyAccounts.name);
// Get project info for the currency // Get project info for the currency
const [project] = await db const [project] = await db
.select({ name: projects.name }) .select({ name: projects.name })
@@ -29,7 +53,12 @@ export const load: PageServerLoad = async ({ locals, params }) => {
.where(eq(projects.id, params.projectId)) .where(eq(projects.id, params.projectId))
.limit(1); .limit(1);
return { categories: categoryList, tags: tagList, projectName: project?.name }; return {
categories: categoryList,
tags: tagList,
accounts: accountList,
projectName: project?.name
};
}; };
export const actions: Actions = { export const actions: Actions = {
@@ -42,6 +71,7 @@ export const actions: Actions = {
const amount = formData.get('amount')?.toString().trim(); const amount = formData.get('amount')?.toString().trim();
const expenseDate = formData.get('expenseDate')?.toString(); const expenseDate = formData.get('expenseDate')?.toString();
const categoryId = formData.get('categoryId')?.toString() || null; const categoryId = formData.get('categoryId')?.toString() || null;
const accountId = formData.get('accountId')?.toString() || null;
const tagIds = formData.getAll('tagIds').map((t) => t.toString()); const tagIds = formData.getAll('tagIds').map((t) => t.toString());
if (!title || !amount || !expenseDate) { if (!title || !amount || !expenseDate) {
@@ -69,6 +99,7 @@ export const actions: Actions = {
.values({ .values({
projectId: params.projectId, projectId: params.projectId,
categoryId: categoryId || null, categoryId: categoryId || null,
accountId: accountId || null,
submittedBy: user.id, submittedBy: user.id,
title, title,
description, description,
@@ -83,6 +83,25 @@
</select> </select>
</div> </div>
{#if data.accounts.length > 0}
<div class="mb-4">
<label for="accountId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Funding Account
<span class="ml-1 text-xs text-gray-400">(posts on approval)</span>
</label>
<select
id="accountId"
name="accountId"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
>
<option value="">None — assign later</option>
{#each data.accounts as acct}
<option value={acct.id}>{acct.name} ({acct.currency})</option>
{/each}
</select>
</div>
{/if}
{#if data.tags.length > 0} {#if data.tags.length > 0}
<div class="mb-4"> <div class="mb-4">
<span class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tags</span> <span class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tags</span>