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