Compare commits
6 Commits
2c2353e2e7
...
77c5d72e43
| Author | SHA1 | Date | |
|---|---|---|---|
| 77c5d72e43 | |||
| 0d4fdb6fd7 | |||
| 3a095851e9 | |||
| d75fe6ed95 | |||
| aea6dbc06e | |||
| 57e72e5b6c |
@@ -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
@@ -127,6 +127,9 @@ export const expenses = pgTable(
|
||||
.references(() => projects.id, { onDelete: 'cascade' }),
|
||||
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
|
||||
partyId: uuid('party_id').references((): any => parties.id, { onDelete: 'set null' }),
|
||||
accountId: uuid('account_id').references((): any => companyAccounts.id, {
|
||||
onDelete: 'set null'
|
||||
}),
|
||||
submittedBy: text('submitted_by')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
@@ -452,6 +455,9 @@ export const invoices = pgTable(
|
||||
currency: text('currency').notNull().default('THB'),
|
||||
status: invoiceStatusEnum('status').notNull().default('draft'),
|
||||
expenseId: uuid('expense_id').references(() => expenses.id, { onDelete: 'set null' }),
|
||||
paymentAccountId: uuid('payment_account_id').references((): any => companyAccounts.id, {
|
||||
onDelete: 'set null'
|
||||
}),
|
||||
notes: text('notes'),
|
||||
pdfPath: text('pdf_path'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
@@ -826,52 +832,116 @@ export const cardBrandEnum = pgEnum('card_brand', [
|
||||
'other'
|
||||
]);
|
||||
|
||||
export const companyBankAccounts = pgTable(
|
||||
'company_bank_accounts',
|
||||
// ── Company Accounts (unified ledger) ──────────────────
|
||||
|
||||
export const companyAccountTypeEnum = pgEnum('company_account_type', [
|
||||
'bank',
|
||||
'credit_card',
|
||||
'cash',
|
||||
'mobile_money',
|
||||
'petty_cash',
|
||||
'loan',
|
||||
'other'
|
||||
]);
|
||||
|
||||
export const companyAccountTxnTypeEnum = pgEnum('company_account_txn_type', [
|
||||
'opening_balance',
|
||||
'expense',
|
||||
'invoice_payment',
|
||||
'transfer_in',
|
||||
'transfer_out',
|
||||
'deposit',
|
||||
'adjustment',
|
||||
'reconciliation'
|
||||
]);
|
||||
|
||||
export const companyAccounts = pgTable(
|
||||
'company_accounts',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
bankName: text('bank_name').notNull(),
|
||||
accountName: text('account_name').notNull(),
|
||||
accountNumber: text('account_number').notNull(),
|
||||
accountType: text('account_type'),
|
||||
accountType: companyAccountTypeEnum('account_type').notNull(),
|
||||
name: text('name').notNull(),
|
||||
currency: text('currency').notNull().default('THB'),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
isArchived: boolean('is_archived').notNull().default(false),
|
||||
notes: text('notes'),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
// Bank-specific
|
||||
bankName: text('bank_name'),
|
||||
accountNumber: text('account_number'),
|
||||
branch: text('branch'),
|
||||
swiftBic: text('swift_bic'),
|
||||
iban: text('iban'),
|
||||
currency: text('currency').notNull().default('THB'),
|
||||
isPrimary: boolean('is_primary').notNull().default(false),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
notes: text('notes'),
|
||||
accountHolderName: text('account_holder_name'),
|
||||
// Card-specific
|
||||
cardBrand: cardBrandEnum('card_brand'),
|
||||
last4: varchar('last4', { length: 4 }),
|
||||
cardholderName: text('cardholder_name'),
|
||||
expiryMonth: integer('expiry_month'),
|
||||
expiryYear: integer('expiry_year'),
|
||||
creditLimit: numeric('credit_limit', { precision: 15, scale: 2 }),
|
||||
statementCloseDay: integer('statement_close_day'),
|
||||
paymentDueDay: integer('payment_due_day'),
|
||||
// Banking integration link
|
||||
externalAccountId: uuid('external_account_id').references(() => externalAccounts.id, {
|
||||
onDelete: 'set null'
|
||||
}),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [index('company_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(
|
||||
'company_cards',
|
||||
export const companyAccountTransactions = pgTable(
|
||||
'company_account_transactions',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
accountId: uuid('account_id')
|
||||
.notNull()
|
||||
.references(() => companyAccounts.id, { onDelete: 'cascade' }),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
brand: cardBrandEnum('brand').notNull(),
|
||||
last4: varchar('last4', { length: 4 }).notNull(),
|
||||
cardholderName: text('cardholder_name').notNull(),
|
||||
expiryMonth: integer('expiry_month'),
|
||||
expiryYear: integer('expiry_year'),
|
||||
nickname: text('nickname'),
|
||||
bankAccountId: uuid('bank_account_id').references(() => companyBankAccounts.id, {
|
||||
type: companyAccountTxnTypeEnum('type').notNull(),
|
||||
amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
|
||||
currency: text('currency').notNull(),
|
||||
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
|
||||
description: text('description'),
|
||||
reference: text('reference'),
|
||||
counterpartyAccountId: uuid('counterparty_account_id').references(
|
||||
(): any => companyAccounts.id,
|
||||
{ onDelete: 'set null' }
|
||||
),
|
||||
sourceExpenseId: uuid('source_expense_id').references(() => expenses.id, {
|
||||
onDelete: 'set null'
|
||||
}),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
notes: text('notes'),
|
||||
sourceInvoiceId: uuid('source_invoice_id').references(() => invoices.id, {
|
||||
onDelete: 'set null'
|
||||
}),
|
||||
sourceExternalTransactionId: uuid('source_external_transaction_id').references(
|
||||
() => externalTransactions.id,
|
||||
{ onDelete: 'set null' }
|
||||
),
|
||||
fxRate: numeric('fx_rate', { precision: 18, scale: 8 }),
|
||||
fxAmount: numeric('fx_amount', { precision: 15, scale: 2 }),
|
||||
createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [index('company_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(
|
||||
@@ -960,7 +1030,14 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
|
||||
'document_deleted',
|
||||
'link_added',
|
||||
'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(
|
||||
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
externalAccounts,
|
||||
externalTransactions,
|
||||
users,
|
||||
companyBankAccounts,
|
||||
companyCards,
|
||||
companyAddresses,
|
||||
companyAccounts,
|
||||
companyAccountTransactions,
|
||||
companyDocuments,
|
||||
companyDocumentVersions
|
||||
} from '../db/schema.js';
|
||||
@@ -73,8 +73,8 @@ export async function buildFinancialExport(
|
||||
``,
|
||||
`Files:`,
|
||||
` company.csv — company record`,
|
||||
` company_bank_accounts.csv — company bank accounts`,
|
||||
` company_cards.csv — company credit/debit cards (last 4 only)`,
|
||||
` company_accounts.csv — unified ledger accounts (bank, card, cash, etc.)`,
|
||||
` company_account_transactions.csv — ledger transactions in the selected year`,
|
||||
` company_addresses.csv — legal/shipping/billing/other addresses`,
|
||||
` company_documents.csv — uploaded document metadata (files not bundled)`,
|
||||
` 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()
|
||||
.from(companyBankAccounts)
|
||||
.where(eq(companyBankAccounts.companyId, companyId))
|
||||
.orderBy(asc(companyBankAccounts.bankName));
|
||||
.from(companyAccounts)
|
||||
.where(eq(companyAccounts.companyId, companyId))
|
||||
.orderBy(asc(companyAccounts.accountType), asc(companyAccounts.name));
|
||||
const rows: unknown[][] = [
|
||||
[
|
||||
'id', 'bankName', 'accountName', 'accountNumber', 'accountType', 'branch',
|
||||
'swiftBic', 'iban', 'currency', 'isPrimary', 'isActive', 'notes',
|
||||
'createdAt', 'updatedAt'
|
||||
'id', 'accountType', 'name', 'currency', 'isActive', 'isArchived',
|
||||
'bankName', 'accountNumber', 'branch', 'swiftBic', 'iban', 'accountHolderName',
|
||||
'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([
|
||||
b.id, b.bankName, b.accountName, b.accountNumber, b.accountType ?? '',
|
||||
b.branch ?? '', b.swiftBic ?? '', b.iban ?? '', b.currency,
|
||||
b.isPrimary, b.isActive, b.notes ?? '',
|
||||
b.createdAt.toISOString(), b.updatedAt.toISOString()
|
||||
a.id, a.accountType, a.name, a.currency, a.isActive, a.isArchived,
|
||||
a.bankName ?? '', a.accountNumber ?? '', a.branch ?? '', a.swiftBic ?? '',
|
||||
a.iban ?? '', a.accountHolderName ?? '',
|
||||
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({
|
||||
id: companyCards.id,
|
||||
brand: companyCards.brand,
|
||||
last4: companyCards.last4,
|
||||
cardholderName: companyCards.cardholderName,
|
||||
expiryMonth: companyCards.expiryMonth,
|
||||
expiryYear: companyCards.expiryYear,
|
||||
nickname: companyCards.nickname,
|
||||
bankAccountId: companyCards.bankAccountId,
|
||||
bankAccountName: companyBankAccounts.bankName,
|
||||
isActive: companyCards.isActive,
|
||||
notes: companyCards.notes,
|
||||
createdAt: companyCards.createdAt,
|
||||
updatedAt: companyCards.updatedAt
|
||||
id: companyAccountTransactions.id,
|
||||
accountId: companyAccountTransactions.accountId,
|
||||
accountName: companyAccounts.name,
|
||||
type: companyAccountTransactions.type,
|
||||
amount: companyAccountTransactions.amount,
|
||||
currency: companyAccountTransactions.currency,
|
||||
occurredAt: companyAccountTransactions.occurredAt,
|
||||
description: companyAccountTransactions.description,
|
||||
reference: companyAccountTransactions.reference,
|
||||
counterpartyAccountId: companyAccountTransactions.counterpartyAccountId,
|
||||
sourceExpenseId: companyAccountTransactions.sourceExpenseId,
|
||||
sourceInvoiceId: companyAccountTransactions.sourceInvoiceId,
|
||||
sourceExternalTransactionId: companyAccountTransactions.sourceExternalTransactionId,
|
||||
fxRate: companyAccountTransactions.fxRate,
|
||||
fxAmount: companyAccountTransactions.fxAmount,
|
||||
createdAt: companyAccountTransactions.createdAt
|
||||
})
|
||||
.from(companyCards)
|
||||
.leftJoin(companyBankAccounts, eq(companyCards.bankAccountId, companyBankAccounts.id))
|
||||
.where(eq(companyCards.companyId, companyId))
|
||||
.orderBy(asc(companyCards.brand));
|
||||
.from(companyAccountTransactions)
|
||||
.innerJoin(companyAccounts, eq(companyAccountTransactions.accountId, companyAccounts.id))
|
||||
.where(
|
||||
and(
|
||||
eq(companyAccountTransactions.companyId, companyId),
|
||||
sql`${companyAccountTransactions.occurredAt} >= ${yearStartDate}`,
|
||||
sql`${companyAccountTransactions.occurredAt} <= ${yearEndDate}`
|
||||
)
|
||||
)
|
||||
.orderBy(
|
||||
asc(companyAccountTransactions.occurredAt),
|
||||
asc(companyAccountTransactions.createdAt)
|
||||
);
|
||||
const rows: unknown[][] = [
|
||||
[
|
||||
'id', 'brand', 'last4', 'cardholderName', 'expiryMonth', 'expiryYear',
|
||||
'nickname', 'bankAccountId', 'bankAccountName', 'isActive', 'notes',
|
||||
'createdAt', 'updatedAt'
|
||||
'id', 'accountId', 'accountName', 'type', 'amount', 'currency',
|
||||
'occurredAt', 'description', 'reference',
|
||||
'counterpartyAccountId', 'sourceExpenseId', 'sourceInvoiceId',
|
||||
'sourceExternalTransactionId', 'fxRate', 'fxAmount', 'createdAt'
|
||||
]
|
||||
];
|
||||
for (const c of cardRows) {
|
||||
for (const t of txRows) {
|
||||
rows.push([
|
||||
c.id, c.brand, c.last4, c.cardholderName,
|
||||
c.expiryMonth ?? '', c.expiryYear ?? '',
|
||||
c.nickname ?? '', c.bankAccountId ?? '', c.bankAccountName ?? '',
|
||||
c.isActive, c.notes ?? '',
|
||||
c.createdAt.toISOString(), c.updatedAt.toISOString()
|
||||
t.id, t.accountId, t.accountName, t.type, t.amount, t.currency,
|
||||
t.occurredAt.toISOString(), t.description ?? '', t.reference ?? '',
|
||||
t.counterpartyAccountId ?? '', t.sourceExpenseId ?? '',
|
||||
t.sourceInvoiceId ?? '', t.sourceExternalTransactionId ?? '',
|
||||
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 ──────────────────────────
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
: []),
|
||||
...(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}/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"
|
||||
>
|
||||
← 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 { db } from '$lib/server/db/index.js';
|
||||
import { expenses, projects, users, categories } from '$lib/server/db/schema.js';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { requireCompanyRole } from '$lib/server/authorization.js';
|
||||
import {
|
||||
expenses,
|
||||
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 { formatCurrency } from '$lib/utils/currency.js';
|
||||
import {
|
||||
postExpenseTransaction,
|
||||
removeExpenseTransaction
|
||||
} from '$lib/server/accounts/ledger.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, params, url }) => {
|
||||
await parent();
|
||||
|
||||
const status = url.searchParams.get('status') || 'all';
|
||||
|
||||
let query = db
|
||||
const expenseList = await db
|
||||
.select({
|
||||
id: expenses.id,
|
||||
title: expenses.title,
|
||||
@@ -28,12 +38,15 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
|
||||
projectId: projects.id,
|
||||
projectName: projects.name,
|
||||
categoryName: categories.name,
|
||||
accountId: expenses.accountId,
|
||||
accountName: companyAccounts.name,
|
||||
createdAt: expenses.createdAt
|
||||
})
|
||||
.from(expenses)
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.innerJoin(users, eq(expenses.submittedBy, users.id))
|
||||
.leftJoin(categories, eq(expenses.categoryId, categories.id))
|
||||
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
|
||||
.where(
|
||||
status === 'all'
|
||||
? eq(projects.companyId, params.companyId)
|
||||
@@ -45,9 +58,24 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
|
||||
.orderBy(sql`${expenses.createdAt} desc`)
|
||||
.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 = {
|
||||
@@ -58,14 +86,19 @@ export const actions: Actions = {
|
||||
|
||||
if (!expenseId) return fail(400, { error: 'Missing expense ID' });
|
||||
|
||||
// Get expense details for the log
|
||||
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)
|
||||
.where(eq(expenses.id, expenseId))
|
||||
.limit(1);
|
||||
|
||||
await db
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(expenses)
|
||||
.set({
|
||||
status: 'approved',
|
||||
@@ -75,10 +108,18 @@ export const actions: Actions = {
|
||||
})
|
||||
.where(and(eq(expenses.id, expenseId), eq(expenses.status, 'pending')));
|
||||
|
||||
if (expense?.accountId) {
|
||||
await postExpenseTransaction(expenseId, expense.accountId, user.id, tx);
|
||||
}
|
||||
});
|
||||
|
||||
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)}`,
|
||||
{ expenseId, amount: expense.amount }
|
||||
{ expenseId, amount: expense.amount, accountId: expense.accountId }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,7 +140,8 @@ export const actions: Actions = {
|
||||
.where(eq(expenses.id, expenseId))
|
||||
.limit(1);
|
||||
|
||||
await db
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(expenses)
|
||||
.set({
|
||||
status: 'rejected',
|
||||
@@ -110,13 +152,87 @@ export const actions: Actions = {
|
||||
})
|
||||
.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) {
|
||||
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}` : ''}`,
|
||||
{ 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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
const canApprove = $derived(
|
||||
data.companyRoles.includes('admin') || data.companyRoles.includes('manager')
|
||||
);
|
||||
const canAssignAccount = $derived(
|
||||
data.companyRoles.includes('admin') ||
|
||||
data.companyRoles.includes('manager') ||
|
||||
data.companyRoles.includes('accountant')
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -57,6 +62,15 @@
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
By {expense.submitterName ?? expense.submitterEmail} · {expense.expenseDate}
|
||||
</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 class="text-right">
|
||||
<p class="text-lg font-semibold dark:text-white">{formatCurrency(expense.amount, expense.currency)}</p>
|
||||
@@ -80,7 +94,7 @@
|
||||
{/if}
|
||||
|
||||
{#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>
|
||||
<input type="hidden" name="expenseId" value={expense.id} />
|
||||
<button
|
||||
@@ -107,6 +121,37 @@
|
||||
</form>
|
||||
</div>
|
||||
{/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>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { externalAccounts, externalTransactions, expenses, projects } from '$lib/server/db/schema.js';
|
||||
import { eq, and, isNull, isNotNull, desc } from 'drizzle-orm';
|
||||
import {
|
||||
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 { logCompanyEvent } from '$lib/server/audit.js';
|
||||
import { postReconciliationTransaction } from '$lib/server/accounts/ledger.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params, url }) => {
|
||||
await requireCompanyRole(locals, params.companyId, 'admin');
|
||||
@@ -82,11 +90,53 @@ export const load: PageServerLoad = async ({ locals, params, url }) => {
|
||||
.orderBy(desc(expenses.createdAt))
|
||||
.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 {
|
||||
transactions,
|
||||
matchedExpenseTitles,
|
||||
matchableExpenses,
|
||||
matchedFilter: matched
|
||||
matchedFilter: matched,
|
||||
accounts: accountsList,
|
||||
postedMap
|
||||
};
|
||||
};
|
||||
|
||||
@@ -144,6 +194,72 @@ export const actions: Actions = {
|
||||
.set({ matchedExpenseId: null })
|
||||
.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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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">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">Ledger Account</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -152,6 +153,44 @@
|
||||
</form>
|
||||
{/if}
|
||||
</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>
|
||||
{/each}
|
||||
</tbody>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { invoices, parties } from '$lib/server/db/schema.js';
|
||||
import { eq, and, sql, gte, lte } from 'drizzle-orm';
|
||||
import { invoices, parties, companyAccounts } from '$lib/server/db/schema.js';
|
||||
import { eq, and, sql, gte, lte, isNull } from 'drizzle-orm';
|
||||
import { requireCompanyRoleAny } from '$lib/server/authorization.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 }) => {
|
||||
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`)
|
||||
.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 = {
|
||||
@@ -90,7 +118,10 @@ export const actions: Actions = {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
|
||||
const formData = await request.formData();
|
||||
const invoiceId = formData.get('invoiceId')?.toString();
|
||||
const paymentAccountId = formData.get('paymentAccountId')?.toString() || null;
|
||||
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
|
||||
.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' });
|
||||
|
||||
await db
|
||||
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: 'paid', updatedAt: new Date() })
|
||||
.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(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'invoice_paid',
|
||||
`Marked invoice ${inv.invoiceNumber} as paid`,
|
||||
{ invoiceId }
|
||||
{ invoiceId, paymentAccountId }
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -141,14 +141,30 @@
|
||||
</form>
|
||||
{/if}
|
||||
{#if inv.status === 'sent' || inv.status === 'overdue'}
|
||||
<form method="POST" action="?/markPaid" use:enhance>
|
||||
{#if data.accounts.length === 0}
|
||||
<span class="text-xs text-gray-400" title="Create an account first to mark invoices paid">
|
||||
No account
|
||||
</span>
|
||||
{:else}
|
||||
<form method="POST" action="?/markPaid" use:enhance class="flex items-center gap-1">
|
||||
<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}
|
||||
</div>
|
||||
</td>
|
||||
{/if}
|
||||
|
||||
@@ -7,11 +7,16 @@ import {
|
||||
parties,
|
||||
expenses,
|
||||
projects,
|
||||
packages
|
||||
packages,
|
||||
companyAccounts
|
||||
} from '$lib/server/db/schema.js';
|
||||
import { eq, and, isNull } from 'drizzle-orm';
|
||||
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||
import {
|
||||
postInvoicePaymentTransaction,
|
||||
removeInvoicePaymentTransaction
|
||||
} from '$lib/server/accounts/ledger.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'user']);
|
||||
@@ -30,6 +35,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
currency: invoices.currency,
|
||||
notes: invoices.notes,
|
||||
expenseId: invoices.expenseId,
|
||||
paymentAccountId: invoices.paymentAccountId,
|
||||
createdAt: invoices.createdAt,
|
||||
partyId: invoices.partyId,
|
||||
partyName: parties.name,
|
||||
@@ -49,6 +55,23 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
|
||||
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
|
||||
.select()
|
||||
.from(invoiceLineItems)
|
||||
@@ -72,7 +95,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
.where(eq(packages.invoiceId, params.invoiceId))
|
||||
.orderBy(packages.createdAt);
|
||||
|
||||
return { invoice, lineItems, projects: projectList, linkedPackages };
|
||||
return { invoice, lineItems, projects: projectList, linkedPackages, accounts: accountsList };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
@@ -86,6 +109,7 @@ export const actions: Actions = {
|
||||
| 'overdue'
|
||||
| 'cancelled'
|
||||
| undefined;
|
||||
const paymentAccountId = formData.get('paymentAccountId')?.toString() || null;
|
||||
|
||||
const validStatuses = ['draft', 'sent', 'paid', 'overdue', 'cancelled'];
|
||||
if (!newStatus || !validStatuses.includes(newStatus)) {
|
||||
@@ -93,22 +117,57 @@ export const actions: Actions = {
|
||||
}
|
||||
|
||||
const [inv] = await db
|
||||
.select({ invoiceNumber: invoices.invoiceNumber })
|
||||
.select({
|
||||
invoiceNumber: invoices.invoiceNumber,
|
||||
status: invoices.status,
|
||||
paymentAccountId: invoices.paymentAccountId
|
||||
})
|
||||
.from(invoices)
|
||||
.where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
|
||||
if (!inv) return fail(404, { error: 'Invoice not found' });
|
||||
|
||||
await db
|
||||
if (newStatus === 'paid') {
|
||||
if (!paymentAccountId) {
|
||||
return fail(400, { error: 'Payment account is required to mark an invoice paid' });
|
||||
}
|
||||
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, updatedAt: new Date() })
|
||||
.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') {
|
||||
await logCompanyEvent(params.companyId, user.id, 'invoice_sent', `Marked invoice ${inv.invoiceNumber} as sent`, { invoiceId: params.invoiceId });
|
||||
} 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 };
|
||||
|
||||
@@ -202,8 +202,27 @@
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<!-- Status transitions -->
|
||||
{#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} />
|
||||
{#if targetStatus === 'paid'}
|
||||
{#if data.accounts.length === 0}
|
||||
<span class="text-xs text-gray-400" title="Create an account first to mark as paid">
|
||||
No account
|
||||
</span>
|
||||
{:else}
|
||||
<select
|
||||
name="paymentAccountId"
|
||||
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' :
|
||||
@@ -213,6 +232,7 @@
|
||||
'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>
|
||||
{/each}
|
||||
|
||||
|
||||
@@ -1,300 +1,33 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import {
|
||||
companyBankAccounts,
|
||||
companyCards,
|
||||
companyAddresses
|
||||
} from '$lib/server/db/schema.js';
|
||||
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
||||
import { companyAddresses } from '$lib/server/db/schema.js';
|
||||
import { eq, and, desc, asc } from 'drizzle-orm';
|
||||
import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||
|
||||
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 CardBrand = (typeof ALL_CARD_BRANDS)[number];
|
||||
|
||||
function trimOrNull(v: FormDataEntryValue | null): string | null {
|
||||
const s = v?.toString().trim();
|
||||
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 }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
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
|
||||
.select()
|
||||
.from(companyAddresses)
|
||||
.where(eq(companyAddresses.companyId, params.companyId))
|
||||
.orderBy(asc(companyAddresses.type), desc(companyAddresses.isDefault));
|
||||
|
||||
return { bankAccounts, cards, addresses };
|
||||
return { addresses };
|
||||
};
|
||||
|
||||
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 }) => {
|
||||
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
|
||||
const fd = await request.formData();
|
||||
|
||||
@@ -6,22 +6,9 @@
|
||||
|
||||
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 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> = {
|
||||
legal: 'Legal',
|
||||
shipping: 'Shipping',
|
||||
@@ -36,18 +23,7 @@
|
||||
other: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
};
|
||||
|
||||
function maskAccount(n: string): 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 {
|
||||
function fullAddress(a: (typeof data.addresses)[number]): string {
|
||||
return [
|
||||
a.addressLine1,
|
||||
a.addressLine2,
|
||||
@@ -87,7 +63,10 @@
|
||||
<header>
|
||||
<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">
|
||||
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>
|
||||
</header>
|
||||
|
||||
@@ -97,234 +76,30 @@
|
||||
</div>
|
||||
{/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 ========== -->
|
||||
<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">Addresses</h2>
|
||||
{#if isAdmin}
|
||||
<button onclick={() => (showAddAddress = !showAddAddress)}
|
||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
|
||||
<button
|
||||
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'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showAddAddress && isAdmin}
|
||||
<form method="POST" 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">
|
||||
<form
|
||||
method="POST"
|
||||
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>
|
||||
<label for="addr-type" class={labelCls}>Type *</label>
|
||||
<select id="addr-type" name="type" required class={inputCls}>
|
||||
|
||||
+34
-3
@@ -1,8 +1,15 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { expenses, categories, tags, expenseTags, projects } from '$lib/server/db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import {
|
||||
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 { logCompanyEvent } from '$lib/server/audit.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))
|
||||
.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
|
||||
const [project] = await db
|
||||
.select({ name: projects.name })
|
||||
@@ -29,7 +53,12 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
.where(eq(projects.id, params.projectId))
|
||||
.limit(1);
|
||||
|
||||
return { categories: categoryList, tags: tagList, projectName: project?.name };
|
||||
return {
|
||||
categories: categoryList,
|
||||
tags: tagList,
|
||||
accounts: accountList,
|
||||
projectName: project?.name
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
@@ -42,6 +71,7 @@ export const actions: Actions = {
|
||||
const amount = formData.get('amount')?.toString().trim();
|
||||
const expenseDate = formData.get('expenseDate')?.toString();
|
||||
const categoryId = formData.get('categoryId')?.toString() || null;
|
||||
const accountId = formData.get('accountId')?.toString() || null;
|
||||
const tagIds = formData.getAll('tagIds').map((t) => t.toString());
|
||||
|
||||
if (!title || !amount || !expenseDate) {
|
||||
@@ -69,6 +99,7 @@ export const actions: Actions = {
|
||||
.values({
|
||||
projectId: params.projectId,
|
||||
categoryId: categoryId || null,
|
||||
accountId: accountId || null,
|
||||
submittedBy: user.id,
|
||||
title,
|
||||
description,
|
||||
|
||||
@@ -83,6 +83,25 @@
|
||||
</select>
|
||||
</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}
|
||||
<div class="mb-4">
|
||||
<span class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tags</span>
|
||||
|
||||
Reference in New Issue
Block a user