Major expansion: HR module, CRM, integrations, packages, validation pipeline
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:
2026-04-14 16:35:13 +07:00
parent 765bf0d402
commit b6f07fe4df
98 changed files with 12012 additions and 145 deletions
+60 -12
View File
@@ -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
View File
@@ -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(
+25
View File
@@ -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.'
);
}
+26
View File
@@ -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.'
);
}
+10
View File
@@ -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
}
+297
View File
@@ -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();
}
+215
View File
@@ -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();
}
+93
View File
@@ -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 };
}
+99
View File
@@ -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' }
];
+21
View File
@@ -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.'
);
}
+23
View File
@@ -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.'
);
}
+23
View File
@@ -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.'
);
}
+21
View File
@@ -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.'
);
}
+64
View File
@@ -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.');
}
}
+34
View File
@@ -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;
}