Reconciliation link, account CSVs in export, drop legacy bank/card tables
Validate / validate (push) Successful in 31s
Validate / validate (push) Successful in 31s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,115 +0,0 @@
|
|||||||
/**
|
|
||||||
* One-shot legacy migration: copy rows from companyBankAccounts and companyCards
|
|
||||||
* into the unified companyAccounts table.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* tsx src/lib/server/accounts/migrate-legacy.ts
|
|
||||||
*
|
|
||||||
* Safe to re-run: skips any company that already has a companyAccounts row matching
|
|
||||||
* the same legacy identifier (bank: accountNumber; card: last4 + cardholderName).
|
|
||||||
*
|
|
||||||
* Note: companyCards.bankAccountId linkage is dropped — each card becomes a standalone
|
|
||||||
* companyAccounts row. Revisit later if card→funding-bank linkage needs preserving.
|
|
||||||
*/
|
|
||||||
import { db } from '$lib/server/db/index.js';
|
|
||||||
import {
|
|
||||||
companyAccounts,
|
|
||||||
companyBankAccounts,
|
|
||||||
companyCards
|
|
||||||
} from '$lib/server/db/schema.js';
|
|
||||||
import { and, eq } from 'drizzle-orm';
|
|
||||||
|
|
||||||
async function migrateBankAccounts(): Promise<number> {
|
|
||||||
const rows = await db.select().from(companyBankAccounts);
|
|
||||||
let inserted = 0;
|
|
||||||
for (const r of rows) {
|
|
||||||
// Skip if a row with same companyId + accountNumber already exists
|
|
||||||
const existing = await db
|
|
||||||
.select({ id: companyAccounts.id })
|
|
||||||
.from(companyAccounts)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(companyAccounts.companyId, r.companyId),
|
|
||||||
eq(companyAccounts.accountType, 'bank'),
|
|
||||||
eq(companyAccounts.accountNumber, r.accountNumber)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (existing.length > 0) continue;
|
|
||||||
|
|
||||||
await db.insert(companyAccounts).values({
|
|
||||||
companyId: r.companyId,
|
|
||||||
accountType: 'bank',
|
|
||||||
name: r.bankName + (r.accountNumber ? ` •••• ${r.accountNumber.slice(-4)}` : ''),
|
|
||||||
currency: r.currency,
|
|
||||||
isActive: r.isActive,
|
|
||||||
notes: r.notes,
|
|
||||||
bankName: r.bankName,
|
|
||||||
accountNumber: r.accountNumber,
|
|
||||||
branch: r.branch,
|
|
||||||
swiftBic: r.swiftBic,
|
|
||||||
iban: r.iban,
|
|
||||||
accountHolderName: r.accountName,
|
|
||||||
createdAt: r.createdAt,
|
|
||||||
updatedAt: r.updatedAt
|
|
||||||
});
|
|
||||||
inserted++;
|
|
||||||
}
|
|
||||||
return inserted;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateCards(): Promise<number> {
|
|
||||||
const rows = await db.select().from(companyCards);
|
|
||||||
let inserted = 0;
|
|
||||||
for (const r of rows) {
|
|
||||||
const existing = await db
|
|
||||||
.select({ id: companyAccounts.id })
|
|
||||||
.from(companyAccounts)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(companyAccounts.companyId, r.companyId),
|
|
||||||
eq(companyAccounts.accountType, 'credit_card'),
|
|
||||||
eq(companyAccounts.last4, r.last4)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (existing.length > 0) continue;
|
|
||||||
|
|
||||||
const displayName =
|
|
||||||
r.nickname ?? `${r.cardholderName} •••• ${r.last4}`;
|
|
||||||
|
|
||||||
await db.insert(companyAccounts).values({
|
|
||||||
companyId: r.companyId,
|
|
||||||
accountType: 'credit_card',
|
|
||||||
name: displayName,
|
|
||||||
currency: 'THB',
|
|
||||||
isActive: r.isActive,
|
|
||||||
notes: r.notes,
|
|
||||||
cardBrand: r.brand,
|
|
||||||
last4: r.last4,
|
|
||||||
cardholderName: r.cardholderName,
|
|
||||||
expiryMonth: r.expiryMonth,
|
|
||||||
expiryYear: r.expiryYear,
|
|
||||||
createdAt: r.createdAt,
|
|
||||||
updatedAt: r.updatedAt
|
|
||||||
});
|
|
||||||
inserted++;
|
|
||||||
}
|
|
||||||
return inserted;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('Migrating legacy bank accounts and cards to companyAccounts…');
|
|
||||||
const bankCount = await migrateBankAccounts();
|
|
||||||
console.log(` bank accounts migrated: ${bankCount}`);
|
|
||||||
const cardCount = await migrateCards();
|
|
||||||
console.log(` cards migrated: ${cardCount}`);
|
|
||||||
console.log('Done. Legacy tables left in place; Phase 6 will drop them.');
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
.then(() => process.exit(0))
|
|
||||||
.catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -944,54 +944,6 @@ export const companyAccountTransactions = pgTable(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const companyBankAccounts = pgTable(
|
|
||||||
'company_bank_accounts',
|
|
||||||
{
|
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
|
||||||
companyId: uuid('company_id')
|
|
||||||
.notNull()
|
|
||||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
|
||||||
bankName: text('bank_name').notNull(),
|
|
||||||
accountName: text('account_name').notNull(),
|
|
||||||
accountNumber: text('account_number').notNull(),
|
|
||||||
accountType: text('account_type'),
|
|
||||||
branch: text('branch'),
|
|
||||||
swiftBic: text('swift_bic'),
|
|
||||||
iban: text('iban'),
|
|
||||||
currency: text('currency').notNull().default('THB'),
|
|
||||||
isPrimary: boolean('is_primary').notNull().default(false),
|
|
||||||
isActive: boolean('is_active').notNull().default(true),
|
|
||||||
notes: text('notes'),
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
|
||||||
},
|
|
||||||
(table) => [index('company_bank_accounts_company_idx').on(table.companyId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const companyCards = pgTable(
|
|
||||||
'company_cards',
|
|
||||||
{
|
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
|
||||||
companyId: uuid('company_id')
|
|
||||||
.notNull()
|
|
||||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
|
||||||
brand: cardBrandEnum('brand').notNull(),
|
|
||||||
last4: varchar('last4', { length: 4 }).notNull(),
|
|
||||||
cardholderName: text('cardholder_name').notNull(),
|
|
||||||
expiryMonth: integer('expiry_month'),
|
|
||||||
expiryYear: integer('expiry_year'),
|
|
||||||
nickname: text('nickname'),
|
|
||||||
bankAccountId: uuid('bank_account_id').references(() => companyBankAccounts.id, {
|
|
||||||
onDelete: 'set null'
|
|
||||||
}),
|
|
||||||
isActive: boolean('is_active').notNull().default(true),
|
|
||||||
notes: text('notes'),
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
|
||||||
},
|
|
||||||
(table) => [index('company_cards_company_idx').on(table.companyId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const companyAddresses = pgTable(
|
export const companyAddresses = pgTable(
|
||||||
'company_addresses',
|
'company_addresses',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 ──────────────────────────
|
||||||
|
|||||||
@@ -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,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,243 +76,30 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div
|
|
||||||
class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-900/20 dark:text-amber-200"
|
|
||||||
>
|
|
||||||
Bank accounts and cards are now managed in the <a
|
|
||||||
href={`/companies/${data.company.id}/accounts`}
|
|
||||||
class="font-medium underline hover:text-amber-900 dark:hover:text-amber-100">Accounts</a
|
|
||||||
> tab, where they also show rolling balances and transaction history. The sections below will be removed in an upcoming update.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ========== 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}>
|
||||||
|
|||||||
Reference in New Issue
Block a user