Major expansion: HR module, CRM, integrations, packages, validation pipeline
Validate / validate (push) Successful in 34s
Validate / validate (push) Successful in 34s
HR module: - Multi-role per company (admin/manager/user/viewer/hr orthogonal) - Employees with salary history, terminate/reactivate - Per-company public holidays (seeded from ppraserts/thailand-open-data with manual fallback for unsupported years) - Leave types (editable defaults), leave requests with approve/reject - Per-employee leave balances (auto-seeded), remaining-days hint on request form, HR balance summary on requests page - Thai-compliant payroll: SSO 5% capped, PND1 brackets, monthly WHT - Payslip generation with editable line items, finalize/mark-paid, pdf-lib PDF download - CSV export of leave per employee or company-wide CRM & invoicing: - Customer/supplier party database with archive - Invoice line items, VAT 7%, status transitions, PDF generation - Outgoing/incoming direction; incoming auto-creates linked expense Package tracking: - packages + package_events + shipping_accounts tables - 8 carrier stubs (UPS/FedEx/DHL/USPS/Flash Express/Kerry/J&T/TH Post) with API doc references for future implementation - Manual status updates with timeline - Customs duty invoice flow on delivery - Per-company carrier credentials (admin only) Integrations scaffold: - external_accounts + external_transactions (Kasikorn K-Biz, Ether.fi) - Manual transaction matching to expenses Infrastructure: - APP_NAME env var for branding - Soft-delete for companies and parties - Light/dark mode toggle, dark-mode classes throughout - pre-push hook (husky) + Gitea/GitHub Actions running svelte-check with --threshold warning + vite build - npm run validate combines both checks Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,41 +19,89 @@ export function requireSystemAdmin(locals: App.Locals): NonNullable<App.Locals['
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getCompanyRole(
|
||||
export async function getCompanyRoles(
|
||||
userId: string,
|
||||
companyId: string
|
||||
): Promise<CompanyRole | null> {
|
||||
): Promise<CompanyRole[] | null> {
|
||||
const result = await db
|
||||
.select({ role: companyMembers.role })
|
||||
.select({ roles: companyMembers.roles })
|
||||
.from(companyMembers)
|
||||
.where(and(eq(companyMembers.userId, userId), eq(companyMembers.companyId, companyId)))
|
||||
.limit(1);
|
||||
|
||||
if (result.length === 0) return null;
|
||||
return result[0].role;
|
||||
return result[0].roles as CompanyRole[];
|
||||
}
|
||||
|
||||
/** Does the role set include the target role? */
|
||||
export function hasRole(roles: CompanyRole[], target: CompanyRole): boolean {
|
||||
return roles.includes(target);
|
||||
}
|
||||
|
||||
/** Does any hierarchical role in the set meet or exceed the minimum rank? `hr` does not count. */
|
||||
export function meetsMinRole(roles: CompanyRole[], min: Exclude<CompanyRole, 'hr'>): boolean {
|
||||
const minRank = ROLE_HIERARCHY[min];
|
||||
for (const r of roles) {
|
||||
if (r === 'hr') continue;
|
||||
const rank = ROLE_HIERARCHY[r as Exclude<CompanyRole, 'hr'>];
|
||||
if (rank >= minRank) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the caller meets the minimum hierarchical role (admin>manager>user>viewer).
|
||||
* System admins bypass. Returns the caller's full role set.
|
||||
*/
|
||||
export async function requireCompanyRole(
|
||||
locals: App.Locals,
|
||||
companyId: string,
|
||||
minRole: CompanyRole
|
||||
): Promise<{ user: NonNullable<App.Locals['user']>; role: CompanyRole }> {
|
||||
minRole: Exclude<CompanyRole, 'hr'>
|
||||
): Promise<{ user: NonNullable<App.Locals['user']>; roles: CompanyRole[] }> {
|
||||
const user = requireAuth(locals);
|
||||
|
||||
// System admins bypass company role checks
|
||||
if (user.isSystemAdmin) {
|
||||
return { user, role: 'admin' };
|
||||
return { user, roles: ['admin'] };
|
||||
}
|
||||
|
||||
const role = await getCompanyRole(user.id, companyId);
|
||||
const roles = await getCompanyRoles(user.id, companyId);
|
||||
|
||||
if (!role) {
|
||||
if (!roles || roles.length === 0) {
|
||||
error(403, 'Not a member of this company');
|
||||
}
|
||||
|
||||
if (ROLE_HIERARCHY[role] < ROLE_HIERARCHY[minRole]) {
|
||||
if (!meetsMinRole(roles, minRole)) {
|
||||
error(403, `Requires ${minRole} role or higher`);
|
||||
}
|
||||
|
||||
return { user, role };
|
||||
return { user, roles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the caller has ANY of the listed roles. Useful for orthogonal roles like `hr`.
|
||||
* System admins bypass.
|
||||
*/
|
||||
export async function requireCompanyRoleAny(
|
||||
locals: App.Locals,
|
||||
companyId: string,
|
||||
anyOf: CompanyRole[]
|
||||
): Promise<{ user: NonNullable<App.Locals['user']>; roles: CompanyRole[] }> {
|
||||
const user = requireAuth(locals);
|
||||
|
||||
if (user.isSystemAdmin) {
|
||||
return { user, roles: ['admin'] };
|
||||
}
|
||||
|
||||
const roles = await getCompanyRoles(user.id, companyId);
|
||||
|
||||
if (!roles || roles.length === 0) {
|
||||
error(403, 'Not a member of this company');
|
||||
}
|
||||
|
||||
const has = roles.some((r) => anyOf.includes(r));
|
||||
if (!has) {
|
||||
error(403, `Requires one of: ${anyOf.join(', ')}`);
|
||||
}
|
||||
|
||||
return { user, roles };
|
||||
}
|
||||
|
||||
+445
-3
@@ -15,7 +15,7 @@ import {
|
||||
|
||||
// ── Enums ──────────────────────────────────────────────
|
||||
|
||||
export const companyRoleEnum = pgEnum('company_role', ['admin', 'manager', 'user', 'viewer']);
|
||||
export const companyRoleEnum = pgEnum('company_role', ['admin', 'manager', 'user', 'viewer', 'hr']);
|
||||
export const expenseStatusEnum = pgEnum('expense_status', ['pending', 'approved', 'rejected']);
|
||||
|
||||
// ── Users ──────────────────────────────────────────────
|
||||
@@ -77,7 +77,7 @@ export const companyMembers = pgTable(
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
role: companyRoleEnum('role').notNull(),
|
||||
roles: companyRoleEnum('roles').array().notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [uniqueIndex('company_members_user_company_idx').on(table.userId, table.companyId)]
|
||||
@@ -124,6 +124,7 @@ export const expenses = pgTable(
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: 'cascade' }),
|
||||
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
|
||||
partyId: uuid('party_id').references((): any => parties.id, { onDelete: 'set null' }),
|
||||
submittedBy: text('submitted_by')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
@@ -192,6 +193,422 @@ export const budgetAllocations = pgTable('budget_allocations', {
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
});
|
||||
|
||||
// ── Employees ──────────────────────────────────────────
|
||||
|
||||
export const employees = pgTable('employees', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
firstName: text('first_name').notNull(),
|
||||
lastName: text('last_name').notNull(),
|
||||
displayName: text('display_name'),
|
||||
email: text('email'),
|
||||
phone: text('phone'),
|
||||
employeeCode: text('employee_code'),
|
||||
position: text('position'),
|
||||
department: text('department'),
|
||||
hireDate: date('hire_date').notNull(),
|
||||
terminationDate: date('termination_date'),
|
||||
nationalId: text('national_id'),
|
||||
taxId: text('tax_id'),
|
||||
bankName: text('bank_name'),
|
||||
bankAccount: text('bank_account'),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
});
|
||||
|
||||
export const salaryHistory = pgTable('salary_history', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
employeeId: uuid('employee_id')
|
||||
.notNull()
|
||||
.references(() => employees.id, { onDelete: 'cascade' }),
|
||||
effectiveFrom: date('effective_from').notNull(),
|
||||
grossSalary: numeric('gross_salary', { precision: 15, scale: 2 }).notNull(),
|
||||
currency: text('currency').notNull().default('THB'),
|
||||
note: text('note'),
|
||||
setBy: text('set_by')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
});
|
||||
|
||||
// ── Public Holidays ────────────────────────────────────
|
||||
|
||||
export const publicHolidays = pgTable(
|
||||
'public_holidays',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
date: date('date').notNull(),
|
||||
name: text('name').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [uniqueIndex('public_holidays_company_date_idx').on(table.companyId, table.date)]
|
||||
);
|
||||
|
||||
// ── Leave ──────────────────────────────────────────────
|
||||
|
||||
export const leaveStatusEnum = pgEnum('leave_status', ['pending', 'approved', 'rejected']);
|
||||
|
||||
export const leaveTypes = pgTable(
|
||||
'leave_types',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
defaultDaysPerYear: numeric('default_days_per_year', { precision: 5, scale: 2 }),
|
||||
isPaid: boolean('is_paid').notNull().default(true),
|
||||
color: text('color'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [uniqueIndex('leave_types_company_name_idx').on(table.companyId, table.name)]
|
||||
);
|
||||
|
||||
export const leaveBalances = pgTable(
|
||||
'leave_balances',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
employeeId: uuid('employee_id')
|
||||
.notNull()
|
||||
.references(() => employees.id, { onDelete: 'cascade' }),
|
||||
leaveTypeId: uuid('leave_type_id')
|
||||
.notNull()
|
||||
.references(() => leaveTypes.id, { onDelete: 'cascade' }),
|
||||
year: numeric('year', { precision: 4, scale: 0 }).notNull(),
|
||||
allocated: numeric('allocated', { precision: 5, scale: 2 }).notNull().default('0'),
|
||||
used: numeric('used', { precision: 5, scale: 2 }).notNull().default('0'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('leave_balances_emp_type_year_idx').on(
|
||||
table.employeeId,
|
||||
table.leaveTypeId,
|
||||
table.year
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
export const leaveRequests = pgTable('leave_requests', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
employeeId: uuid('employee_id')
|
||||
.notNull()
|
||||
.references(() => employees.id, { onDelete: 'cascade' }),
|
||||
leaveTypeId: uuid('leave_type_id')
|
||||
.notNull()
|
||||
.references(() => leaveTypes.id, { onDelete: 'cascade' }),
|
||||
startDate: date('start_date').notNull(),
|
||||
endDate: date('end_date').notNull(),
|
||||
days: numeric('days', { precision: 5, scale: 2 }).notNull(),
|
||||
reason: text('reason'),
|
||||
status: leaveStatusEnum('status').notNull().default('pending'),
|
||||
reviewedBy: text('reviewed_by').references(() => users.id),
|
||||
reviewedAt: timestamp('reviewed_at', { withTimezone: true }),
|
||||
rejectionReason: text('rejection_reason'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
});
|
||||
|
||||
// ── Payslips ───────────────────────────────────────────
|
||||
|
||||
export const payslipStatusEnum = pgEnum('payslip_status', ['draft', 'finalized', 'paid']);
|
||||
export const payslipLineTypeEnum = pgEnum('payslip_line_type', ['earning', 'deduction']);
|
||||
|
||||
export const payslips = pgTable(
|
||||
'payslips',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
employeeId: uuid('employee_id')
|
||||
.notNull()
|
||||
.references(() => employees.id, { onDelete: 'cascade' }),
|
||||
periodYear: numeric('period_year', { precision: 4, scale: 0 }).notNull(),
|
||||
periodMonth: numeric('period_month', { precision: 2, scale: 0 }).notNull(),
|
||||
grossSalary: numeric('gross_salary', { precision: 15, scale: 2 }).notNull(),
|
||||
overtime: numeric('overtime', { precision: 15, scale: 2 }).notNull().default('0'),
|
||||
bonus: numeric('bonus', { precision: 15, scale: 2 }).notNull().default('0'),
|
||||
otherEarnings: numeric('other_earnings', { precision: 15, scale: 2 }).notNull().default('0'),
|
||||
ssoEmployee: numeric('sso_employee', { precision: 15, scale: 2 }).notNull().default('0'),
|
||||
ssoEmployer: numeric('sso_employer', { precision: 15, scale: 2 }).notNull().default('0'),
|
||||
incomeTax: numeric('income_tax', { precision: 15, scale: 2 }).notNull().default('0'),
|
||||
otherDeductions: numeric('other_deductions', { precision: 15, scale: 2 }).notNull().default('0'),
|
||||
netPay: numeric('net_pay', { precision: 15, scale: 2 }).notNull(),
|
||||
currency: text('currency').notNull().default('THB'),
|
||||
status: payslipStatusEnum('status').notNull().default('draft'),
|
||||
finalizedAt: timestamp('finalized_at', { withTimezone: true }),
|
||||
paidAt: timestamp('paid_at', { withTimezone: true }),
|
||||
generatedBy: text('generated_by')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
pdfPath: text('pdf_path'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('payslips_emp_period_idx').on(table.employeeId, table.periodYear, table.periodMonth)
|
||||
]
|
||||
);
|
||||
|
||||
export const payslipLineItems = pgTable('payslip_line_items', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
payslipId: uuid('payslip_id')
|
||||
.notNull()
|
||||
.references(() => payslips.id, { onDelete: 'cascade' }),
|
||||
type: payslipLineTypeEnum('type').notNull(),
|
||||
label: text('label').notNull(),
|
||||
amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
|
||||
isStatutory: boolean('is_statutory').notNull().default(false)
|
||||
});
|
||||
|
||||
// ── Parties (Customers / Suppliers) ────────────────────
|
||||
|
||||
export const partyTypeEnum = pgEnum('party_type', ['customer', 'supplier', 'both']);
|
||||
|
||||
export const parties = pgTable('parties', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
type: partyTypeEnum('type').notNull().default('customer'),
|
||||
name: text('name').notNull(),
|
||||
contactPerson: text('contact_person'),
|
||||
email: text('email'),
|
||||
phone: text('phone'),
|
||||
website: text('website'),
|
||||
taxId: text('tax_id'),
|
||||
addressLine1: text('address_line_1'),
|
||||
addressLine2: text('address_line_2'),
|
||||
city: text('city'),
|
||||
postalCode: text('postal_code'),
|
||||
country: text('country'),
|
||||
paymentTerms: text('payment_terms'),
|
||||
notes: text('notes'),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
});
|
||||
|
||||
// ── Invoices ───────────────────────────────────────────
|
||||
|
||||
export const invoiceDirectionEnum = pgEnum('invoice_direction', ['incoming', 'outgoing']);
|
||||
export const invoiceStatusEnum = pgEnum('invoice_status', [
|
||||
'draft',
|
||||
'sent',
|
||||
'paid',
|
||||
'overdue',
|
||||
'cancelled'
|
||||
]);
|
||||
|
||||
export const invoices = pgTable(
|
||||
'invoices',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
partyId: uuid('party_id')
|
||||
.notNull()
|
||||
.references(() => parties.id, { onDelete: 'restrict' }),
|
||||
direction: invoiceDirectionEnum('direction').notNull(),
|
||||
invoiceNumber: text('invoice_number').notNull(),
|
||||
issueDate: date('issue_date').notNull(),
|
||||
dueDate: date('due_date'),
|
||||
subtotal: numeric('subtotal', { precision: 15, scale: 2 }).notNull(),
|
||||
vat: numeric('vat', { precision: 15, scale: 2 }).notNull().default('0'),
|
||||
total: numeric('total', { precision: 15, scale: 2 }).notNull(),
|
||||
currency: text('currency').notNull().default('THB'),
|
||||
status: invoiceStatusEnum('status').notNull().default('draft'),
|
||||
expenseId: uuid('expense_id').references(() => expenses.id, { onDelete: 'set null' }),
|
||||
notes: text('notes'),
|
||||
pdfPath: text('pdf_path'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('invoices_company_direction_number_idx').on(
|
||||
table.companyId,
|
||||
table.direction,
|
||||
table.invoiceNumber
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
export const invoiceLineItems = pgTable('invoice_line_items', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
invoiceId: uuid('invoice_id')
|
||||
.notNull()
|
||||
.references(() => invoices.id, { onDelete: 'cascade' }),
|
||||
description: text('description').notNull(),
|
||||
quantity: numeric('quantity', { precision: 10, scale: 2 }).notNull().default('1'),
|
||||
unitPrice: numeric('unit_price', { precision: 15, scale: 2 }).notNull(),
|
||||
total: numeric('total', { precision: 15, scale: 2 }).notNull()
|
||||
});
|
||||
|
||||
// ── External Integrations ──────────────────────────────
|
||||
|
||||
export const integrationProviderEnum = pgEnum('integration_provider', [
|
||||
'kasikorn_kbiz',
|
||||
'etherfi',
|
||||
'manual'
|
||||
]);
|
||||
export const txDirectionEnum = pgEnum('tx_direction', ['credit', 'debit']);
|
||||
|
||||
export const externalAccounts = pgTable('external_accounts', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
provider: integrationProviderEnum('provider').notNull(),
|
||||
displayName: text('display_name').notNull(),
|
||||
accountIdentifier: text('account_identifier').notNull(),
|
||||
credentialsEncrypted: text('credentials_encrypted'),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
lastSyncedAt: timestamp('last_synced_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
});
|
||||
|
||||
export const externalTransactions = pgTable(
|
||||
'external_transactions',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
accountId: uuid('account_id')
|
||||
.notNull()
|
||||
.references(() => externalAccounts.id, { onDelete: 'cascade' }),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
externalId: text('external_id').notNull(),
|
||||
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
|
||||
amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
|
||||
currency: text('currency').notNull(),
|
||||
direction: txDirectionEnum('direction').notNull(),
|
||||
description: text('description'),
|
||||
counterparty: text('counterparty'),
|
||||
matchedExpenseId: uuid('matched_expense_id').references(() => expenses.id, {
|
||||
onDelete: 'set null'
|
||||
}),
|
||||
rawPayload: text('raw_payload'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [uniqueIndex('external_tx_account_extid_idx').on(table.accountId, table.externalId)]
|
||||
);
|
||||
|
||||
// ── Package Tracking ───────────────────────────────────
|
||||
|
||||
export const packageDirectionEnum = pgEnum('package_direction', ['incoming', 'outgoing']);
|
||||
export const packageStatusEnum = pgEnum('package_status', [
|
||||
'pending',
|
||||
'in_transit',
|
||||
'out_for_delivery',
|
||||
'delivered',
|
||||
'exception',
|
||||
'returned',
|
||||
'cancelled'
|
||||
]);
|
||||
export const carrierEnum = pgEnum('carrier', [
|
||||
'ups',
|
||||
'fedex',
|
||||
'dhl',
|
||||
'usps',
|
||||
'flash_express',
|
||||
'kerry_th',
|
||||
'jnt_express',
|
||||
'thailand_post',
|
||||
'other'
|
||||
]);
|
||||
|
||||
export const packages = pgTable(
|
||||
'packages',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
direction: packageDirectionEnum('direction').notNull(),
|
||||
carrier: carrierEnum('carrier').notNull(),
|
||||
trackingNumber: text('tracking_number').notNull(),
|
||||
status: packageStatusEnum('status').notNull().default('pending'),
|
||||
currentLocation: text('current_location'),
|
||||
description: text('description'),
|
||||
recipientName: text('recipient_name'),
|
||||
estimatedDelivery: date('estimated_delivery'),
|
||||
shippedAt: timestamp('shipped_at', { withTimezone: true }),
|
||||
deliveredAt: timestamp('delivered_at', { withTimezone: true }),
|
||||
weightKg: numeric('weight_kg', { precision: 10, scale: 3 }),
|
||||
shippingCost: numeric('shipping_cost', { precision: 15, scale: 2 }),
|
||||
currency: text('currency').notNull().default('THB'),
|
||||
invoiceId: uuid('invoice_id').references(() => invoices.id, { onDelete: 'set null' }),
|
||||
customsInvoiceId: uuid('customs_invoice_id').references(() => invoices.id, { onDelete: 'set null' }),
|
||||
expenseId: uuid('expense_id').references(() => expenses.id, { onDelete: 'set null' }),
|
||||
partyId: uuid('party_id').references(() => parties.id, { onDelete: 'set null' }),
|
||||
notes: text('notes'),
|
||||
lastRefreshedAt: timestamp('last_refreshed_at', { withTimezone: true }),
|
||||
createdBy: text('created_by')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('packages_company_carrier_tracking_idx').on(
|
||||
table.companyId,
|
||||
table.carrier,
|
||||
table.trackingNumber
|
||||
),
|
||||
index('packages_company_status_idx').on(table.companyId, table.status)
|
||||
]
|
||||
);
|
||||
|
||||
export const packageEvents = pgTable(
|
||||
'package_events',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
packageId: uuid('package_id')
|
||||
.notNull()
|
||||
.references(() => packages.id, { onDelete: 'cascade' }),
|
||||
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
|
||||
status: packageStatusEnum('status'),
|
||||
location: text('location'),
|
||||
description: text('description'),
|
||||
source: text('source').notNull().default('manual'),
|
||||
rawPayload: text('raw_payload'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [index('package_events_package_idx').on(table.packageId, table.occurredAt)]
|
||||
);
|
||||
|
||||
export const shippingAccounts = pgTable(
|
||||
'shipping_accounts',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
carrier: carrierEnum('carrier').notNull(),
|
||||
displayName: text('display_name'),
|
||||
credentialsEncrypted: text('credentials_encrypted'),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [uniqueIndex('shipping_accounts_company_carrier_idx').on(table.companyId, table.carrier)]
|
||||
);
|
||||
|
||||
// ── Company Log (Audit Trail) ──────────────────────────
|
||||
|
||||
export const companyLogEventEnum = pgEnum('company_log_event', [
|
||||
@@ -210,7 +627,32 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
|
||||
'expense_approved',
|
||||
'expense_rejected',
|
||||
'category_created',
|
||||
'import_completed'
|
||||
'import_completed',
|
||||
'employee_created',
|
||||
'employee_updated',
|
||||
'employee_terminated',
|
||||
'salary_changed',
|
||||
'holiday_added',
|
||||
'leave_type_created',
|
||||
'leave_submitted',
|
||||
'leave_approved',
|
||||
'leave_rejected',
|
||||
'payslip_generated',
|
||||
'payslip_finalized',
|
||||
'payslip_paid',
|
||||
'party_created',
|
||||
'invoice_created',
|
||||
'invoice_sent',
|
||||
'invoice_paid',
|
||||
'integration_connected',
|
||||
'integration_disconnected',
|
||||
'transaction_matched',
|
||||
'package_created',
|
||||
'package_updated',
|
||||
'package_delivered',
|
||||
'package_status_refreshed',
|
||||
'shipping_account_added',
|
||||
'shipping_account_removed'
|
||||
]);
|
||||
|
||||
export const companyLog = pgTable(
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Ether.fi integration — STUB.
|
||||
*
|
||||
* TODO: implement on-chain or API-based transaction tracking for an ether.fi
|
||||
* wallet address. Two possible approaches:
|
||||
* 1. Query ether.fi's public API for account activity (staking rewards, deposits, withdrawals).
|
||||
* Docs: https://docs.ether.fi/
|
||||
* 2. Query the Ethereum blockchain directly via a public RPC (e.g. Alchemy/Infura)
|
||||
* to track EETH and LIQUID transfers to/from the wallet address.
|
||||
*
|
||||
* Credentials shape (stored in externalAccounts.credentialsEncrypted as JSON):
|
||||
* { walletAddress, apiKey? }
|
||||
*/
|
||||
|
||||
import type { NormalizedTransaction } from './types.js';
|
||||
|
||||
export async function fetchTransactions(
|
||||
_credentials: { walletAddress: string; apiKey?: string },
|
||||
_from: Date,
|
||||
_to: Date
|
||||
): Promise<NormalizedTransaction[]> {
|
||||
throw new Error(
|
||||
'Ether.fi integration is not implemented yet. See src/lib/server/integrations/etherfi.ts for TODO.'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Kasikorn Bank K-Biz API integration — STUB.
|
||||
*
|
||||
* TODO: implement real API calls once K-Biz API credentials are obtained.
|
||||
*
|
||||
* K-Biz API docs (as of 2026): https://apiportal.kasikornbank.com/
|
||||
* Expected flow:
|
||||
* 1. OAuth2 client-credentials flow to obtain access token.
|
||||
* 2. GET /v1/accounts/{accountId}/transactions?from=...&to=...
|
||||
* 3. Parse response → normalize into `ExternalTransaction` shape.
|
||||
*
|
||||
* Credentials shape (stored in externalAccounts.credentialsEncrypted as JSON):
|
||||
* { clientId, clientSecret, accountId }
|
||||
*/
|
||||
|
||||
import type { NormalizedTransaction } from './types.js';
|
||||
|
||||
export async function fetchTransactions(
|
||||
_credentials: { clientId: string; clientSecret: string; accountId: string },
|
||||
_from: Date,
|
||||
_to: Date
|
||||
): Promise<NormalizedTransaction[]> {
|
||||
throw new Error(
|
||||
'Kasikorn K-Biz integration is not implemented yet. See src/lib/server/integrations/kasikorn.ts for TODO.'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface NormalizedTransaction {
|
||||
externalId: string;
|
||||
occurredAt: Date;
|
||||
amount: string; // stringified numeric
|
||||
currency: string;
|
||||
direction: 'credit' | 'debit';
|
||||
description: string | null;
|
||||
counterparty: string | null;
|
||||
rawPayload: string; // JSON.stringified raw provider response
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
|
||||
|
||||
interface PartyBlock {
|
||||
name: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
taxId?: string | null;
|
||||
addressLine1?: string | null;
|
||||
addressLine2?: string | null;
|
||||
city?: string | null;
|
||||
postalCode?: string | null;
|
||||
country?: string | null;
|
||||
}
|
||||
|
||||
interface InvoiceData {
|
||||
company: { name: string; currency: string };
|
||||
party: PartyBlock;
|
||||
invoice: {
|
||||
number: string;
|
||||
issueDate: string;
|
||||
dueDate: string | null;
|
||||
direction: 'incoming' | 'outgoing';
|
||||
subtotal: number;
|
||||
vat: number;
|
||||
total: number;
|
||||
notes: string | null;
|
||||
};
|
||||
lineItems: {
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
total: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
function money(n: number, currency: string): string {
|
||||
return `${currency} ${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
export async function generateInvoicePDF(data: InvoiceData): Promise<Uint8Array> {
|
||||
const pdf = await PDFDocument.create();
|
||||
const page = pdf.addPage([595, 842]); // A4 portrait
|
||||
const font = await pdf.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await pdf.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const { width, height } = page.getSize();
|
||||
const margin = 50;
|
||||
let y = height - margin;
|
||||
|
||||
const black = rgb(0, 0, 0);
|
||||
const grey = rgb(0.4, 0.4, 0.4);
|
||||
const lightGrey = rgb(0.8, 0.8, 0.8);
|
||||
|
||||
// ── Header ───────────────────────────────────────────
|
||||
page.drawText(data.company.name, {
|
||||
x: margin,
|
||||
y,
|
||||
size: 18,
|
||||
font: fontBold,
|
||||
color: black
|
||||
});
|
||||
|
||||
const docTitle =
|
||||
data.invoice.direction === 'outgoing' ? 'INVOICE' : 'BILL / RECEIPT';
|
||||
const titleWidth = fontBold.widthOfTextAtSize(docTitle, 14);
|
||||
page.drawText(docTitle, {
|
||||
x: width - margin - titleWidth,
|
||||
y,
|
||||
size: 14,
|
||||
font: fontBold,
|
||||
color: grey
|
||||
});
|
||||
|
||||
y -= 20;
|
||||
const invNumText = `#${data.invoice.number}`;
|
||||
const invNumWidth = fontBold.widthOfTextAtSize(invNumText, 11);
|
||||
page.drawText(invNumText, {
|
||||
x: width - margin - invNumWidth,
|
||||
y,
|
||||
size: 11,
|
||||
font: fontBold,
|
||||
color: black
|
||||
});
|
||||
|
||||
y -= 14;
|
||||
const issuedText = `Issued: ${data.invoice.issueDate}`;
|
||||
const issuedWidth = font.widthOfTextAtSize(issuedText, 9);
|
||||
page.drawText(issuedText, { x: width - margin - issuedWidth, y, size: 9, font, color: grey });
|
||||
|
||||
if (data.invoice.dueDate) {
|
||||
y -= 12;
|
||||
const dueText = `Due: ${data.invoice.dueDate}`;
|
||||
const dueWidth = font.widthOfTextAtSize(dueText, 9);
|
||||
page.drawText(dueText, { x: width - margin - dueWidth, y, size: 9, font, color: grey });
|
||||
}
|
||||
|
||||
// Reset y for left side after header
|
||||
y = height - margin - 70;
|
||||
|
||||
// ── From / Bill To columns ───────────────────────────
|
||||
const col1x = margin;
|
||||
const col2x = margin + 270;
|
||||
let yLeft = y;
|
||||
let yRight = y;
|
||||
|
||||
if (data.invoice.direction === 'outgoing') {
|
||||
// Left: From (company)
|
||||
page.drawText('FROM', { x: col1x, y: yLeft, size: 8, font: fontBold, color: grey });
|
||||
yLeft -= 13;
|
||||
page.drawText(data.company.name, { x: col1x, y: yLeft, size: 10, font: fontBold, color: black });
|
||||
yLeft -= 12;
|
||||
}
|
||||
|
||||
// Bill-to / Supplier column
|
||||
const billLabel = data.invoice.direction === 'outgoing' ? 'BILL TO' : 'FROM SUPPLIER';
|
||||
page.drawText(billLabel, { x: col2x, y: yRight, size: 8, font: fontBold, color: grey });
|
||||
yRight -= 13;
|
||||
page.drawText(data.party.name, { x: col2x, y: yRight, size: 10, font: fontBold, color: black });
|
||||
yRight -= 12;
|
||||
|
||||
if (data.party.email) {
|
||||
page.drawText(data.party.email, { x: col2x, y: yRight, size: 9, font, color: grey });
|
||||
yRight -= 12;
|
||||
}
|
||||
if (data.party.phone) {
|
||||
page.drawText(data.party.phone, { x: col2x, y: yRight, size: 9, font, color: grey });
|
||||
yRight -= 12;
|
||||
}
|
||||
if (data.party.taxId) {
|
||||
page.drawText(`Tax ID: ${data.party.taxId}`, { x: col2x, y: yRight, size: 9, font, color: grey });
|
||||
yRight -= 12;
|
||||
}
|
||||
if (data.party.addressLine1) {
|
||||
const addrLine = data.party.addressLine2
|
||||
? `${data.party.addressLine1}, ${data.party.addressLine2}`
|
||||
: data.party.addressLine1;
|
||||
page.drawText(addrLine, { x: col2x, y: yRight, size: 9, font, color: grey });
|
||||
yRight -= 12;
|
||||
}
|
||||
if (data.party.city || data.party.postalCode) {
|
||||
page.drawText(`${data.party.city ?? ''} ${data.party.postalCode ?? ''}`.trim(), {
|
||||
x: col2x,
|
||||
y: yRight,
|
||||
size: 9,
|
||||
font,
|
||||
color: grey
|
||||
});
|
||||
yRight -= 12;
|
||||
}
|
||||
if (data.party.country) {
|
||||
page.drawText(data.party.country, { x: col2x, y: yRight, size: 9, font, color: grey });
|
||||
yRight -= 12;
|
||||
}
|
||||
|
||||
y = Math.min(yLeft, yRight) - 20;
|
||||
|
||||
// ── Divider ──────────────────────────────────────────
|
||||
page.drawLine({
|
||||
start: { x: margin, y },
|
||||
end: { x: width - margin, y },
|
||||
thickness: 1,
|
||||
color: lightGrey
|
||||
});
|
||||
y -= 18;
|
||||
|
||||
// ── Line items table header ──────────────────────────
|
||||
const col = {
|
||||
desc: margin,
|
||||
qty: margin + 260,
|
||||
unit: margin + 320,
|
||||
total: width - margin
|
||||
};
|
||||
|
||||
page.drawText('Description', { x: col.desc, y, size: 9, font: fontBold, color: grey });
|
||||
page.drawText('Qty', { x: col.qty, y, size: 9, font: fontBold, color: grey });
|
||||
page.drawText('Unit Price', { x: col.unit, y, size: 9, font: fontBold, color: grey });
|
||||
const totalHdr = 'Total';
|
||||
const totalHdrW = fontBold.widthOfTextAtSize(totalHdr, 9);
|
||||
page.drawText(totalHdr, { x: col.total - totalHdrW, y, size: 9, font: fontBold, color: grey });
|
||||
y -= 6;
|
||||
|
||||
page.drawLine({
|
||||
start: { x: margin, y },
|
||||
end: { x: width - margin, y },
|
||||
thickness: 0.5,
|
||||
color: lightGrey
|
||||
});
|
||||
y -= 12;
|
||||
|
||||
// ── Line items ───────────────────────────────────────
|
||||
for (const li of data.lineItems) {
|
||||
page.drawText(li.description.slice(0, 60), { x: col.desc, y, size: 9, font, color: black });
|
||||
page.drawText(li.quantity.toLocaleString('en-US'), { x: col.qty, y, size: 9, font, color: black });
|
||||
const upText = money(li.unitPrice, data.invoice.number ? '' : data.company.currency).replace(/^[A-Z]+ /, '');
|
||||
page.drawText(upText, { x: col.unit, y, size: 9, font, color: black });
|
||||
const liTotal = money(li.total, '').trim();
|
||||
const liTotalW = font.widthOfTextAtSize(liTotal, 9);
|
||||
page.drawText(liTotal, { x: col.total - liTotalW, y, size: 9, font, color: black });
|
||||
y -= 14;
|
||||
}
|
||||
|
||||
y -= 4;
|
||||
page.drawLine({
|
||||
start: { x: margin, y },
|
||||
end: { x: width - margin, y },
|
||||
thickness: 0.5,
|
||||
color: lightGrey
|
||||
});
|
||||
y -= 16;
|
||||
|
||||
// ── Totals box ───────────────────────────────────────
|
||||
const boxX = width - margin - 200;
|
||||
const boxW = 200;
|
||||
let boxY = y;
|
||||
|
||||
const boxRows: [string, string][] = [
|
||||
['Subtotal', money(data.invoice.subtotal, data.company.currency)]
|
||||
];
|
||||
if (data.invoice.vat > 0) {
|
||||
boxRows.push(['VAT 7%', money(data.invoice.vat, data.company.currency)]);
|
||||
}
|
||||
const boxHeight = 16 * boxRows.length + 36;
|
||||
|
||||
page.drawRectangle({
|
||||
x: boxX,
|
||||
y: boxY - boxHeight,
|
||||
width: boxW,
|
||||
height: boxHeight,
|
||||
borderColor: lightGrey,
|
||||
borderWidth: 0.5,
|
||||
color: rgb(0.98, 0.98, 0.98)
|
||||
});
|
||||
|
||||
for (const [label, value] of boxRows) {
|
||||
page.drawText(label, { x: boxX + 10, y: boxY - 12, size: 9, font, color: grey });
|
||||
const valW = font.widthOfTextAtSize(value, 9);
|
||||
page.drawText(value, { x: boxX + boxW - 10 - valW, y: boxY - 12, size: 9, font, color: black });
|
||||
boxY -= 16;
|
||||
}
|
||||
|
||||
boxY -= 4;
|
||||
page.drawLine({
|
||||
start: { x: boxX + 8, y: boxY },
|
||||
end: { x: boxX + boxW - 8, y: boxY },
|
||||
thickness: 0.5,
|
||||
color: lightGrey
|
||||
});
|
||||
boxY -= 4;
|
||||
|
||||
const totalLabel = 'TOTAL';
|
||||
const totalValue = money(data.invoice.total, data.company.currency);
|
||||
page.drawText(totalLabel, { x: boxX + 10, y: boxY - 12, size: 11, font: fontBold, color: black });
|
||||
const totalW = fontBold.widthOfTextAtSize(totalValue, 12);
|
||||
page.drawText(totalValue, {
|
||||
x: boxX + boxW - 10 - totalW,
|
||||
y: boxY - 14,
|
||||
size: 12,
|
||||
font: fontBold,
|
||||
color: black
|
||||
});
|
||||
|
||||
y = y - boxHeight - 20;
|
||||
|
||||
// ── Notes ────────────────────────────────────────────
|
||||
if (data.invoice.notes) {
|
||||
page.drawText('Notes:', { x: margin, y, size: 9, font: fontBold, color: grey });
|
||||
y -= 12;
|
||||
// Simple word-wrap approximation (split by sentence)
|
||||
const words = data.invoice.notes.split(' ');
|
||||
let line = '';
|
||||
for (const word of words) {
|
||||
const test = line ? `${line} ${word}` : word;
|
||||
if (font.widthOfTextAtSize(test, 9) > width - 2 * margin) {
|
||||
page.drawText(line, { x: margin, y, size: 9, font, color: grey });
|
||||
y -= 11;
|
||||
line = word;
|
||||
} else {
|
||||
line = test;
|
||||
}
|
||||
}
|
||||
if (line) {
|
||||
page.drawText(line, { x: margin, y, size: 9, font, color: grey });
|
||||
y -= 11;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Footer ───────────────────────────────────────────
|
||||
page.drawText('This document is computer-generated.', {
|
||||
x: margin,
|
||||
y: margin - 10,
|
||||
size: 8,
|
||||
font,
|
||||
color: lightGrey
|
||||
});
|
||||
|
||||
return pdf.save();
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
|
||||
|
||||
interface PayslipData {
|
||||
company: { name: string; currency: string };
|
||||
employee: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
employeeCode: string | null;
|
||||
position: string | null;
|
||||
department: string | null;
|
||||
nationalId: string | null;
|
||||
bankName: string | null;
|
||||
bankAccount: string | null;
|
||||
};
|
||||
period: { year: number; month: number };
|
||||
earnings: { label: string; amount: number }[];
|
||||
deductions: { label: string; amount: number }[];
|
||||
grossTotal: number;
|
||||
deductionTotal: number;
|
||||
netPay: number;
|
||||
generatedAt: Date;
|
||||
}
|
||||
|
||||
const MONTHS = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December'
|
||||
];
|
||||
|
||||
function formatMoney(n: number, currency: string): string {
|
||||
return `${currency} ${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
export async function generatePayslipPDF(data: PayslipData): Promise<Uint8Array> {
|
||||
const pdf = await PDFDocument.create();
|
||||
const page = pdf.addPage([595, 842]); // A4 portrait
|
||||
const font = await pdf.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await pdf.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const { width, height } = page.getSize();
|
||||
const margin = 50;
|
||||
let y = height - margin;
|
||||
|
||||
const black = rgb(0, 0, 0);
|
||||
const grey = rgb(0.4, 0.4, 0.4);
|
||||
const lineColor = rgb(0.8, 0.8, 0.8);
|
||||
|
||||
// Header
|
||||
page.drawText(data.company.name, { x: margin, y, size: 18, font: fontBold, color: black });
|
||||
y -= 24;
|
||||
page.drawText('PAYSLIP', { x: margin, y, size: 12, font: fontBold, color: grey });
|
||||
y -= 18;
|
||||
page.drawText(`${MONTHS[data.period.month - 1]} ${data.period.year}`, {
|
||||
x: margin,
|
||||
y,
|
||||
size: 11,
|
||||
font,
|
||||
color: grey
|
||||
});
|
||||
|
||||
// Right side: generated date
|
||||
const genText = `Generated: ${data.generatedAt.toISOString().split('T')[0]}`;
|
||||
const genWidth = font.widthOfTextAtSize(genText, 9);
|
||||
page.drawText(genText, { x: width - margin - genWidth, y, size: 9, font, color: grey });
|
||||
|
||||
y -= 24;
|
||||
page.drawLine({
|
||||
start: { x: margin, y },
|
||||
end: { x: width - margin, y },
|
||||
thickness: 1,
|
||||
color: lineColor
|
||||
});
|
||||
y -= 20;
|
||||
|
||||
// Employee details (two columns)
|
||||
const rows: [string, string][] = [
|
||||
['Name', `${data.employee.firstName} ${data.employee.lastName}`],
|
||||
['Employee Code', data.employee.employeeCode || '—'],
|
||||
['Position', data.employee.position || '—'],
|
||||
['Department', data.employee.department || '—'],
|
||||
['National ID', data.employee.nationalId || '—'],
|
||||
['Bank', data.employee.bankName ? `${data.employee.bankName} — ${data.employee.bankAccount ?? ''}` : '—']
|
||||
];
|
||||
|
||||
for (const [label, value] of rows) {
|
||||
page.drawText(label, { x: margin, y, size: 9, font, color: grey });
|
||||
page.drawText(value, { x: margin + 120, y, size: 10, font, color: black });
|
||||
y -= 15;
|
||||
}
|
||||
|
||||
y -= 20;
|
||||
page.drawLine({
|
||||
start: { x: margin, y },
|
||||
end: { x: width - margin, y },
|
||||
thickness: 1,
|
||||
color: lineColor
|
||||
});
|
||||
y -= 20;
|
||||
|
||||
// Earnings section
|
||||
page.drawText('EARNINGS', { x: margin, y, size: 11, font: fontBold, color: black });
|
||||
y -= 18;
|
||||
for (const e of data.earnings) {
|
||||
page.drawText(e.label, { x: margin + 10, y, size: 10, font, color: black });
|
||||
const txt = formatMoney(e.amount, data.company.currency);
|
||||
const txtW = font.widthOfTextAtSize(txt, 10);
|
||||
page.drawText(txt, { x: width - margin - txtW, y, size: 10, font, color: black });
|
||||
y -= 15;
|
||||
}
|
||||
y -= 5;
|
||||
page.drawLine({
|
||||
start: { x: margin, y },
|
||||
end: { x: width - margin, y },
|
||||
thickness: 0.5,
|
||||
color: lineColor
|
||||
});
|
||||
y -= 15;
|
||||
page.drawText('Total Earnings', { x: margin + 10, y, size: 10, font: fontBold, color: black });
|
||||
{
|
||||
const txt = formatMoney(data.grossTotal, data.company.currency);
|
||||
const txtW = fontBold.widthOfTextAtSize(txt, 10);
|
||||
page.drawText(txt, { x: width - margin - txtW, y, size: 10, font: fontBold, color: black });
|
||||
}
|
||||
|
||||
y -= 30;
|
||||
|
||||
// Deductions section
|
||||
page.drawText('DEDUCTIONS', { x: margin, y, size: 11, font: fontBold, color: black });
|
||||
y -= 18;
|
||||
for (const d of data.deductions) {
|
||||
page.drawText(d.label, { x: margin + 10, y, size: 10, font, color: black });
|
||||
const txt = formatMoney(d.amount, data.company.currency);
|
||||
const txtW = font.widthOfTextAtSize(txt, 10);
|
||||
page.drawText(txt, { x: width - margin - txtW, y, size: 10, font, color: black });
|
||||
y -= 15;
|
||||
}
|
||||
y -= 5;
|
||||
page.drawLine({
|
||||
start: { x: margin, y },
|
||||
end: { x: width - margin, y },
|
||||
thickness: 0.5,
|
||||
color: lineColor
|
||||
});
|
||||
y -= 15;
|
||||
page.drawText('Total Deductions', { x: margin + 10, y, size: 10, font: fontBold, color: black });
|
||||
{
|
||||
const txt = formatMoney(data.deductionTotal, data.company.currency);
|
||||
const txtW = fontBold.widthOfTextAtSize(txt, 10);
|
||||
page.drawText(txt, { x: width - margin - txtW, y, size: 10, font: fontBold, color: black });
|
||||
}
|
||||
|
||||
y -= 40;
|
||||
|
||||
// Net pay box
|
||||
page.drawRectangle({
|
||||
x: margin,
|
||||
y: y - 10,
|
||||
width: width - 2 * margin,
|
||||
height: 35,
|
||||
borderColor: black,
|
||||
borderWidth: 1.5,
|
||||
color: rgb(0.95, 0.95, 0.95)
|
||||
});
|
||||
page.drawText('NET PAY', { x: margin + 15, y: y + 5, size: 12, font: fontBold, color: black });
|
||||
{
|
||||
const txt = formatMoney(data.netPay, data.company.currency);
|
||||
const txtW = fontBold.widthOfTextAtSize(txt, 14);
|
||||
page.drawText(txt, {
|
||||
x: width - margin - 15 - txtW,
|
||||
y: y + 3,
|
||||
size: 14,
|
||||
font: fontBold,
|
||||
color: black
|
||||
});
|
||||
}
|
||||
|
||||
y -= 80;
|
||||
|
||||
// Signature lines
|
||||
page.drawLine({
|
||||
start: { x: margin, y },
|
||||
end: { x: margin + 180, y },
|
||||
thickness: 0.5,
|
||||
color: black
|
||||
});
|
||||
page.drawLine({
|
||||
start: { x: width - margin - 180, y },
|
||||
end: { x: width - margin, y },
|
||||
thickness: 0.5,
|
||||
color: black
|
||||
});
|
||||
y -= 12;
|
||||
page.drawText('Employer Signature', { x: margin, y, size: 9, font, color: grey });
|
||||
page.drawText('Employee Signature', { x: width - margin - 180, y, size: 9, font, color: grey });
|
||||
|
||||
// Footer
|
||||
page.drawText('This payslip is computer-generated.', {
|
||||
x: margin,
|
||||
y: margin - 10,
|
||||
size: 8,
|
||||
font,
|
||||
color: grey
|
||||
});
|
||||
|
||||
return pdf.save();
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Thailand payroll calculations.
|
||||
* Sources:
|
||||
* - Social Security (SSO): 5% of monthly salary, capped at THB 750 per side.
|
||||
* - Income tax (PND1): 2024 progressive brackets after standard deductions.
|
||||
* - Expense deduction: 50% of salary, capped at 100,000 THB/year.
|
||||
* - Personal allowance: 60,000 THB/year.
|
||||
* - Monthly withholding = annual tax / 12 (assumes no bonus or other incomes).
|
||||
*/
|
||||
|
||||
const SSO_RATE = 0.05;
|
||||
const SSO_SALARY_FLOOR = 1650; // SSO minimum salary
|
||||
const SSO_SALARY_CAP = 15000; // SSO maximum salary for 5% calc
|
||||
|
||||
export function calculateSSO(monthlyGross: number): number {
|
||||
if (monthlyGross <= 0) return 0;
|
||||
const base = Math.min(Math.max(monthlyGross, SSO_SALARY_FLOOR), SSO_SALARY_CAP);
|
||||
return Math.round(base * SSO_RATE * 100) / 100;
|
||||
}
|
||||
|
||||
interface Bracket {
|
||||
min: number;
|
||||
max: number;
|
||||
rate: number;
|
||||
}
|
||||
|
||||
/** 2024 PND1 tax brackets (THB annual taxable income) */
|
||||
const TAX_BRACKETS: Bracket[] = [
|
||||
{ min: 0, max: 150_000, rate: 0 },
|
||||
{ min: 150_000, max: 300_000, rate: 0.05 },
|
||||
{ min: 300_000, max: 500_000, rate: 0.1 },
|
||||
{ min: 500_000, max: 750_000, rate: 0.15 },
|
||||
{ min: 750_000, max: 1_000_000, rate: 0.2 },
|
||||
{ min: 1_000_000, max: 2_000_000, rate: 0.25 },
|
||||
{ min: 2_000_000, max: 5_000_000, rate: 0.3 },
|
||||
{ min: 5_000_000, max: Infinity, rate: 0.35 }
|
||||
];
|
||||
|
||||
export function calculateIncomeTax(annualTaxableIncome: number): number {
|
||||
if (annualTaxableIncome <= 0) return 0;
|
||||
|
||||
let tax = 0;
|
||||
for (const b of TAX_BRACKETS) {
|
||||
if (annualTaxableIncome <= b.min) break;
|
||||
const portion = Math.min(annualTaxableIncome, b.max) - b.min;
|
||||
tax += portion * b.rate;
|
||||
}
|
||||
return Math.round(tax * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate monthly withholding tax from monthly gross salary.
|
||||
* Annualizes gross, applies expense + personal deductions, looks up tax, divides by 12.
|
||||
*/
|
||||
export function calculateMonthlyWHT(monthlyGross: number): number {
|
||||
if (monthlyGross <= 0) return 0;
|
||||
const annualGross = monthlyGross * 12;
|
||||
const expenseDeduction = Math.min(annualGross * 0.5, 100_000);
|
||||
const personalAllowance = 60_000;
|
||||
const ssoAnnual = calculateSSO(monthlyGross) * 12;
|
||||
|
||||
const taxableIncome = Math.max(0, annualGross - expenseDeduction - personalAllowance - ssoAnnual);
|
||||
const annualTax = calculateIncomeTax(taxableIncome);
|
||||
return Math.round((annualTax / 12) * 100) / 100;
|
||||
}
|
||||
|
||||
export interface PayrollCalculation {
|
||||
grossEarnings: number;
|
||||
ssoEmployee: number;
|
||||
ssoEmployer: number;
|
||||
incomeTax: number;
|
||||
netPay: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a complete payroll line.
|
||||
* @param baseSalary monthly gross
|
||||
* @param extras additional earnings (overtime + bonus + other)
|
||||
* @param extraDeductions non-statutory deductions
|
||||
*/
|
||||
export function calculatePayroll(
|
||||
baseSalary: number,
|
||||
extras: number,
|
||||
extraDeductions: number
|
||||
): PayrollCalculation {
|
||||
const grossEarnings = baseSalary + extras;
|
||||
const ssoEmployee = calculateSSO(baseSalary);
|
||||
const ssoEmployer = ssoEmployee;
|
||||
const incomeTax = calculateMonthlyWHT(grossEarnings);
|
||||
const netPay =
|
||||
Math.round((grossEarnings - ssoEmployee - incomeTax - extraDeductions) * 100) / 100;
|
||||
return { grossEarnings, ssoEmployee, ssoEmployer, incomeTax, netPay };
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Thai public holiday source.
|
||||
* Primary: ppraserts/thailand-open-data on GitHub (includes lunar holidays).
|
||||
* Fallback: hardcoded list of fixed-date holidays (used if the repo is unreachable).
|
||||
*/
|
||||
|
||||
export interface HolidayEntry {
|
||||
date: string; // YYYY-MM-DD
|
||||
name: string;
|
||||
}
|
||||
|
||||
const REPO_URL = (year: number) =>
|
||||
`https://raw.githubusercontent.com/ppraserts/thailand-open-data/main/data/thai-public-holidays/${year}.json`;
|
||||
|
||||
interface RepoEntry {
|
||||
date: string;
|
||||
name_th: string;
|
||||
name_en: string;
|
||||
type: string;
|
||||
is_substitution: boolean;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
interface RepoResponse {
|
||||
source?: string;
|
||||
year?: number;
|
||||
year_buddhist_era?: number;
|
||||
total_holidays?: number;
|
||||
holidays: RepoEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch holidays for a given Gregorian year from the open-data repo.
|
||||
* Throws on network/parse failure — caller should fall back to {@link thaiHolidaysFallback}.
|
||||
*/
|
||||
export async function fetchThaiHolidays(year: number): Promise<HolidayEntry[]> {
|
||||
const res = await fetch(REPO_URL(year), {
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Thai holidays fetch failed: ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as RepoResponse;
|
||||
if (!data || !Array.isArray(data.holidays)) {
|
||||
throw new Error('Thai holidays response missing "holidays" array');
|
||||
}
|
||||
// Use English names; include all types (public + bank + substitution + special).
|
||||
return data.holidays
|
||||
.filter((e) => typeof e.date === 'string' && typeof e.name_en === 'string')
|
||||
.map((e) => ({
|
||||
date: e.date,
|
||||
name: e.is_substitution ? `${e.name_en} (Substitution)` : e.name_en
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Try the repo first; on failure, fall back to fixed-date seed.
|
||||
*/
|
||||
export async function getThaiHolidays(year: number): Promise<HolidayEntry[]> {
|
||||
try {
|
||||
return await fetchThaiHolidays(year);
|
||||
} catch (err) {
|
||||
console.warn(`[thai-holidays] falling back to static list for ${year}:`, err);
|
||||
return thaiHolidaysFallback(year);
|
||||
}
|
||||
}
|
||||
|
||||
/** Static fallback — fixed-date holidays only (no lunar). */
|
||||
export function thaiHolidaysFallback(year: number): HolidayEntry[] {
|
||||
return [
|
||||
{ date: `${year}-01-01`, name: "New Year's Day" },
|
||||
{ date: `${year}-04-06`, name: 'Chakri Memorial Day' },
|
||||
{ date: `${year}-04-13`, name: 'Songkran Festival' },
|
||||
{ date: `${year}-04-14`, name: 'Songkran Festival' },
|
||||
{ date: `${year}-04-15`, name: 'Songkran Festival' },
|
||||
{ date: `${year}-05-01`, name: 'Labour Day' },
|
||||
{ date: `${year}-05-04`, name: 'Coronation Day' },
|
||||
{ date: `${year}-06-03`, name: "Queen Suthida's Birthday" },
|
||||
{ date: `${year}-07-28`, name: "HM King's Birthday" },
|
||||
{ date: `${year}-08-12`, name: "HM Queen Mother's Birthday" },
|
||||
{ date: `${year}-10-13`, name: 'Anniversary of the Passing of King Bhumibol' },
|
||||
{ date: `${year}-10-23`, name: 'Chulalongkorn Memorial Day' },
|
||||
{ date: `${year}-12-05`, name: "King Bhumibol's Birthday / Father's Day" },
|
||||
{ date: `${year}-12-10`, name: 'Constitution Day' },
|
||||
{ date: `${year}-12-31`, name: "New Year's Eve" }
|
||||
];
|
||||
}
|
||||
|
||||
/** Backwards-compat wrapper used by `companies/+page.server.ts` — synchronous fallback. */
|
||||
export function thaiHolidaysForYear(year: number): HolidayEntry[] {
|
||||
return thaiHolidaysFallback(year);
|
||||
}
|
||||
|
||||
export const DEFAULT_LEAVE_TYPES = [
|
||||
{ name: 'Annual Leave', defaultDaysPerYear: '6', isPaid: true, color: '#3B82F6' },
|
||||
{ name: 'Sick Leave', defaultDaysPerYear: '30', isPaid: true, color: '#EF4444' },
|
||||
{ name: 'Personal Leave', defaultDaysPerYear: '3', isPaid: true, color: '#8B5CF6' },
|
||||
{ name: 'Unpaid Leave', defaultDaysPerYear: null, isPaid: false, color: '#6B7280' }
|
||||
];
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NormalizedShipmentStatus } from '../types.js';
|
||||
|
||||
/**
|
||||
* DHL Shipment Tracking — Unified API — STUB.
|
||||
*
|
||||
* Docs: https://developer.dhl.com/api-reference/shipment-tracking
|
||||
* Auth: API Key in header (DHL-API-Key)
|
||||
* Track: GET https://api-eu.dhl.com/track/shipments?trackingNumber={tn}
|
||||
* Free: Basic tier is free after signup.
|
||||
*
|
||||
* Credentials shape:
|
||||
* { apiKey: string }
|
||||
*/
|
||||
export async function fetchStatus(
|
||||
_trackingNumber: string,
|
||||
_credentials: { apiKey: string }
|
||||
): Promise<NormalizedShipmentStatus> {
|
||||
throw new Error(
|
||||
'DHL tracking is not implemented yet. See src/lib/server/shipping/carriers/dhl.ts for TODO.'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { NormalizedShipmentStatus } from '../types.js';
|
||||
|
||||
/**
|
||||
* FedEx Track API — STUB.
|
||||
*
|
||||
* Docs: https://developer.fedex.com/api/en-us/catalog/track.html
|
||||
* Auth: OAuth 2.0 (client credentials)
|
||||
* POST https://apis.fedex.com/oauth/token (grant_type=client_credentials)
|
||||
* Track: POST https://apis.fedex.com/track/v1/trackingnumbers
|
||||
* Body: { trackingInfo: [{ trackingNumberInfo: { trackingNumber } }] }
|
||||
* Free: Developer account free; production requires approval.
|
||||
*
|
||||
* Credentials shape:
|
||||
* { apiKey: string, apiSecret: string, accountNumber?: string }
|
||||
*/
|
||||
export async function fetchStatus(
|
||||
_trackingNumber: string,
|
||||
_credentials: { apiKey: string; apiSecret: string; accountNumber?: string }
|
||||
): Promise<NormalizedShipmentStatus> {
|
||||
throw new Error(
|
||||
'FedEx tracking is not implemented yet. See src/lib/server/shipping/carriers/fedex.ts for TODO.'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { NormalizedShipmentStatus } from '../types.js';
|
||||
|
||||
/**
|
||||
* Flash Express (Thailand) — STUB.
|
||||
*
|
||||
* Docs: https://open-api.flashexpress.com/ (merchant portal login required)
|
||||
* Auth: API key + signature (HMAC-SHA256 of nonce + body + secret)
|
||||
* Track: POST https://open-api.flashexpress.com/open/v1/orders/{pno}/routes
|
||||
* or GET /open/v3/orders/{pno}
|
||||
* Free: Merchant account required (free to register, usage is free).
|
||||
*
|
||||
* Credentials shape:
|
||||
* { merchantId: string, apiKey: string, secret: string }
|
||||
*/
|
||||
export async function fetchStatus(
|
||||
_trackingNumber: string,
|
||||
_credentials: { merchantId: string; apiKey: string; secret: string }
|
||||
): Promise<NormalizedShipmentStatus> {
|
||||
throw new Error(
|
||||
'Flash Express tracking is not implemented yet. See src/lib/server/shipping/carriers/flash_express.ts for TODO.'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { NormalizedShipmentStatus } from '../types.js';
|
||||
|
||||
/**
|
||||
* J&T Express — STUB.
|
||||
*
|
||||
* Docs: https://open.jtexpress.co.th/ (Thailand merchant portal)
|
||||
* Auth: PSID (partner ID) + signature (HMAC) + timestamp
|
||||
* Track: POST https://openapi.jtexpress.my/webopenplatformapi/api/logistics/trace
|
||||
* (endpoint host varies by region — .my, .th, etc.)
|
||||
* Free: Merchant account required.
|
||||
*
|
||||
* Credentials shape:
|
||||
* { apiAccount: string, privateKey: string, customerCode?: string }
|
||||
*/
|
||||
export async function fetchStatus(
|
||||
_trackingNumber: string,
|
||||
_credentials: { apiAccount: string; privateKey: string; customerCode?: string }
|
||||
): Promise<NormalizedShipmentStatus> {
|
||||
throw new Error(
|
||||
'J&T Express tracking is not implemented yet. See src/lib/server/shipping/carriers/jnt_express.ts for TODO.'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NormalizedShipmentStatus } from '../types.js';
|
||||
|
||||
/**
|
||||
* Kerry Express Thailand — STUB.
|
||||
*
|
||||
* Docs: Limited public API. Merchant portal required for integration.
|
||||
* Contact support or check https://th.kerryexpress.com/ for API access.
|
||||
* Auth: Typically API key via merchant account.
|
||||
* Track: GET/POST to their tracking endpoint — exact path varies by contract.
|
||||
*
|
||||
* Credentials shape:
|
||||
* { apiKey: string, merchantCode?: string }
|
||||
*/
|
||||
export async function fetchStatus(
|
||||
_trackingNumber: string,
|
||||
_credentials: { apiKey: string; merchantCode?: string }
|
||||
): Promise<NormalizedShipmentStatus> {
|
||||
throw new Error(
|
||||
'Kerry Express (TH) tracking is not implemented yet. See src/lib/server/shipping/carriers/kerry_th.ts for TODO.'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { NormalizedShipmentStatus } from '../types.js';
|
||||
|
||||
/**
|
||||
* Thailand Post — STUB.
|
||||
*
|
||||
* Docs: No official public API for tracking as of 2026.
|
||||
* Options:
|
||||
* - Scrape track.thailandpost.co.th (fragile, not recommended).
|
||||
* - Use an aggregator like 17track or AfterShip as a fallback.
|
||||
* For now, Thailand Post packages must be updated manually.
|
||||
*
|
||||
* Credentials shape:
|
||||
* {} (none — stub will always throw)
|
||||
*/
|
||||
export async function fetchStatus(
|
||||
_trackingNumber: string,
|
||||
_credentials: Record<string, never>
|
||||
): Promise<NormalizedShipmentStatus> {
|
||||
throw new Error(
|
||||
'Thailand Post has no official tracking API. Please update this package status manually.'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { NormalizedShipmentStatus } from '../types.js';
|
||||
|
||||
/**
|
||||
* UPS Tracking API — STUB.
|
||||
*
|
||||
* Docs: https://developer.ups.com/
|
||||
* Auth: OAuth 2.0 (client credentials grant)
|
||||
* Exchange clientId + clientSecret at https://onlinetools.ups.com/security/v1/oauth/token
|
||||
* Track: GET https://onlinetools.ups.com/api/track/v1/details/{trackingNumber}
|
||||
* Headers: Authorization: Bearer <token>, transId, transactionSrc
|
||||
* Free: Developer account free; tracking API included at no cost.
|
||||
*
|
||||
* Credentials shape:
|
||||
* { clientId: string, clientSecret: string, accountNumber?: string }
|
||||
*/
|
||||
export async function fetchStatus(
|
||||
_trackingNumber: string,
|
||||
_credentials: { clientId: string; clientSecret: string; accountNumber?: string }
|
||||
): Promise<NormalizedShipmentStatus> {
|
||||
throw new Error(
|
||||
'UPS tracking is not implemented yet. See src/lib/server/shipping/carriers/ups.ts for TODO.'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NormalizedShipmentStatus } from '../types.js';
|
||||
|
||||
/**
|
||||
* USPS Tracking API — STUB.
|
||||
*
|
||||
* Docs: https://developer.usps.com/
|
||||
* Auth: OAuth 2.0 (as of the new USPS APIs) — legacy Web Tools used userId only.
|
||||
* Track: GET https://api.usps.com/tracking/v3/tracking/{trackingNumber}
|
||||
* Free: Developer account free.
|
||||
*
|
||||
* Credentials shape:
|
||||
* { clientId: string, clientSecret: string }
|
||||
*/
|
||||
export async function fetchStatus(
|
||||
_trackingNumber: string,
|
||||
_credentials: { clientId: string; clientSecret: string }
|
||||
): Promise<NormalizedShipmentStatus> {
|
||||
throw new Error(
|
||||
'USPS tracking is not implemented yet. See src/lib/server/shipping/carriers/usps.ts for TODO.'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Carrier, NormalizedShipmentStatus } from './types.js';
|
||||
import * as ups from './carriers/ups.js';
|
||||
import * as fedex from './carriers/fedex.js';
|
||||
import * as dhl from './carriers/dhl.js';
|
||||
import * as usps from './carriers/usps.js';
|
||||
import * as flashExpress from './carriers/flash_express.js';
|
||||
import * as kerryTh from './carriers/kerry_th.js';
|
||||
import * as jntExpress from './carriers/jnt_express.js';
|
||||
import * as thailandPost from './carriers/thailand_post.js';
|
||||
|
||||
export const CARRIER_LABELS: Record<Carrier, string> = {
|
||||
ups: 'UPS',
|
||||
fedex: 'FedEx',
|
||||
dhl: 'DHL',
|
||||
usps: 'USPS',
|
||||
flash_express: 'Flash Express',
|
||||
kerry_th: 'Kerry Express (TH)',
|
||||
jnt_express: 'J&T Express',
|
||||
thailand_post: 'Thailand Post',
|
||||
other: 'Other'
|
||||
};
|
||||
|
||||
/**
|
||||
* Dispatch a tracking lookup to the appropriate carrier module.
|
||||
* Carrier modules are currently stubs and will throw "Not implemented".
|
||||
*/
|
||||
export async function fetchTrackingStatus(
|
||||
carrier: Carrier,
|
||||
trackingNumber: string,
|
||||
credentials: unknown
|
||||
): Promise<NormalizedShipmentStatus> {
|
||||
switch (carrier) {
|
||||
case 'ups':
|
||||
return ups.fetchStatus(trackingNumber, credentials as Parameters<typeof ups.fetchStatus>[1]);
|
||||
case 'fedex':
|
||||
return fedex.fetchStatus(trackingNumber, credentials as Parameters<typeof fedex.fetchStatus>[1]);
|
||||
case 'dhl':
|
||||
return dhl.fetchStatus(trackingNumber, credentials as Parameters<typeof dhl.fetchStatus>[1]);
|
||||
case 'usps':
|
||||
return usps.fetchStatus(trackingNumber, credentials as Parameters<typeof usps.fetchStatus>[1]);
|
||||
case 'flash_express':
|
||||
return flashExpress.fetchStatus(
|
||||
trackingNumber,
|
||||
credentials as Parameters<typeof flashExpress.fetchStatus>[1]
|
||||
);
|
||||
case 'kerry_th':
|
||||
return kerryTh.fetchStatus(
|
||||
trackingNumber,
|
||||
credentials as Parameters<typeof kerryTh.fetchStatus>[1]
|
||||
);
|
||||
case 'jnt_express':
|
||||
return jntExpress.fetchStatus(
|
||||
trackingNumber,
|
||||
credentials as Parameters<typeof jntExpress.fetchStatus>[1]
|
||||
);
|
||||
case 'thailand_post':
|
||||
return thailandPost.fetchStatus(
|
||||
trackingNumber,
|
||||
credentials as Parameters<typeof thailandPost.fetchStatus>[1]
|
||||
);
|
||||
case 'other':
|
||||
throw new Error('Carrier "other" cannot be tracked automatically. Use manual updates.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export type ShipmentStatus =
|
||||
| 'pending'
|
||||
| 'in_transit'
|
||||
| 'out_for_delivery'
|
||||
| 'delivered'
|
||||
| 'exception'
|
||||
| 'returned'
|
||||
| 'cancelled';
|
||||
|
||||
export type Carrier =
|
||||
| 'ups'
|
||||
| 'fedex'
|
||||
| 'dhl'
|
||||
| 'usps'
|
||||
| 'flash_express'
|
||||
| 'kerry_th'
|
||||
| 'jnt_express'
|
||||
| 'thailand_post'
|
||||
| 'other';
|
||||
|
||||
export interface ShipmentEvent {
|
||||
occurredAt: Date;
|
||||
status: ShipmentStatus;
|
||||
location: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface NormalizedShipmentStatus {
|
||||
status: ShipmentStatus;
|
||||
currentLocation: string | null;
|
||||
estimatedDelivery: Date | null;
|
||||
events: ShipmentEvent[];
|
||||
rawPayload: string;
|
||||
}
|
||||
Reference in New Issue
Block a user