Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/lib/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,8 @@ export function canManageBilling (role?: string): boolean {
export function canDeleteBilling (role?: string): boolean {
return role === 'owner'
}

/** Issued and still owing: open, or pending while a slip awaits review. */
export function isInvoiceUnpaid (status?: Api.InvoiceStatus): boolean {
return status === 'open' || status === 'pending'
}
6 changes: 6 additions & 0 deletions src/lib/components/InvoiceStatusBadge.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

const badge = $derived.by(() => {
if (status === 'paid') return { icon: 'fa-circle-check', cls: 'is-positive', label: 'Paid' }
if (status === 'pending') return { icon: 'fa-hourglass-half', cls: 'is-info', label: 'Pending' }
if (status === 'open') return { icon: 'fa-clock', cls: 'is-warning', label: 'Open' }
if (status === 'void') return { icon: 'fa-ban', cls: 'is-muted', label: 'Void' }
if (status === 'draft') return { icon: 'fa-pen', cls: 'is-muted', label: 'Draft' }
Expand Down Expand Up @@ -36,6 +37,11 @@
background: hsl(var(--hsl-warning, var(--hsl-primary)) / 0.14);
}

.invoice-badge.is-info {
color: hsl(var(--hsl-primary));
background: hsl(var(--hsl-primary) / 0.12);
}

.invoice-badge.is-muted {
color: hsl(var(--hsl-content) / 0.65);
}
Expand Down
4 changes: 2 additions & 2 deletions src/lib/components/PayInvoiceModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

interface Props {
/** called after a slip is accepted */
onuploaded?: () => void
onuploaded?: () => void | Promise<void>
}

const { onuploaded }: Props = $props()
Expand Down Expand Up @@ -115,7 +115,7 @@
return
}
isActive = false
onuploaded?.()
await onuploaded?.()
} catch (err) {
error = err instanceof Error ? err.message : String(err)
} finally {
Expand Down
46 changes: 37 additions & 9 deletions src/lib/server/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1331,9 +1331,9 @@ const repositoryManifests = [
{ digest: 'sha256:2222222222222222222222222222222222222222222222222222222222222222', size: 156237824, createdAt: CREATED_AT }
]

// Invoices covering every status the UI renders (paid / open / void / draft)
// plus a foreign-currency + zero-tax case and a partial-period case, so the
// status badge and detail layout can be exercised offline. period_end is the
// Invoices covering every status the UI renders (paid / open / pending / void /
// draft) plus a foreign-currency + zero-tax case and a partial-period case, so
// the status badge and detail layout can be exercised offline. period_end is the
// exclusive first instant of the next period, matching the real backend.
// NOTE: the real billing.listInvoices hides drafts; this mock lists the draft
// too so its badge is visible during dev.
Expand Down Expand Up @@ -1363,6 +1363,29 @@ const invoices = [
{ projectId: '1002', project: 'api-service', description: 'API service', amount: 384 }
]
},
{
id: 'inv_mock_pending',
billingAccountId: 'ba_mock_1',
number: 'INV-2026-0010',
currency: 'THB',
periodStart: '2026-06-01T00:00:00Z',
periodEnd: '2026-07-01T00:00:00Z',
subtotal: 200,
taxRate: 0.07,
taxAmount: 14,
total: 214,
status: 'pending',
taxId: '0123456789012',
taxName: 'Acme Co., Ltd.',
taxAddress: '1 Mockingbird Lane, Bangkok 10110',
issuedAt: '2026-07-01T00:00:00Z',
paidAt: '',
voidedAt: '',
createdAt: '2026-07-01T00:00:00Z',
lineItems: [
{ projectId: '1001', project: 'web-frontend', description: 'Web frontend', amount: 214 }
]
},
{
id: 'inv_mock_8',
billingAccountId: 'ba_mock_1',
Expand Down Expand Up @@ -1625,8 +1648,6 @@ const handlers: Record<string, (args: any) => object> = {
expiresAt: '2026-06-02T00:00:00Z'
})
},
// Multipart upload: the proxy can't JSON-parse the body in mock mode, so
// args is empty here — just acknowledge the upload.
'billing.listMembers': () => ok({ owner: billingOwnerEmail, items: billingMembers }),
'billing.addMember': (args) => {
const email = String(args?.email ?? '').toLowerCase()
Expand All @@ -1644,10 +1665,17 @@ const handlers: Record<string, (args: any) => object> = {
billingMembers = billingMembers.filter((m) => m.email !== email)
return ok({})
},
'billing.uploadTransferSlip': () => ok({
downloadUrl: 'https://dropbox.deploys.app/files/mock-slip.jpg',
expiresAt: '2026-06-02T00:00:00Z'
}),
// Multipart upload: the proxy can't JSON-parse the body in mock mode, so
// args is empty here. Flip the demo open invoice to pending so a refresh
// after Pay shows the awaiting-review state.
'billing.uploadTransferSlip': () => {
const inv = invoices.find((i) => i.id === 'inv_mock_9')
if (inv) inv.status = 'pending'
return ok({
downloadUrl: 'https://dropbox.deploys.app/files/mock-slip.jpg',
expiresAt: '2026-06-02T00:00:00Z'
})
},
'billing.uploadWHTCertificate': () => ok({
downloadUrl: 'https://dropbox.deploys.app/files/mock-whtcert.pdf',
expiresAt: '2027-05-31T00:00:00Z'
Expand Down
14 changes: 7 additions & 7 deletions src/routes/(auth)/billing/detail/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import * as modal from '$lib/modal'
import api from '$lib/api'
import InvoiceStatusBadge from '$lib/components/InvoiceStatusBadge.svelte'
import { canManageBilling, canDeleteBilling } from '$lib/billing'
import { canManageBilling, canDeleteBilling, isInvoiceUnpaid } from '$lib/billing'
import type { PageData } from './$types'

const { data }: { data: PageData } = $props()
Expand All @@ -15,11 +15,11 @@
const canManage = $derived(canManageBilling(billingAccount.role))
const canDelete = $derived(canDeleteBilling(billingAccount.role))

const openInvoices = $derived(invoices.filter((it) => it.status === 'open'))
const currency = $derived(openInvoices[0]?.currency ?? invoices[0]?.currency ?? 'THB')
const amountDue = $derived(openInvoices.reduce((sum, it) => sum + it.total, 0))
// The most recent open invoice is the one to settle first.
const payTarget = $derived(openInvoices[0])
const unpaidInvoices = $derived(invoices.filter((it) => isInvoiceUnpaid(it.status)))
const currency = $derived(unpaidInvoices[0]?.currency ?? invoices[0]?.currency ?? 'THB')
const amountDue = $derived(unpaidInvoices.reduce((sum, it) => sum + it.total, 0))
// The most recent unpaid invoice is the one to settle first.
const payTarget = $derived(unpaidInvoices[0])
const latest = $derived(invoices[0])

function money (v: number, cur = currency) {
Expand Down Expand Up @@ -62,7 +62,7 @@
<div class="hero-amount">{money(amountDue)}</div>
<div class="hero-sub">
{#if amountDue > 0}
{openInvoices.length} open {openInvoices.length === 1 ? 'invoice' : 'invoices'}
{unpaidInvoices.length} unpaid {unpaidInvoices.length === 1 ? 'invoice' : 'invoices'}
{:else}
<i class="fa-solid fa-circle-check text-positive"></i> You're all caught up
{/if}
Expand Down
20 changes: 14 additions & 6 deletions src/routes/(auth)/billing/invoice/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import * as format from '$lib/format'
import * as modal from '$lib/modal'
import api from '$lib/api'
import { isInvoiceUnpaid } from '$lib/billing'
import type { PageData } from './$types'

const { data }: { data: PageData } = $props()
Expand All @@ -16,7 +17,9 @@
let downloadingReceipt = $state(false)
let payModal = $state<ReturnType<typeof PayInvoiceModal> | null>(null)

function onSlipUploaded () {
async function onSlipUploaded () {
await api.invalidate('billing.getInvoice')
await api.invalidate('billing.listInvoices')
modal.success({ content: 'Payment slip uploaded. We\'ll verify it and mark the invoice as paid.' })
}

Expand Down Expand Up @@ -232,7 +235,7 @@
Attach WHT certificate
</button>
{/if}
{#if invoice.status === 'open'}
{#if isInvoiceUnpaid(invoice.status)}
<button class="button is-icon-left" onclick={() => payModal?.open(invoice)}>
<i class="fa-solid fa-receipt"></i>
Pay
Expand Down Expand Up @@ -323,15 +326,20 @@
</div>
</div>

{#if invoice.status === 'open' && invoice.payment?.accountNo}
{#if isInvoiceUnpaid(invoice.status) && invoice.payment?.accountNo}
<hr>

<div>
<h6 class="mb-3"><strong>How to pay</strong></h6>
<p class="mb-3 text-content/70">
Transfer {money(invoice.total)} to the account below, then use the
<strong>Pay</strong> button above to upload your slip. We'll verify it and
mark the invoice as paid.
{#if invoice.status === 'pending'}
We've received your payment slip and are verifying it. We'll mark the
invoice as paid once confirmed. You can re-upload a slip if needed.
{:else}
Transfer {money(invoice.total)} to the account below, then use the
<strong>Pay</strong> button above to upload your slip. We'll verify it and
mark the invoice as paid.
{/if}
</p>
<div class="meta">
<div class="key">Bank</div>
Expand Down
3 changes: 2 additions & 1 deletion src/routes/(auth)/billing/invoices/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import ErrorRow from '$lib/components/ErrorRow.svelte'
import InvoiceStatusBadge from '$lib/components/InvoiceStatusBadge.svelte'
import * as format from '$lib/format'
import { isInvoiceUnpaid } from '$lib/billing'
import type { PageData } from './$types'

const { data }: { data: PageData } = $props()
Expand Down Expand Up @@ -51,7 +52,7 @@
<td class="is-hide-mobile">{it.receiptNumber || '—'}</td>
<td class="is-hide-mobile">{format.datetime(it.issuedAt)}</td>
<td class="is-align-right">
{#if it.status === 'open'}
{#if isInvoiceUnpaid(it.status)}
<a class="button is-size-small is-icon-left" href={`/billing/invoice?id=${it.id}`}>
<i class="fa-solid fa-receipt"></i>
Pay
Expand Down
4 changes: 3 additions & 1 deletion src/types/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,9 @@ declare namespace Api {
createdAt: string
}

export type InvoiceStatus = 'draft' | 'open' | 'paid' | 'void'
// pending is a customer-facing overlay: the invoice is still open in the
// ledger, but a transfer slip is awaiting operator review.
export type InvoiceStatus = 'draft' | 'open' | 'pending' | 'paid' | 'void'

export type InvoiceListItem = {
id: string
Expand Down
95 changes: 95 additions & 0 deletions tests/billing.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,28 @@ test.describe('billing accounts', () => {
await expect(main.getByText('Company', { exact: true })).toBeVisible()
})

test('counts pending invoices in amount due', async ({ page }) => {
await setMocks({
'billing.get': { ok: true, result: sampleBillingAccount },
'billing.listInvoices': {
ok: true,
result: {
items: [
{ ...sampleInvoice, id: 'inv-1', status: 'pending', total: 10.7, currency: 'USD' }
]
}
}
})

await page.goto('/billing/detail?id=ba-1')

const main = page.locator('.content-wrapper')
await expect(main.getByText('Amount due')).toBeVisible()
await expect(main.locator('.hero-amount')).toHaveText('10.70 USD')
await expect(main.getByText('1 unpaid invoice')).toBeVisible()
await expect(main.getByRole('link', { name: 'Pay now' })).toBeVisible()
})

test('shows receipt numbers in the invoices list', async ({ page }) => {
await setMocks({
'billing.get': { ok: true, result: sampleBillingAccount },
Expand All @@ -61,6 +83,26 @@ test.describe('billing accounts', () => {
await expect(openRow.locator('td').nth(4)).toHaveText('—')
})

test('shows pending in the invoices list and keeps Pay', async ({ page }) => {
await setMocks({
'billing.get': { ok: true, result: sampleBillingAccount },
'billing.listInvoices': {
ok: true,
result: {
items: [
{ ...sampleInvoice, id: 'inv-1', status: 'pending', receiptNumber: '' }
]
}
}
})

await page.goto('/billing/invoices?id=ba-1')

const row = page.locator('.content-wrapper table tbody tr', { hasText: 'INV-2024-001' })
await expect(row.getByText('Pending', { exact: true })).toBeVisible()
await expect(row.getByRole('link', { name: 'Pay' })).toBeVisible()
})

test('empty state when no billing accounts', async ({ page }) => {
await page.goto('/billing')
const main = page.locator('.content-wrapper')
Expand Down Expand Up @@ -381,4 +423,57 @@ test.describe('invoice detail', () => {

await expect(page.locator('#app-modal')).toHaveText(/invoice pdf export is not available/)
})

test('shows pending while a payment slip awaits review', async ({ page }) => {
await setMocks({
'billing.getInvoice': { ok: true, result: { ...sampleInvoice, status: 'pending' } },
'billing.get': { ok: true, result: sampleBillingAccount }
})

await page.goto('/billing/invoice?id=inv-1')

await expect(page.getByText('Pending', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: 'Pay' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'How to pay' })).toBeVisible()
await expect(page.getByText(/received your payment slip/i)).toBeVisible()
})

test('flips the invoice to pending after a slip upload', async ({ page }) => {
await setMocks({
'billing.getInvoice': { ok: true, result: sampleInvoice },
'billing.get': { ok: true, result: sampleBillingAccount }
})

await page.route('**/api/billing.uploadTransferSlip', async (route) => {
await setMocks({
'billing.getInvoice': { ok: true, result: { ...sampleInvoice, status: 'pending' } },
'billing.get': { ok: true, result: sampleBillingAccount }
})
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
ok: true,
result: {
downloadUrl: 'https://dropbox.example/slip.jpg',
expiresAt: '2026-06-02T00:00:00Z'
}
})
})
})

await page.goto('/billing/invoice?id=inv-1')
await expect(page.getByText('Open', { exact: true })).toBeVisible()

await page.getByRole('button', { name: 'Pay' }).click()
await page.locator('.modal.is-active input[type=file]').setInputFiles({
name: 'slip.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('%PDF-1.4 mock slip')
})
await page.getByRole('button', { name: 'Upload slip' }).click()

await expect(page.locator('#app-modal')).toHaveText(/Payment slip uploaded/)
await expect(page.getByText('Pending', { exact: true })).toBeVisible()
})
})
Loading