From 965007f9305b11ddd360996c8f615b7f0797aba5 Mon Sep 17 00:00:00 2001 From: acoshift Date: Mon, 31 Aug 2026 15:41:57 +0700 Subject: [PATCH] billing: show pending after a payment slip is uploaded Treat pending invoices as unpaid (amount due, Pay, how-to-pay) and refresh the invoice after upload so the badge flips immediately. --- src/lib/billing.ts | 5 + src/lib/components/InvoiceStatusBadge.svelte | 6 ++ src/lib/components/PayInvoiceModal.svelte | 4 +- src/lib/server/mock.ts | 46 +++++++-- src/routes/(auth)/billing/detail/+page.svelte | 14 +-- .../(auth)/billing/invoice/+page.svelte | 20 ++-- .../(auth)/billing/invoices/+page.svelte | 3 +- src/types/api.d.ts | 4 +- tests/billing.spec.js | 95 +++++++++++++++++++ 9 files changed, 171 insertions(+), 26 deletions(-) diff --git a/src/lib/billing.ts b/src/lib/billing.ts index e378f94e..fd9650e9 100644 --- a/src/lib/billing.ts +++ b/src/lib/billing.ts @@ -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' +} diff --git a/src/lib/components/InvoiceStatusBadge.svelte b/src/lib/components/InvoiceStatusBadge.svelte index 4bac91cc..479ad457 100644 --- a/src/lib/components/InvoiceStatusBadge.svelte +++ b/src/lib/components/InvoiceStatusBadge.svelte @@ -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' } @@ -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); } diff --git a/src/lib/components/PayInvoiceModal.svelte b/src/lib/components/PayInvoiceModal.svelte index 09a5892d..2b256a72 100644 --- a/src/lib/components/PayInvoiceModal.svelte +++ b/src/lib/components/PayInvoiceModal.svelte @@ -7,7 +7,7 @@ interface Props { /** called after a slip is accepted */ - onuploaded?: () => void + onuploaded?: () => void | Promise } const { onuploaded }: Props = $props() @@ -115,7 +115,7 @@ return } isActive = false - onuploaded?.() + await onuploaded?.() } catch (err) { error = err instanceof Error ? err.message : String(err) } finally { diff --git a/src/lib/server/mock.ts b/src/lib/server/mock.ts index 81c86fff..1867e173 100644 --- a/src/lib/server/mock.ts +++ b/src/lib/server/mock.ts @@ -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. @@ -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', @@ -1625,8 +1648,6 @@ const handlers: Record 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() @@ -1644,10 +1665,17 @@ const handlers: Record 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' diff --git a/src/routes/(auth)/billing/detail/+page.svelte b/src/routes/(auth)/billing/detail/+page.svelte index 5f6ea5ba..9563518e 100644 --- a/src/routes/(auth)/billing/detail/+page.svelte +++ b/src/routes/(auth)/billing/detail/+page.svelte @@ -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() @@ -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) { @@ -62,7 +62,7 @@
{money(amountDue)}
{#if amountDue > 0} - {openInvoices.length} open {openInvoices.length === 1 ? 'invoice' : 'invoices'} + {unpaidInvoices.length} unpaid {unpaidInvoices.length === 1 ? 'invoice' : 'invoices'} {:else} You're all caught up {/if} diff --git a/src/routes/(auth)/billing/invoice/+page.svelte b/src/routes/(auth)/billing/invoice/+page.svelte index 1dea2bc4..93844aa7 100644 --- a/src/routes/(auth)/billing/invoice/+page.svelte +++ b/src/routes/(auth)/billing/invoice/+page.svelte @@ -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() @@ -16,7 +17,9 @@ let downloadingReceipt = $state(false) let payModal = $state | 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.' }) } @@ -232,7 +235,7 @@ Attach WHT certificate {/if} - {#if invoice.status === 'open'} + {#if isInvoiceUnpaid(invoice.status)}
- {#if invoice.status === 'open' && invoice.payment?.accountNo} + {#if isInvoiceUnpaid(invoice.status) && invoice.payment?.accountNo}
How to pay

- Transfer {money(invoice.total)} to the account below, then use the - Pay 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 + Pay button above to upload your slip. We'll verify it and + mark the invoice as paid. + {/if}

Bank
diff --git a/src/routes/(auth)/billing/invoices/+page.svelte b/src/routes/(auth)/billing/invoices/+page.svelte index c534dc16..cf049ac8 100644 --- a/src/routes/(auth)/billing/invoices/+page.svelte +++ b/src/routes/(auth)/billing/invoices/+page.svelte @@ -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() @@ -51,7 +52,7 @@ {it.receiptNumber || '—'} {format.datetime(it.issuedAt)} - {#if it.status === 'open'} + {#if isInvoiceUnpaid(it.status)} Pay diff --git a/src/types/api.d.ts b/src/types/api.d.ts index 5b12ad90..85e1f5b3 100644 --- a/src/types/api.d.ts +++ b/src/types/api.d.ts @@ -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 diff --git a/tests/billing.spec.js b/tests/billing.spec.js index 632abb84..f77d624c 100644 --- a/tests/billing.spec.js +++ b/tests/billing.spec.js @@ -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 }, @@ -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') @@ -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() + }) })