Compare commits

..

2 Commits

Author SHA1 Message Date
Nikolai Giman 27e1597c13 Merge pull request #111 from Gimanh/fix/issues
feat: OAuth
2026-09-06 18:07:29 +02:00
Nikolai Giman 968c3d2eeb Merge pull request #105 from Gimanh/fix/issues
Fix/issues
2026-08-27 20:44:20 +02:00
84 changed files with 0 additions and 6714 deletions
-2
View File
@@ -27,7 +27,6 @@
"@types/luxon": "^3.7.1", "@types/luxon": "^3.7.1",
"@types/node": "^22.10.3", "@types/node": "^22.10.3",
"@types/passport-apple": "^2.0.3", "@types/passport-apple": "^2.0.3",
"@types/pdfmake": "^0.3.3",
"@types/pg": "^8.15.5", "@types/pg": "^8.15.5",
"@types/semver": "^7.5.8", "@types/semver": "^7.5.8",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
@@ -69,7 +68,6 @@
"passport-apple": "^2.0.2", "passport-apple": "^2.0.2",
"passport-github2": "^0.1.12", "passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
"pdfmake": "^0.3.11",
"pg": "^8.16.3", "pg": "^8.16.3",
"pg-boss": "^12.14.0", "pg-boss": "^12.14.0",
"pino": "^9.4.0", "pino": "^9.4.0",
-20
View File
@@ -780,25 +780,5 @@
"These surfaces were guarded only by an organization role or by project ownership, so they ignored the scope of an API or OAuth token: a token issued with a single permission could still create organizations, add admins, change SSO settings and create webhooks. The new keys make those actions narrowable like every other permission.", "These surfaces were guarded only by an organization role or by project ownership, so they ignored the scope of an API or OAuth token: a token issued with a single permission could still create organizations, add admins, change SSO settings and create webhooks. The new keys make those actions narrowable like every other permission.",
"Backwards compatible: a token with an empty permission list stays unrestricted, so existing integrations keep working." "Backwards compatible: a token with an empty permission list stays unrestricted, so existing integrations keep working."
] ]
},
"61": {
"version": "1.66.0",
"name": "Billing: currencies, sellers, clients, invoices",
"releaseDate": "20260904",
"scripts": [
"/1.66.0/0.create-currencies.sql",
"/1.66.0/1.create-billing-sellers.sql",
"/1.66.0/2.create-billing-counterparties.sql",
"/1.66.0/3.create-invoices.sql",
"/1.66.0/4.create-invoice-lines.sql",
"/1.66.0/5.billing-permission.sql",
"/1.66.0/6.alter-invoices-lifecycle.sql"
],
"description": [
"New schema tv_billing with the reference table currencies (ISO 4217, seeded with 20 currencies), sellers (the organization's own companies that issue invoices, with bank details and free-form requisites), counterparties (clients that are invoiced) and invoices with invoice_lines.",
"Invoices keep JSONB snapshots of the seller and the client plus the project name, so editing a company, a client or deleting a project never changes an issued document. Companies and clients are archived rather than deleted once they have invoices.",
"New organization-level permission billing_can_manage (group 6) narrows what an API or OAuth token may do on the billing surfaces; access itself requires the organization owner or admin role.",
"Invoice lifecycle: issued_at, paid_at, voided_at, replaces_invoice_id (void and reissue chain), template_version and totals frozen at issue time (subtotal, discount_amount, tax_amount, total). Status transitions are validated by the API; only drafts can be edited or deleted."
]
} }
} }
@@ -1,45 +0,0 @@
-- Reference list of currencies (ISO 4217) for anything that carries money:
-- invoices, counterparties, seller profiles. Names are stored in English only;
-- the UI localises them through Intl.DisplayNames by code. decimal_digits
-- drives amount formatting (JPY and KRW have none). Rows are never deleted,
-- only deactivated, so existing references stay valid.
CREATE SCHEMA IF NOT EXISTS tv_billing;
CREATE TABLE IF NOT EXISTS tv_billing.currencies (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
code CHAR(3) NOT NULL UNIQUE,
numeric_code SMALLINT NOT NULL UNIQUE,
name VARCHAR(64) NOT NULL,
symbol VARCHAR(8) NOT NULL,
decimal_digits SMALLINT NOT NULL DEFAULT 2,
sort_order SMALLINT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT currencies_code_upper_check CHECK (code = UPPER(code)),
CONSTRAINT currencies_decimal_digits_check CHECK (decimal_digits BETWEEN 0 AND 4)
);
CREATE INDEX IF NOT EXISTS idx_currencies_active_sort ON tv_billing.currencies(is_active, sort_order);
INSERT INTO tv_billing.currencies (code, numeric_code, name, symbol, decimal_digits, sort_order) VALUES
('USD', 840, 'US Dollar', '$', 2, 10),
('EUR', 978, 'Euro', '', 2, 20),
('GBP', 826, 'Pound Sterling', '£', 2, 30),
('JPY', 392, 'Japanese Yen', '¥', 0, 40),
('CNY', 156, 'Chinese Yuan', '¥', 2, 50),
('CHF', 756, 'Swiss Franc', 'CHF', 2, 60),
('CAD', 124, 'Canadian Dollar', 'CA$', 2, 70),
('AUD', 36, 'Australian Dollar', 'A$', 2, 80),
('RUB', 643, 'Russian Ruble', '', 2, 25),
('INR', 356, 'Indian Rupee', '', 2, 100),
('BRL', 986, 'Brazilian Real', 'R$', 2, 110),
('KRW', 410, 'South Korean Won', '', 0, 120),
('SGD', 702, 'Singapore Dollar', 'S$', 2, 130),
('HKD', 344, 'Hong Kong Dollar', 'HK$', 2, 140),
('SEK', 752, 'Swedish Krona', 'kr', 2, 150),
('NOK', 578, 'Norwegian Krone', 'kr', 2, 160),
('DKK', 208, 'Danish Krone', 'kr', 2, 170),
('PLN', 985, 'Polish Zloty', '', 2, 180),
('TRY', 949, 'Turkish Lira', '', 2, 190),
('AED', 784, 'UAE Dirham', 'د.إ', 2, 200)
ON CONFLICT (code) DO NOTHING;
@@ -1,20 +0,0 @@
CREATE TABLE IF NOT EXISTS tv_billing.sellers (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
organization_id INTEGER NOT NULL REFERENCES tv_auth.organizations(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
legal_name VARCHAR(300) NOT NULL DEFAULT '',
address VARCHAR(1000) NOT NULL DEFAULT '',
email VARCHAR(320) NOT NULL DEFAULT '',
phone VARCHAR(50) NOT NULL DEFAULT '',
logo_url VARCHAR(1000) NOT NULL DEFAULT '',
currency_code CHAR(3) NOT NULL DEFAULT 'USD' REFERENCES tv_billing.currencies(code),
bank JSONB NOT NULL DEFAULT '{}'::jsonb,
requisites JSONB NOT NULL DEFAULT '[]'::jsonb,
default_terms VARCHAR(2000) NOT NULL DEFAULT '',
tax_note VARCHAR(500) NOT NULL DEFAULT '',
archived BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_billing_sellers_org ON tv_billing.sellers(organization_id, archived);
@@ -1,18 +0,0 @@
CREATE TABLE IF NOT EXISTS tv_billing.counterparties (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
organization_id INTEGER NOT NULL REFERENCES tv_auth.organizations(id) ON DELETE CASCADE,
kind VARCHAR(20) NOT NULL DEFAULT 'organization',
name VARCHAR(200) NOT NULL,
legal_name VARCHAR(300) NOT NULL DEFAULT '',
address VARCHAR(1000) NOT NULL DEFAULT '',
email VARCHAR(320) NOT NULL DEFAULT '',
phone VARCHAR(50) NOT NULL DEFAULT '',
contact_person VARCHAR(200) NOT NULL DEFAULT '',
requisites JSONB NOT NULL DEFAULT '[]'::jsonb,
archived BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT counterparties_kind_check CHECK (kind IN ('organization', 'person'))
);
CREATE INDEX IF NOT EXISTS idx_billing_counterparties_org ON tv_billing.counterparties(organization_id, archived);
@@ -1,37 +0,0 @@
CREATE TABLE IF NOT EXISTS tv_billing.invoices (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
organization_id INTEGER NOT NULL REFERENCES tv_auth.organizations(id) ON DELETE CASCADE,
goal_id INTEGER REFERENCES tasks.goals(id) ON DELETE SET NULL,
goal_name VARCHAR(500) NOT NULL DEFAULT '',
seller_id INTEGER NOT NULL REFERENCES tv_billing.sellers(id) ON DELETE RESTRICT,
counterparty_id INTEGER NOT NULL REFERENCES tv_billing.counterparties(id) ON DELETE RESTRICT,
number VARCHAR(50) NOT NULL,
status VARCHAR(10) NOT NULL DEFAULT 'draft',
reference VARCHAR(200) NOT NULL DEFAULT '',
currency_code CHAR(3) NOT NULL REFERENCES tv_billing.currencies(code),
issue_date DATE NOT NULL,
payment_terms VARCHAR(20) NOT NULL DEFAULT 'net14',
due_date DATE,
period_from DATE,
period_to DATE,
discount_type VARCHAR(10) NOT NULL DEFAULT 'percent',
discount_value NUMERIC(12, 2) NOT NULL DEFAULT 0,
tax_rate NUMERIC(5, 2) NOT NULL DEFAULT 0,
tax_exempt BOOLEAN NOT NULL DEFAULT FALSE,
tax_note VARCHAR(500) NOT NULL DEFAULT '',
notes VARCHAR(2000) NOT NULL DEFAULT '',
terms VARCHAR(2000) NOT NULL DEFAULT '',
seller_snapshot JSONB NOT NULL,
counterparty_snapshot JSONB NOT NULL,
created_by INTEGER REFERENCES tv_auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT invoices_status_check CHECK (status IN ('draft', 'issued', 'paid', 'void')),
CONSTRAINT invoices_discount_type_check CHECK (discount_type IN ('percent', 'amount')),
CONSTRAINT invoices_payment_terms_check CHECK (payment_terms IN ('on_receipt', 'net7', 'net14', 'net30', 'custom')),
CONSTRAINT invoices_number_per_org UNIQUE (organization_id, number)
);
CREATE INDEX IF NOT EXISTS idx_billing_invoices_org_issue ON tv_billing.invoices(organization_id, issue_date DESC);
CREATE INDEX IF NOT EXISTS idx_billing_invoices_counterparty ON tv_billing.invoices(counterparty_id);
CREATE INDEX IF NOT EXISTS idx_billing_invoices_seller ON tv_billing.invoices(seller_id);
@@ -1,13 +0,0 @@
CREATE TABLE IF NOT EXISTS tv_billing.invoice_lines (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
invoice_id INTEGER NOT NULL REFERENCES tv_billing.invoices(id) ON DELETE CASCADE,
position SMALLINT NOT NULL DEFAULT 0,
task_id INTEGER REFERENCES tasks.tasks(id) ON DELETE SET NULL,
description VARCHAR(1000) NOT NULL,
unit VARCHAR(20) NOT NULL DEFAULT 'service',
quantity NUMERIC(12, 2) NOT NULL DEFAULT 1,
unit_price NUMERIC(12, 2) NOT NULL DEFAULT 0,
CONSTRAINT invoice_lines_unit_check CHECK (unit IN ('service', 'hours', 'pcs'))
);
CREATE INDEX IF NOT EXISTS idx_billing_invoice_lines_invoice ON tv_billing.invoice_lines(invoice_id, position);
@@ -1,14 +0,0 @@
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
VALUES (
'billing_can_manage',
'Manage invoices, clients and seller companies of the organization',
6,
'{
"en": "Manage billing. Create and edit invoices, clients and seller companies of the organization.",
"ru": "Управление биллингом. Создавать и редактировать инвойсы, клиентов и компании-продавцы организации.",
"de": "Abrechnung verwalten. Rechnungen, Kunden und Firmen der Organisation erstellen und bearbeiten.",
"es": "Gestionar facturación. Crear y editar facturas, clientes y empresas de la organización.",
"pt-BR": "Gerenciar faturamento. Criar e editar faturas, clientes e empresas da organização."
}'::jsonb
)
ON CONFLICT (name) DO NOTHING;
@@ -1,11 +0,0 @@
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS issued_at TIMESTAMP;
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS paid_at TIMESTAMP;
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS voided_at TIMESTAMP;
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS replaces_invoice_id INTEGER REFERENCES tv_billing.invoices(id) ON DELETE SET NULL;
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS template_version SMALLINT NOT NULL DEFAULT 1;
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS subtotal NUMERIC(12, 2);
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS discount_amount NUMERIC(12, 2);
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS tax_amount NUMERIC(12, 2);
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS total NUMERIC(12, 2);
CREATE INDEX IF NOT EXISTS idx_billing_invoices_replaces ON tv_billing.invoices(replaces_invoice_id);
-4
View File
@@ -24,8 +24,6 @@ import TimeTrackingRoutes from '../tv-modules/time-tracking/TimeTrackingRoutes';
import UiPreferencesRoutes from '../tv-modules/ui-preferences/UiPreferencesRoutes'; import UiPreferencesRoutes from '../tv-modules/ui-preferences/UiPreferencesRoutes';
import SprintsRoutes from '../tv-modules/sprints/SprintsRoutes'; import SprintsRoutes from '../tv-modules/sprints/SprintsRoutes';
import RecurrenceRoutes from '../tv-modules/recurrence/RecurrenceRoutes'; import RecurrenceRoutes from '../tv-modules/recurrence/RecurrenceRoutes';
import BillingRoutes from '../tv-modules/billing/BillingRoutes';
import InvoicesRoutes from '../tv-modules/invoices/InvoicesRoutes';
import type { Routable } from '../types/routable.type'; import type { Routable } from '../types/routable.type';
type RoutableConstructor = new (...args: any[]) => Routable; type RoutableConstructor = new (...args: any[]) => Routable;
@@ -55,8 +53,6 @@ const routes: Record<string, RoutableConstructor> = {
'/module/ui-preferences': UiPreferencesRoutes, '/module/ui-preferences': UiPreferencesRoutes,
'/module/sprints': SprintsRoutes, '/module/sprints': SprintsRoutes,
'/module/recurrence': RecurrenceRoutes, '/module/recurrence': RecurrenceRoutes,
'/module/billing': BillingRoutes,
'/module/invoices': InvoicesRoutes,
'/scim/v2': ScimRoutes, '/scim/v2': ScimRoutes,
'/.well-known': OAuthWellKnownRoutes, '/.well-known': OAuthWellKnownRoutes,
}; };
@@ -1,107 +0,0 @@
import type { Request, Response } from 'express'
import { ArkErrors } from 'arktype'
import { BillingManager } from './BillingManager'
import {
BillingArkTypeArchive,
BillingArkTypeId,
BillingArkTypeList,
CounterpartyArkTypeCreate,
CounterpartyArkTypeUpdate,
SellerArkTypeCreate,
SellerArkTypeUpdate,
type DeleteResult,
} from './types'
const DELETE_STATUS: Record<DeleteResult, number> = { deleted: 200, in_use: 409, not_found: 404 }
export class BillingController {
private readonly manager = new BillingManager()
currencies = async (_req: Request, res: Response) => {
return res.tvJson(await this.manager.fetchCurrencies())
}
fetchSellers = async (req: Request, res: Response) => {
const data = BillingArkTypeList(req.query)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
return res.tvJson(await this.manager.fetchSellers(data))
}
createSeller = async (req: Request, res: Response) => {
const data = SellerArkTypeCreate(req.body)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.createSeller(data)
if (!result) return res.status(500).end()
return res.tvJson(result)
}
updateSeller = async (req: Request, res: Response) => {
const id = BillingArkTypeId(req.params)
const data = SellerArkTypeUpdate(req.body)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.updateSeller({ sellerId: id.id, data })
if (!result) return res.status(404).end()
return res.tvJson(result)
}
archiveSeller = async (req: Request, res: Response) => {
const id = BillingArkTypeId(req.params)
const data = BillingArkTypeArchive(req.body)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.setSellerArchived({ id: id.id, archived: data.archived })
if (!result) return res.status(404).end()
return res.tvJson(result)
}
deleteSeller = async (req: Request, res: Response) => {
const id = BillingArkTypeId(req.params)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
const result = await this.manager.deleteSeller(id.id)
if (result !== 'deleted') return res.status(DELETE_STATUS[result]).end()
return res.tvJson(true)
}
fetchCounterparties = async (req: Request, res: Response) => {
const data = BillingArkTypeList(req.query)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
return res.tvJson(await this.manager.fetchCounterparties(data))
}
createCounterparty = async (req: Request, res: Response) => {
const data = CounterpartyArkTypeCreate(req.body)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.createCounterparty(data)
if (!result) return res.status(500).end()
return res.tvJson(result)
}
updateCounterparty = async (req: Request, res: Response) => {
const id = BillingArkTypeId(req.params)
const data = CounterpartyArkTypeUpdate(req.body)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.updateCounterparty({ counterpartyId: id.id, data })
if (!result) return res.status(404).end()
return res.tvJson(result)
}
archiveCounterparty = async (req: Request, res: Response) => {
const id = BillingArkTypeId(req.params)
const data = BillingArkTypeArchive(req.body)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.setCounterpartyArchived({ id: id.id, archived: data.archived })
if (!result) return res.status(404).end()
return res.tvJson(result)
}
deleteCounterparty = async (req: Request, res: Response) => {
const id = BillingArkTypeId(req.params)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
const result = await this.manager.deleteCounterparty(id.id)
if (result !== 'deleted') return res.status(DELETE_STATUS[result]).end()
return res.tvJson(true)
}
}
@@ -1,74 +0,0 @@
import { BillingRepository } from './BillingRepository'
import type {
BillingArgList,
CounterpartyArgCreate,
CounterpartyForClient,
CounterpartyUpdateArgs,
DeleteResult,
SellerArgCreate,
SellerForClient,
SellerUpdateArgs,
SetArchivedArgs,
} from './types'
export class BillingManager {
public readonly repository: BillingRepository
constructor() {
this.repository = new BillingRepository()
}
fetchCurrencies() {
return this.repository.fetchCurrencies()
}
fetchSellers(args: BillingArgList): Promise<SellerForClient[]> {
return this.repository.fetchSellers(args)
}
fetchSellerById(sellerId: number): Promise<SellerForClient | null> {
return this.repository.fetchSellerById(sellerId)
}
createSeller(data: SellerArgCreate): Promise<SellerForClient | null> {
return this.repository.createSeller(data)
}
updateSeller(args: SellerUpdateArgs): Promise<SellerForClient | null> {
return this.repository.updateSeller(args)
}
setSellerArchived(args: SetArchivedArgs): Promise<SellerForClient | null> {
return this.repository.setSellerArchived(args)
}
async deleteSeller(sellerId: number): Promise<DeleteResult> {
if ((await this.repository.countInvoicesBySeller(sellerId)) > 0) return 'in_use'
return (await this.repository.deleteSeller(sellerId)) ? 'deleted' : 'not_found'
}
fetchCounterparties(args: BillingArgList): Promise<CounterpartyForClient[]> {
return this.repository.fetchCounterparties(args)
}
fetchCounterpartyById(counterpartyId: number): Promise<CounterpartyForClient | null> {
return this.repository.fetchCounterpartyById(counterpartyId)
}
createCounterparty(data: CounterpartyArgCreate): Promise<CounterpartyForClient | null> {
return this.repository.createCounterparty(data)
}
updateCounterparty(args: CounterpartyUpdateArgs): Promise<CounterpartyForClient | null> {
return this.repository.updateCounterparty(args)
}
setCounterpartyArchived(args: SetArchivedArgs): Promise<CounterpartyForClient | null> {
return this.repository.setCounterpartyArchived(args)
}
async deleteCounterparty(counterpartyId: number): Promise<DeleteResult> {
if ((await this.repository.countInvoicesByCounterparty(counterpartyId)) > 0) return 'in_use'
return (await this.repository.deleteCounterparty(counterpartyId)) ? 'deleted' : 'not_found'
}
}
@@ -1,199 +0,0 @@
import { and, asc, count, eq } from 'drizzle-orm'
import {
CounterpartiesSchema,
CurrenciesSchema,
InvoicesSchema,
SellersSchema,
type CounterpartiesSchemaTypeForSelect,
type CurrenciesSchemaTypeForSelect,
type SellersSchemaTypeForSelect,
} from 'taskview-db-schemas'
import { Database } from '../../modules/db'
import { callWithCatch } from '../../utils/helpers'
import type {
BillingArgList,
CounterpartyArgCreate,
CounterpartyArgUpdate,
CounterpartyUpdateArgs,
SellerArgCreate,
SellerArgUpdate,
SellerUpdateArgs,
SetArchivedArgs,
} from './types'
export class BillingRepository {
private readonly db: Database
constructor() {
this.db = Database.getInstance()
}
async fetchCurrencies(): Promise<CurrenciesSchemaTypeForSelect[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(CurrenciesSchema)
.where(eq(CurrenciesSchema.isActive, true))
.orderBy(asc(CurrenciesSchema.sortOrder)),
)
return result ?? []
}
async fetchSellers({ organizationId, includeArchived }: BillingArgList): Promise<SellersSchemaTypeForSelect[]> {
const conditions = [eq(SellersSchema.organizationId, organizationId)]
if (!includeArchived) conditions.push(eq(SellersSchema.archived, false))
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(SellersSchema).where(and(...conditions)).orderBy(asc(SellersSchema.name)),
)
return result ?? []
}
async fetchSellerById(sellerId: number): Promise<SellersSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(SellersSchema).where(eq(SellersSchema.id, sellerId)),
)
return result?.[0] ?? null
}
private sellerColumns(data: SellerArgUpdate) {
return {
name: data.name,
legalName: data.legalName,
address: data.address,
email: data.email,
phone: data.phone,
logoUrl: data.logoUrl,
currencyCode: data.currencyCode,
bank: data.bank,
requisites: data.requisites,
defaultTerms: data.defaultTerms,
taxNote: data.taxNote,
}
}
private counterpartyColumns(data: CounterpartyArgUpdate) {
return {
kind: data.kind,
name: data.name,
legalName: data.legalName,
address: data.address,
email: data.email,
phone: data.phone,
contactPerson: data.contactPerson,
requisites: data.requisites,
}
}
async createSeller(data: SellerArgCreate): Promise<SellersSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.insert(SellersSchema)
.values({ organizationId: data.organizationId, ...this.sellerColumns(data) })
.returning(),
)
return result?.[0] ?? null
}
async updateSeller({ sellerId, data }: SellerUpdateArgs): Promise<SellersSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.update(SellersSchema)
.set({ ...this.sellerColumns(data), updatedAt: new Date() })
.where(eq(SellersSchema.id, sellerId))
.returning(),
)
return result?.[0] ?? null
}
async setSellerArchived({ id, archived }: SetArchivedArgs): Promise<SellersSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.update(SellersSchema)
.set({ archived, updatedAt: new Date() })
.where(eq(SellersSchema.id, id))
.returning(),
)
return result?.[0] ?? null
}
async deleteSeller(sellerId: number): Promise<boolean> {
const result = await callWithCatch(() => this.db.dbDrizzle.delete(SellersSchema).where(eq(SellersSchema.id, sellerId)))
return !!result?.rowCount
}
async countInvoicesBySeller(sellerId: number): Promise<number> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select({ total: count() }).from(InvoicesSchema).where(eq(InvoicesSchema.sellerId, sellerId)),
)
return result?.[0]?.total ?? 0
}
async fetchCounterparties({ organizationId, includeArchived }: BillingArgList): Promise<CounterpartiesSchemaTypeForSelect[]> {
const conditions = [eq(CounterpartiesSchema.organizationId, organizationId)]
if (!includeArchived) conditions.push(eq(CounterpartiesSchema.archived, false))
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(CounterpartiesSchema)
.where(and(...conditions))
.orderBy(asc(CounterpartiesSchema.name)),
)
return result ?? []
}
async fetchCounterpartyById(counterpartyId: number): Promise<CounterpartiesSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(CounterpartiesSchema).where(eq(CounterpartiesSchema.id, counterpartyId)),
)
return result?.[0] ?? null
}
async createCounterparty(data: CounterpartyArgCreate): Promise<CounterpartiesSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.insert(CounterpartiesSchema)
.values({ organizationId: data.organizationId, ...this.counterpartyColumns(data) })
.returning(),
)
return result?.[0] ?? null
}
async updateCounterparty({ counterpartyId, data }: CounterpartyUpdateArgs): Promise<CounterpartiesSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.update(CounterpartiesSchema)
.set({ ...this.counterpartyColumns(data), updatedAt: new Date() })
.where(eq(CounterpartiesSchema.id, counterpartyId))
.returning(),
)
return result?.[0] ?? null
}
async setCounterpartyArchived({ id, archived }: SetArchivedArgs): Promise<CounterpartiesSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.update(CounterpartiesSchema)
.set({ archived, updatedAt: new Date() })
.where(eq(CounterpartiesSchema.id, id))
.returning(),
)
return result?.[0] ?? null
}
async deleteCounterparty(counterpartyId: number): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.delete(CounterpartiesSchema).where(eq(CounterpartiesSchema.id, counterpartyId)),
)
return !!result?.rowCount
}
async countInvoicesByCounterparty(counterpartyId: number): Promise<number> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({ total: count() })
.from(InvoicesSchema)
.where(eq(InvoicesSchema.counterpartyId, counterpartyId)),
)
return result?.[0]?.total ?? 0
}
}
@@ -1,41 +0,0 @@
import { Router } from 'express'
import type { Routable } from '../../types/routable.type'
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
import { IsOrgAdmin } from '../organizations/middlewares/IsOrgAdmin'
import { RequireTokenPermission } from '../../middlewares/require-token-permission'
import { GoalPermissions } from '../../types/auth.types'
import { BillingController } from './BillingController'
import { isOrgAdminForCounterparty, isOrgAdminForSeller } from './middlewares/is-org-admin-for-billing'
export default class BillingRoutes implements Routable {
private readonly router: ReturnType<typeof Router>
private readonly controller: BillingController
constructor() {
this.router = Router()
this.controller = new BillingController()
this.initRoutes()
}
getRouter() {
return this.router
}
private initRoutes() {
const canManage = RequireTokenPermission(GoalPermissions.BILLING_CAN_MANAGE)
this.router.get('/currencies', [IsLoggedIn], this.controller.currencies)
this.router.get('/sellers', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.fetchSellers)
this.router.post('/sellers', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.createSeller)
this.router.patch('/sellers/:id', [IsLoggedIn, isOrgAdminForSeller, canManage], this.controller.updateSeller)
this.router.patch('/sellers/:id/archive', [IsLoggedIn, isOrgAdminForSeller, canManage], this.controller.archiveSeller)
this.router.delete('/sellers/:id', [IsLoggedIn, isOrgAdminForSeller, canManage], this.controller.deleteSeller)
this.router.get('/counterparties', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.fetchCounterparties)
this.router.post('/counterparties', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.createCounterparty)
this.router.patch('/counterparties/:id', [IsLoggedIn, isOrgAdminForCounterparty, canManage], this.controller.updateCounterparty)
this.router.patch('/counterparties/:id/archive', [IsLoggedIn, isOrgAdminForCounterparty, canManage], this.controller.archiveCounterparty)
this.router.delete('/counterparties/:id', [IsLoggedIn, isOrgAdminForCounterparty, canManage], this.controller.deleteCounterparty)
}
}
@@ -1,10 +0,0 @@
import { BillingRepository } from '../BillingRepository'
import { isOrgAdminFor } from './is-org-admin-for'
const repository = new BillingRepository()
export const isOrgAdminForSeller = isOrgAdminFor(async (id) => (await repository.fetchSellerById(id))?.organizationId ?? null)
export const isOrgAdminForCounterparty = isOrgAdminFor(
async (id) => (await repository.fetchCounterpartyById(id))?.organizationId ?? null,
)
@@ -1,20 +0,0 @@
import type { NextFunction, Request, Response } from 'express'
import { ORG_ADMIN_ROLES, type OrgRole } from '../../organizations/types'
import { parsePositiveInt } from '../../../utils/helpers'
export type OrganizationIdResolver = (id: number) => Promise<number | null>
export const isOrgAdminFor = (resolveOrganizationId: OrganizationIdResolver) => {
return async (req: Request, res: Response, next: NextFunction) => {
const id = parsePositiveInt(req.params.id)
if (id === null) return res.status(400).end()
const organizationId = await resolveOrganizationId(id)
if (organizationId === null) return res.status(404).end()
const member = await req.appUser.organizationManager.getCurrentUserMember(organizationId)
if (!member || !ORG_ADMIN_ROLES.includes(member.role as OrgRole)) return res.status(403).end()
return next()
}
}
-103
View File
@@ -1,103 +0,0 @@
import { type } from 'arktype'
import type {
BillingBankDetails,
BillingRequisite,
CounterpartiesSchemaTypeForSelect,
SellersSchemaTypeForSelect,
} from 'taskview-db-schemas'
const NumberFromString = type('string|number').pipe((v) => Number(v))
const BooleanFromString = type('string|boolean|undefined').pipe((v) => {
if (v === undefined) return undefined
if (typeof v === 'boolean') return v
return v === 'true' || v === '1'
})
export const RequisiteArkType = type({
key: 'string<=64',
label: 'string<=100',
value: 'string<=300',
})
export const BankDetailsArkType = type({
bankName: 'string<=200',
accountNumber: 'string<=64',
iban: 'string<=64',
swift: 'string<=32',
correspondentAccount: 'string<=64',
})
export const SellerArkTypeCreate = type({
organizationId: 'number',
name: '1<=string<=200',
legalName: 'string<=300',
address: 'string<=1000',
email: 'string<=320',
phone: 'string<=50',
logoUrl: 'string<=1000',
currencyCode: /^[A-Z]{3}$/,
bank: BankDetailsArkType,
requisites: RequisiteArkType.array(),
defaultTerms: 'string<=2000',
taxNote: 'string<=500',
})
export type SellerArgCreate = typeof SellerArkTypeCreate.infer
export const SellerArkTypeUpdate = SellerArkTypeCreate.omit('organizationId')
export type SellerArgUpdate = typeof SellerArkTypeUpdate.infer
export const CounterpartyArkTypeCreate = type({
organizationId: 'number',
kind: "'organization' | 'person'",
name: '1<=string<=200',
legalName: 'string<=300',
address: 'string<=1000',
email: 'string<=320',
phone: 'string<=50',
contactPerson: 'string<=200',
requisites: RequisiteArkType.array(),
})
export type CounterpartyArgCreate = typeof CounterpartyArkTypeCreate.infer
export const CounterpartyArkTypeUpdate = CounterpartyArkTypeCreate.omit('organizationId')
export type CounterpartyArgUpdate = typeof CounterpartyArkTypeUpdate.infer
export const BillingArkTypeList = type({
organizationId: NumberFromString,
'includeArchived?': BooleanFromString,
})
export type BillingArgList = typeof BillingArkTypeList.infer
export const BillingArkTypeId = type({
id: NumberFromString,
})
export const BillingArkTypeArchive = type({
archived: 'boolean',
})
export type SellerUpdateArgs = {
sellerId: number
data: SellerArgUpdate
}
export type CounterpartyUpdateArgs = {
counterpartyId: number
data: CounterpartyArgUpdate
}
export type SetArchivedArgs = {
id: number
archived: boolean
}
export type SellerForClient = Omit<SellersSchemaTypeForSelect, 'bank' | 'requisites'> & {
bank: BillingBankDetails
requisites: BillingRequisite[]
}
export type CounterpartyForClient = Omit<CounterpartiesSchemaTypeForSelect, 'requisites'> & {
requisites: BillingRequisite[]
}
export type DeleteResult = 'deleted' | 'in_use' | 'not_found'
@@ -1,233 +0,0 @@
import pdfmakeModule from 'pdfmake'
import vfs from 'pdfmake/build/vfs_fonts.js'
import { invoiceLineAmount } from '../../utils/invoiceTotals'
import type { InvoiceForClient, InvoicePdfLang, PdfmakeServer } from './types'
const LABELS: Record<InvoicePdfLang, Record<string, string>> = {
en: {
title: 'INVOICE',
void: 'VOID',
issueDate: 'Issue date',
dueDate: 'Due date',
reference: 'Reference',
period: 'Period',
replaces: 'Replaces',
billTo: 'Bill to',
description: 'Description',
qty: 'Qty',
unit: 'Unit',
price: 'Price',
amount: 'Amount',
subtotal: 'Subtotal',
discount: 'Discount',
tax: 'Tax',
total: 'Total',
paymentDetails: 'Payment details',
bankName: 'Bank',
accountNumber: 'Account',
iban: 'IBAN',
swift: 'SWIFT / BIC',
correspondentAccount: 'Correspondent account',
service: 'service',
hours: 'h',
pcs: 'pcs',
},
ru: {
title: 'СЧЁТ',
void: 'АННУЛИРОВАН',
issueDate: 'Дата выставления',
dueDate: 'Срок оплаты',
reference: 'Основание',
period: 'Период',
replaces: 'Взамен',
billTo: 'Плательщик',
description: 'Описание',
qty: 'Кол-во',
unit: 'Ед.',
price: 'Цена',
amount: 'Сумма',
subtotal: 'Промежуточная сумма',
discount: 'Скидка',
tax: 'Налог',
total: 'Итого',
paymentDetails: 'Реквизиты для оплаты',
bankName: 'Банк',
accountNumber: 'Расчётный счёт',
iban: 'IBAN',
swift: 'SWIFT / БИК',
correspondentAccount: 'Корр. счёт',
service: 'усл.',
hours: 'ч',
pcs: 'шт.',
},
}
const FONT_FILES = ['Roboto-Regular.ttf', 'Roboto-Medium.ttf', 'Roboto-Italic.ttf', 'Roboto-MediumItalic.ttf']
const MUTED = '#6b7280'
const RULE = '#d4d4d8'
export class InvoicePdfRenderer {
private static fontsReady = false
private readonly pdfmake = pdfmakeModule as unknown as PdfmakeServer
constructor() {
if (!InvoicePdfRenderer.fontsReady) {
for (const name of FONT_FILES) this.pdfmake.virtualfs.writeFileSync(name, Buffer.from(vfs[name], 'base64'))
this.pdfmake.setFonts({
Roboto: {
normal: 'Roboto-Regular.ttf',
bold: 'Roboto-Medium.ttf',
italics: 'Roboto-Italic.ttf',
bolditalics: 'Roboto-MediumItalic.ttf',
},
})
InvoicePdfRenderer.fontsReady = true
}
}
render(invoice: InvoiceForClient, lang: InvoicePdfLang): Promise<Buffer> {
return this.pdfmake.createPdf(this.buildDocument(invoice, lang)).getBuffer()
}
private buildDocument(invoice: InvoiceForClient, lang: InvoicePdfLang) {
const t = LABELS[lang]
const money = (amount: number) => this.formatMoney(amount, invoice.currencyCode, lang)
const date = (value: string | null) => this.formatDate(value, lang)
const metaRows: [string, string][] = [[t.issueDate, date(invoice.issueDate)]]
if (invoice.dueDate) metaRows.push([t.dueDate, date(invoice.dueDate)])
if (invoice.reference) metaRows.push([t.reference, invoice.reference])
if (invoice.periodFrom || invoice.periodTo) metaRows.push([t.period, `${date(invoice.periodFrom)} ${date(invoice.periodTo)}`])
const lineRows = invoice.lines.map((line) => [
{ text: line.description },
{ text: String(line.quantity), alignment: 'right' },
{ text: t[line.unit] ?? line.unit, color: MUTED },
{ text: money(line.unitPrice), alignment: 'right' },
{ text: money(invoiceLineAmount(line.quantity, line.unitPrice)), alignment: 'right' },
])
const totalsRows: unknown[] = [[{ text: t.subtotal, color: MUTED }, { text: money(invoice.totals.subtotal), alignment: 'right' }]]
if (invoice.totals.discount > 0) totalsRows.push([{ text: t.discount, color: MUTED }, { text: `${money(invoice.totals.discount)}`, alignment: 'right' }])
if (!invoice.taxExempt) totalsRows.push([{ text: `${t.tax} ${invoice.taxRate}%`, color: MUTED }, { text: money(invoice.totals.tax), alignment: 'right' }])
totalsRows.push([{ text: t.total, bold: true, fontSize: 12 }, { text: money(invoice.totals.total), bold: true, fontSize: 12, alignment: 'right' }])
const bank = invoice.seller.bank
const bankRows = (
[
[t.bankName, bank.bankName],
[t.accountNumber, bank.accountNumber],
[t.iban, bank.iban],
[t.swift, bank.swift],
[t.correspondentAccount, bank.correspondentAccount],
] as [string, string][]
).filter(([, value]) => value.trim().length > 0)
const content: unknown[] = [
{
columns: [
{
width: '*',
stack: [
{ text: invoice.seller.legalName || invoice.seller.name, fontSize: 14, bold: true },
...this.partyLines(invoice.seller),
],
},
{
width: 'auto',
stack: [
{ text: t.title, fontSize: 20, bold: true, alignment: 'right' },
{ text: invoice.number, alignment: 'right', margin: [0, 2, 0, 8] },
{
table: { body: metaRows.map(([label, value]) => [{ text: label, color: MUTED }, { text: value, alignment: 'right' }]) },
layout: 'noBorders',
fontSize: 9,
},
],
},
],
columnGap: 24,
},
{ canvas: [{ type: 'line', x1: 0, y1: 0, x2: 515, y2: 0, lineWidth: 0.5, lineColor: RULE }], margin: [0, 12, 0, 12] },
{ text: t.billTo.toUpperCase(), fontSize: 8, bold: true, color: MUTED },
{ text: invoice.counterparty.legalName || invoice.counterparty.name, bold: true, margin: [0, 2, 0, 0] },
...(invoice.counterparty.contactPerson ? [{ text: invoice.counterparty.contactPerson }] : []),
...this.partyLines(invoice.counterparty),
{
table: {
headerRows: 1,
widths: ['*', 40, 44, 80, 90],
body: [
[
{ text: t.description.toUpperCase(), style: 'th' },
{ text: t.qty.toUpperCase(), style: 'th', alignment: 'right' },
{ text: t.unit.toUpperCase(), style: 'th' },
{ text: t.price.toUpperCase(), style: 'th', alignment: 'right' },
{ text: t.amount.toUpperCase(), style: 'th', alignment: 'right' },
],
...lineRows,
],
},
layout: {
hLineWidth: (index: number, node: { table: { body: unknown[] } }) => (index === 0 || index === node.table.body.length ? 0 : 0.5),
vLineWidth: () => 0,
hLineColor: () => RULE,
paddingTop: () => 6,
paddingBottom: () => 6,
},
margin: [0, 18, 0, 8],
},
{
columns: [
{ width: '*', text: '' },
{ width: 220, table: { widths: ['*', 'auto'], body: totalsRows }, layout: 'noBorders' },
],
},
]
if (bankRows.length > 0) {
content.push(
{ text: t.paymentDetails.toUpperCase(), fontSize: 8, bold: true, color: MUTED, margin: [0, 20, 0, 4] },
{ table: { body: bankRows.map(([label, value]) => [{ text: label, color: MUTED }, { text: value }]) }, layout: 'noBorders' },
)
}
if (invoice.taxExempt && invoice.taxNote) content.push({ text: invoice.taxNote, margin: [0, 16, 0, 0], color: '#374151' })
if (invoice.terms) content.push({ text: invoice.terms, margin: [0, 8, 0, 0], color: '#374151' })
if (invoice.notes) content.push({ text: invoice.notes, margin: [0, 8, 0, 0], color: '#374151' })
return {
pageSize: 'A4',
pageMargins: [40, 40, 40, 40],
info: { title: invoice.number },
...(invoice.status === 'void' ? { watermark: { text: t.void, color: '#ef4444', opacity: 0.12, bold: true } } : {}),
defaultStyle: { font: 'Roboto', fontSize: 10, color: '#18181b' },
styles: { th: { fontSize: 8, bold: true, color: MUTED } },
content,
}
}
private partyLines(party: InvoiceForClient['seller'] | InvoiceForClient['counterparty']) {
const lines: unknown[] = []
if (party.address) lines.push({ text: party.address, color: MUTED, fontSize: 9 })
for (const item of party.requisites) {
if (item.label.trim() && item.value.trim()) lines.push({ text: `${item.label}: ${item.value}`, color: MUTED, fontSize: 9 })
}
const contacts = [party.email, party.phone].filter(Boolean).join(' · ')
if (contacts) lines.push({ text: contacts, color: MUTED, fontSize: 9 })
return lines
}
private formatMoney(amount: number, currencyCode: string, lang: InvoicePdfLang): string {
try {
return new Intl.NumberFormat(lang === 'ru' ? 'ru-RU' : 'en-US', { style: 'currency', currency: currencyCode }).format(amount)
} catch {
return `${amount.toFixed(2)} ${currencyCode}`
}
}
private formatDate(value: string | null, lang: InvoicePdfLang): string {
if (!value) return ''
const [year, month, day] = value.split('-')
return lang === 'ru' ? `${day}.${month}.${year}` : `${day}.${month}.${year}`
}
}
@@ -1,103 +0,0 @@
import type { Request, Response } from 'express'
import { ArkErrors } from 'arktype'
import { InvoicesManager } from './InvoicesManager'
import {
InvoiceArkTypeCreate,
InvoiceArkTypeId,
InvoiceArkTypeList,
InvoiceArkTypePdf,
InvoiceArkTypeStatus,
InvoiceArkTypeUpdate,
type InvoiceDeleteResult,
type InvoiceTransitionResult,
type InvoiceWriteError,
} from './types'
const WRITE_ERROR_STATUS: Record<InvoiceWriteError, number> = {
seller_not_found: 422,
counterparty_not_found: 422,
goal_not_found: 422,
duplicate_number: 409,
not_found: 404,
not_draft: 409,
}
const TRANSITION_ERROR_STATUS = { not_found: 404, invalid_transition: 409, missing_requisites: 422 } as const
const DELETE_STATUS: Record<InvoiceDeleteResult, number> = { deleted: 200, not_draft: 409, not_found: 404 }
export class InvoicesController {
private readonly manager = new InvoicesManager()
fetch = async (req: Request, res: Response) => {
const data = InvoiceArkTypeList(req.query)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
return res.tvJson(await this.manager.fetchList(data))
}
getById = async (req: Request, res: Response) => {
const id = InvoiceArkTypeId(req.params)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
const result = await this.manager.fetchById(id.id)
if (!result) return res.status(404).end()
return res.tvJson(result)
}
create = async (req: Request, res: Response) => {
const data = InvoiceArkTypeCreate(req.body)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.create({ data, createdBy: req.appUser.getUserData()?.id ?? null })
if ('error' in result) return res.status(WRITE_ERROR_STATUS[result.error]).send(result.error)
return res.tvJson(result.invoice)
}
update = async (req: Request, res: Response) => {
const id = InvoiceArkTypeId(req.params)
const data = InvoiceArkTypeUpdate(req.body)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.update({ invoiceId: id.id, data })
if ('error' in result) return res.status(WRITE_ERROR_STATUS[result.error]).send(result.error)
return res.tvJson(result.invoice)
}
setStatus = async (req: Request, res: Response) => {
const id = InvoiceArkTypeId(req.params)
const data = InvoiceArkTypeStatus(req.body)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
return this.sendTransition(res, await this.manager.transition({ invoiceId: id.id, status: data.status }))
}
reissue = async (req: Request, res: Response) => {
const id = InvoiceArkTypeId(req.params)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
const result = await this.manager.reissue({ invoiceId: id.id, createdBy: req.appUser.getUserData()?.id ?? null })
return this.sendTransition(res, result)
}
pdf = async (req: Request, res: Response) => {
const data = InvoiceArkTypePdf({ ...req.params, ...req.query })
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const invoice = await this.manager.fetchById(data.id)
if (!invoice) return res.status(404).end()
const buffer = await this.manager.renderPdf({ invoiceId: data.id, lang: data.lang ?? 'en' })
if (!buffer) return res.status(404).end()
res.setHeader('Content-Type', 'application/pdf')
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(invoice.number)}.pdf"`)
return res.send(buffer)
}
private sendTransition(res: Response, result: InvoiceTransitionResult) {
if ('error' in result) return res.status(TRANSITION_ERROR_STATUS[result.error]).json(result)
return res.tvJson(result.invoice)
}
delete = async (req: Request, res: Response) => {
const id = InvoiceArkTypeId(req.params)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
const result = await this.manager.delete(id.id)
if (result !== 'deleted') return res.status(DELETE_STATUS[result]).end()
return res.tvJson(true)
}
}
@@ -1,255 +0,0 @@
import type {
CounterpartiesSchemaTypeForSelect,
InvoiceCounterpartySnapshot,
InvoiceSellerSnapshot,
InvoiceStatus,
SellersSchemaTypeForSelect,
} from 'taskview-db-schemas'
import { BillingRepository } from '../billing/BillingRepository'
import { computeInvoiceTotals } from '../../utils/invoiceTotals'
import { InvoicePdfRenderer } from './InvoicePdfRenderer'
import { InvoicesRepository } from './InvoicesRepository'
import {
INVOICE_TEMPLATE_VERSION,
INVOICE_TRANSITIONS,
type InvoiceArgList,
type InvoiceArgUpdate,
type InvoiceCreateArgs,
type InvoiceDeleteResult,
type InvoiceForClient,
type InvoiceMissingRequisite,
type InvoiceReissueArgs,
type InvoiceRenderPdfArgs,
type InvoiceSetStatusArgs,
type InvoiceSnapshots,
type InvoiceStatusPatch,
type InvoiceTransitionResult,
type InvoiceUpdateArgs,
type InvoiceWithLines,
type InvoiceWriteError,
type InvoiceWriteResult,
} from './types'
export class InvoicesManager {
public readonly repository: InvoicesRepository
private readonly billing: BillingRepository
private readonly pdf: InvoicePdfRenderer
constructor() {
this.repository = new InvoicesRepository()
this.billing = new BillingRepository()
this.pdf = new InvoicePdfRenderer()
}
async fetchList(args: InvoiceArgList): Promise<InvoiceForClient[]> {
return (await this.repository.fetchList(args)).map((invoice) => this.toClient(invoice))
}
async fetchById(invoiceId: number): Promise<InvoiceForClient | null> {
const invoice = await this.repository.fetchById(invoiceId)
return invoice ? this.toClient(invoice) : null
}
async create({ data, createdBy }: InvoiceCreateArgs): Promise<InvoiceWriteResult> {
const snapshots = await this.resolveSnapshots(data.organizationId, data)
if (typeof snapshots === 'string') return { error: snapshots }
const created = await this.repository.create({ data, createdBy, ...snapshots })
return this.toWriteResult(created)
}
async update({ invoiceId, data }: InvoiceUpdateArgs): Promise<InvoiceWriteResult> {
const existing = await this.repository.fetchById(invoiceId)
if (!existing) return { error: 'not_found' }
if (existing.status !== 'draft') return { error: 'not_draft' }
const snapshots = await this.resolveSnapshots(existing.organizationId, data)
if (typeof snapshots === 'string') return { error: snapshots }
const updated = await this.repository.update({ invoiceId, data, ...snapshots })
return this.toWriteResult(updated)
}
async transition({ invoiceId, status }: InvoiceSetStatusArgs): Promise<InvoiceTransitionResult> {
const existing = await this.repository.fetchById(invoiceId)
if (!existing) return { error: 'not_found' }
if (!INVOICE_TRANSITIONS[existing.status].includes(status)) {
return { error: 'invalid_transition', from: existing.status, to: status }
}
const patch: InvoiceStatusPatch = { status }
if (status === 'issued' && existing.status === 'draft') {
const missing = this.missingRequisites(existing)
if (missing.length > 0) return { error: 'missing_requisites', missing }
const totals = computeInvoiceTotals(existing)
patch.issuedAt = new Date()
patch.templateVersion = INVOICE_TEMPLATE_VERSION
patch.subtotal = String(totals.subtotal)
patch.discountAmount = String(totals.discount)
patch.taxAmount = String(totals.tax)
patch.total = String(totals.total)
}
if (status === 'issued' && existing.status === 'paid') patch.paidAt = null
if (status === 'paid') patch.paidAt = new Date()
if (status === 'void') patch.voidedAt = new Date()
const updated = await this.repository.applyStatus({ invoiceId, patch })
return updated ? { invoice: this.toClient(updated) } : { error: 'not_found' }
}
async reissue({ invoiceId, createdBy }: InvoiceReissueArgs): Promise<InvoiceTransitionResult> {
const original = await this.repository.fetchById(invoiceId)
if (!original) return { error: 'not_found' }
if (original.status === 'issued') {
const voided = await this.transition({ invoiceId, status: 'void' })
if ('error' in voided) return voided
} else if (original.status !== 'void') {
return { error: 'invalid_transition', from: original.status, to: 'void' }
}
const number = await this.repository.nextNumber({ organizationId: original.organizationId, base: original.number })
const created = await this.repository.create({
createdBy,
sellerSnapshot: original.sellerSnapshot,
counterpartySnapshot: original.counterpartySnapshot,
goalName: original.goalName,
data: {
organizationId: original.organizationId,
goalId: original.goalId,
sellerId: original.sellerId,
counterpartyId: original.counterpartyId,
number,
reference: original.reference,
currencyCode: original.currencyCode,
issueDate: new Date().toISOString().slice(0, 10),
paymentTerms: original.paymentTerms,
dueDate: original.dueDate,
periodFrom: original.periodFrom,
periodTo: original.periodTo,
discountType: original.discountType,
discountValue: Number(original.discountValue),
taxRate: Number(original.taxRate),
taxExempt: original.taxExempt,
taxNote: original.taxNote,
notes: original.notes,
terms: original.terms,
lines: original.lines.map((line) => ({
taskId: line.taskId,
description: line.description,
unit: line.unit,
quantity: Number(line.quantity),
unitPrice: Number(line.unitPrice),
})),
},
})
if (!created || created === 'duplicate_number') return { error: 'not_found' }
await this.repository.setReplaces({ invoiceId: created.id, replacesInvoiceId: original.id })
return { invoice: this.toClient({ ...created, replacesInvoiceId: original.id }) }
}
async delete(invoiceId: number): Promise<InvoiceDeleteResult> {
const existing = await this.repository.fetchById(invoiceId)
if (!existing) return 'not_found'
if (existing.status !== 'draft') return 'not_draft'
return (await this.repository.delete(invoiceId)) ? 'deleted' : 'not_found'
}
async renderPdf({ invoiceId, lang }: InvoiceRenderPdfArgs): Promise<Buffer | null> {
const invoice = await this.fetchById(invoiceId)
if (!invoice) return null
return this.pdf.render(invoice, lang)
}
private missingRequisites(invoice: InvoiceWithLines): InvoiceMissingRequisite[] {
const seller = invoice.sellerSnapshot
const counterparty = invoice.counterpartySnapshot
const missing: InvoiceMissingRequisite[] = []
if (!seller.name.trim() && !seller.legalName.trim()) missing.push('seller.name')
if (!seller.address.trim()) missing.push('seller.address')
if (!seller.bank.accountNumber.trim() && !seller.bank.iban.trim()) missing.push('seller.bank')
if (!counterparty.name.trim() && !counterparty.legalName.trim()) missing.push('counterparty.name')
if (!counterparty.address.trim()) missing.push('counterparty.address')
if (invoice.lines.length === 0) missing.push('lines')
return missing
}
private async resolveSnapshots(organizationId: number, data: InvoiceArgUpdate): Promise<InvoiceSnapshots | InvoiceWriteError> {
const seller = await this.billing.fetchSellerById(data.sellerId)
if (!seller || seller.organizationId !== organizationId) return 'seller_not_found'
const counterparty = await this.billing.fetchCounterpartyById(data.counterpartyId)
if (!counterparty || counterparty.organizationId !== organizationId) return 'counterparty_not_found'
let goalName = ''
if (data.goalId !== null) {
const goal = await this.repository.fetchGoal(data.goalId)
if (!goal || goal.organizationId !== organizationId) return 'goal_not_found'
goalName = goal.name ?? ''
}
return {
sellerSnapshot: this.snapshotSeller(seller),
counterpartySnapshot: this.snapshotCounterparty(counterparty),
goalName,
}
}
private snapshotSeller(seller: SellersSchemaTypeForSelect): InvoiceSellerSnapshot {
return {
name: seller.name,
legalName: seller.legalName,
address: seller.address,
email: seller.email,
phone: seller.phone,
logoUrl: seller.logoUrl,
bank: seller.bank,
requisites: seller.requisites,
}
}
private snapshotCounterparty(counterparty: CounterpartiesSchemaTypeForSelect): InvoiceCounterpartySnapshot {
return {
kind: counterparty.kind,
name: counterparty.name,
legalName: counterparty.legalName,
address: counterparty.address,
email: counterparty.email,
phone: counterparty.phone,
contactPerson: counterparty.contactPerson,
requisites: counterparty.requisites,
}
}
private toWriteResult(result: InvoiceWithLines | 'duplicate_number' | null): InvoiceWriteResult {
if (result === 'duplicate_number') return { error: 'duplicate_number' }
if (!result) return { error: 'not_found' }
return { invoice: this.toClient(result) }
}
private toClient(invoice: InvoiceWithLines): InvoiceForClient {
const { sellerSnapshot, counterpartySnapshot, lines, subtotal, discountAmount, taxAmount, total, ...rest } = invoice
const totalsFrozen = total !== null
const totals = totalsFrozen
? {
subtotal: Number(subtotal),
discount: Number(discountAmount),
taxable: Number(subtotal) - Number(discountAmount),
tax: Number(taxAmount),
total: Number(total),
}
: computeInvoiceTotals(invoice)
return {
...rest,
discountValue: Number(invoice.discountValue),
taxRate: Number(invoice.taxRate),
seller: sellerSnapshot,
counterparty: counterpartySnapshot,
totals,
totalsFrozen,
lines: lines.map((line) => ({
id: line.id,
taskId: line.taskId,
description: line.description,
unit: line.unit,
quantity: Number(line.quantity),
unitPrice: Number(line.unitPrice),
})),
}
}
}
export type { InvoiceStatus }
@@ -1,228 +0,0 @@
import { and, asc, desc, eq, inArray } from 'drizzle-orm'
import {
CounterpartiesSchema,
GoalsSchema,
InvoiceLinesSchema,
InvoicesSchema,
type InvoiceLinesSchemaTypeForSelect,
type InvoicesSchemaTypeForSelect,
} from 'taskview-db-schemas'
import { Database } from '../../modules/db'
import { callWithCatch } from '../../utils/helpers'
import type {
InvoiceArgList,
InvoiceArgUpdate,
InvoiceCreateRepoArgs,
InvoiceLineArg,
InvoiceNextNumberArgs,
InvoiceSetReplacesArgs,
InvoiceStatusRepoArgs,
InvoiceUpdateRepoArgs,
InvoiceWithLines,
} from './types'
const PG_UNIQUE_VIOLATION = '23505'
export class InvoicesRepository {
private readonly db: Database
constructor() {
this.db = Database.getInstance()
}
async fetchList({ organizationId, includeArchived }: InvoiceArgList): Promise<InvoiceWithLines[]> {
const conditions = [eq(InvoicesSchema.organizationId, organizationId)]
if (!includeArchived) conditions.push(eq(CounterpartiesSchema.archived, false))
const rows = await callWithCatch(() =>
this.db.dbDrizzle
.select({ invoice: InvoicesSchema })
.from(InvoicesSchema)
.innerJoin(CounterpartiesSchema, eq(CounterpartiesSchema.id, InvoicesSchema.counterpartyId))
.where(and(...conditions))
.orderBy(desc(InvoicesSchema.issueDate), desc(InvoicesSchema.id)),
)
const invoices = (rows ?? []).map((row) => row.invoice)
return this.attachLines(invoices)
}
async fetchById(invoiceId: number): Promise<InvoiceWithLines | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(InvoicesSchema).where(eq(InvoicesSchema.id, invoiceId)),
)
const invoice = result?.[0]
if (!invoice) return null
return (await this.attachLines([invoice]))[0]
}
async fetchOrganizationId(invoiceId: number): Promise<number | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({ organizationId: InvoicesSchema.organizationId })
.from(InvoicesSchema)
.where(eq(InvoicesSchema.id, invoiceId)),
)
return result?.[0]?.organizationId ?? null
}
async fetchGoal(goalId: number): Promise<{ organizationId: number | null; name: string | null } | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({ organizationId: GoalsSchema.organizationId, name: GoalsSchema.name })
.from(GoalsSchema)
.where(eq(GoalsSchema.id, goalId)),
)
return result?.[0] ?? null
}
private invoiceColumns(data: InvoiceArgUpdate) {
return {
goalId: data.goalId,
sellerId: data.sellerId,
counterpartyId: data.counterpartyId,
number: data.number,
reference: data.reference,
currencyCode: data.currencyCode,
issueDate: data.issueDate,
paymentTerms: data.paymentTerms,
dueDate: data.dueDate,
periodFrom: data.periodFrom,
periodTo: data.periodTo,
discountType: data.discountType,
discountValue: String(data.discountValue),
taxRate: String(data.taxRate),
taxExempt: data.taxExempt,
taxNote: data.taxNote,
notes: data.notes,
terms: data.terms,
}
}
async create({ data, createdBy, sellerSnapshot, counterpartySnapshot, goalName }: InvoiceCreateRepoArgs): Promise<InvoiceWithLines | 'duplicate_number' | null> {
try {
return await this.db.dbDrizzle.transaction(async (tx) => {
const inserted = await tx
.insert(InvoicesSchema)
.values({
organizationId: data.organizationId,
...this.invoiceColumns(data),
goalName,
sellerSnapshot,
counterpartySnapshot,
createdBy,
})
.returning()
const created = inserted[0]
const insertedLines = await tx.insert(InvoiceLinesSchema).values(this.toLineRows(created.id, data.lines)).returning()
return { ...created, lines: insertedLines }
})
} catch (error) {
if (this.isUniqueViolation(error)) return 'duplicate_number'
return null
}
}
async update({ invoiceId, data, sellerSnapshot, counterpartySnapshot, goalName }: InvoiceUpdateRepoArgs): Promise<InvoiceWithLines | 'duplicate_number' | null> {
try {
return await this.db.dbDrizzle.transaction(async (tx) => {
const updated = await tx
.update(InvoicesSchema)
.set({
...this.invoiceColumns(data),
goalName,
sellerSnapshot,
counterpartySnapshot,
updatedAt: new Date(),
})
.where(eq(InvoicesSchema.id, invoiceId))
.returning()
const current = updated[0]
if (!current) return null
await tx.delete(InvoiceLinesSchema).where(eq(InvoiceLinesSchema.invoiceId, invoiceId))
const insertedLines = await tx.insert(InvoiceLinesSchema).values(this.toLineRows(invoiceId, data.lines)).returning()
return { ...current, lines: insertedLines }
})
} catch (error) {
if (this.isUniqueViolation(error)) return 'duplicate_number'
return null
}
}
async applyStatus({ invoiceId, patch }: InvoiceStatusRepoArgs): Promise<InvoiceWithLines | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.update(InvoicesSchema)
.set({ ...patch, updatedAt: new Date() })
.where(eq(InvoicesSchema.id, invoiceId))
.returning(),
)
const invoice = result?.[0]
if (!invoice) return null
return (await this.attachLines([invoice]))[0]
}
async setReplaces({ invoiceId, replacesInvoiceId }: InvoiceSetReplacesArgs): Promise<void> {
await callWithCatch(() =>
this.db.dbDrizzle.update(InvoicesSchema).set({ replacesInvoiceId }).where(eq(InvoicesSchema.id, invoiceId)),
)
}
async nextNumber({ organizationId, base }: InvoiceNextNumberArgs): Promise<string> {
const match = base.match(/^(.*?)(\d+)$/)
if (!match) return `${base}-1`
const [, prefix, digits] = match
const rows = await callWithCatch(() =>
this.db.dbDrizzle
.select({ number: InvoicesSchema.number })
.from(InvoicesSchema)
.where(eq(InvoicesSchema.organizationId, organizationId)),
)
let max = Number(digits)
for (const row of rows ?? []) {
if (!row.number.startsWith(prefix)) continue
const tail = row.number.slice(prefix.length)
if (/^\d+$/.test(tail)) max = Math.max(max, Number(tail))
}
return `${prefix}${String(max + 1).padStart(digits.length, '0')}`
}
async delete(invoiceId: number): Promise<boolean> {
const result = await callWithCatch(() => this.db.dbDrizzle.delete(InvoicesSchema).where(eq(InvoicesSchema.id, invoiceId)))
return !!result?.rowCount
}
private toLineRows(invoiceId: number, lines: InvoiceLineArg[]) {
return lines.map((line, index) => ({
invoiceId,
position: index,
taskId: line.taskId,
description: line.description,
unit: line.unit,
quantity: String(line.quantity),
unitPrice: String(line.unitPrice),
}))
}
private async attachLines(invoices: InvoicesSchemaTypeForSelect[]): Promise<InvoiceWithLines[]> {
if (invoices.length === 0) return []
const lines = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(InvoiceLinesSchema)
.where(inArray(InvoiceLinesSchema.invoiceId, invoices.map((invoice) => invoice.id)))
.orderBy(asc(InvoiceLinesSchema.invoiceId), asc(InvoiceLinesSchema.position)),
)
const byInvoice = new Map<number, InvoiceLinesSchemaTypeForSelect[]>()
for (const line of lines ?? []) {
const list = byInvoice.get(line.invoiceId) ?? []
list.push(line)
byInvoice.set(line.invoiceId, list)
}
return invoices.map((invoice) => ({ ...invoice, lines: byInvoice.get(invoice.id) ?? [] }))
}
private isUniqueViolation(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false
const { code, cause } = error as { code?: string; cause?: { code?: string } }
return code === PG_UNIQUE_VIOLATION || cause?.code === PG_UNIQUE_VIOLATION
}
}
@@ -1,36 +0,0 @@
import { Router } from 'express'
import type { Routable } from '../../types/routable.type'
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
import { IsOrgAdmin } from '../organizations/middlewares/IsOrgAdmin'
import { RequireTokenPermission } from '../../middlewares/require-token-permission'
import { GoalPermissions } from '../../types/auth.types'
import { InvoicesController } from './InvoicesController'
import { isOrgAdminForInvoice } from './middlewares/is-org-admin-for-invoice'
export default class InvoicesRoutes implements Routable {
private readonly router: ReturnType<typeof Router>
private readonly controller: InvoicesController
constructor() {
this.router = Router()
this.controller = new InvoicesController()
this.initRoutes()
}
getRouter() {
return this.router
}
private initRoutes() {
const canManage = RequireTokenPermission(GoalPermissions.BILLING_CAN_MANAGE)
this.router.get('', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.fetch)
this.router.post('', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.create)
this.router.get('/:id', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.getById)
this.router.patch('/:id', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.update)
this.router.patch('/:id/status', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.setStatus)
this.router.post('/:id/reissue', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.reissue)
this.router.get('/:id/pdf', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.pdf)
this.router.delete('/:id', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.delete)
}
}
@@ -1,260 +0,0 @@
import axios from 'axios'
import type http from 'http'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import App from '../../../App'
const port = 1811
const api = axios.create({ baseURL: `http://localhost:${port}`, validateStatus: () => true })
const LOGIN = 'test@mail.dest'
const PASSWORD = 'user1!#Q'
let server: http.Server
let jwt = ''
let organizationId = 0
let goalId = 0
let sellerId = 0
let counterpartyId = 0
let invoiceId = 0
const auth = () => ({ headers: { Authorization: `Bearer ${jwt}` } })
const sellerPayload = () => ({
organizationId,
name: 'IT Seller',
legalName: 'IT Seller LLC',
address: 'Somewhere 1',
email: 'billing@seller.test',
phone: '',
logoUrl: '',
currencyCode: 'EUR',
bank: { bankName: 'Bank', accountNumber: '123', iban: 'DE00', swift: 'XXX', correspondentAccount: '' },
requisites: [{ key: 'vat', label: 'VAT ID', value: 'DE1' }],
defaultTerms: 'Net 14',
taxNote: '',
})
const counterpartyPayload = () => ({
organizationId,
kind: 'organization',
name: 'IT Client',
legalName: 'IT Client GmbH',
address: '',
email: 'ap@client.test',
phone: '',
contactPerson: 'Anna',
requisites: [],
})
const invoicePayload = (number: string) => ({
organizationId,
goalId,
sellerId,
counterpartyId,
number,
reference: 'PO-1',
currencyCode: 'EUR',
issueDate: '2026-09-01',
paymentTerms: 'net14',
dueDate: '2026-09-15',
periodFrom: null,
periodTo: null,
discountType: 'percent',
discountValue: 10,
taxRate: 19,
taxExempt: false,
taxNote: '',
notes: '',
terms: 'Net 14',
lines: [
{ taskId: null, description: 'Design', unit: 'service', quantity: 1, unitPrice: 1000 },
{ taskId: null, description: 'Dev', unit: 'hours', quantity: 10.5, unitPrice: 80 },
],
})
describe('billing and invoices', () => {
vi.mock('emailjs', () => ({
SMTPClient: vi.fn().mockImplementation(() => ({ sendAsync: vi.fn().mockResolvedValue(true) })),
}))
beforeAll(async () => {
server = new App(port).listen()
const login = await api.post('/module/auth/login', { login: LOGIN, password: PASSWORD })
expect(login.status).toBe(200)
jwt = login.data.access
const org = await api.post('/module/organizations', { name: `billing-it-${Date.now()}` }, auth())
expect(org.status).toBe(200)
organizationId = org.data.response.id
const goal = await api.post('/module/goals', { name: 'billing-it-goal', organizationId }, auth())
expect(goal.status).toBe(200)
goalId = goal.data.response.id ?? goal.data.response.goal?.id
})
afterAll(async () => {
if (organizationId) await api.delete(`/module/organizations/${organizationId}`, auth())
server?.close()
})
it('lists the seeded currencies', async () => {
const response = await api.get('/module/billing/currencies', auth())
expect(response.status).toBe(200)
expect(response.data.response.length).toBeGreaterThanOrEqual(20)
expect(response.data.response.find((c: { code: string }) => c.code === 'JPY').decimalDigits).toBe(0)
})
it('creates a seller and a counterparty for the organization', async () => {
const seller = await api.post('/module/billing/sellers', sellerPayload(), auth())
expect(seller.status).toBe(200)
sellerId = seller.data.response.id
expect(seller.data.response.bank.iban).toBe('DE00')
const counterparty = await api.post('/module/billing/counterparties', counterpartyPayload(), auth())
expect(counterparty.status).toBe(200)
counterpartyId = counterparty.data.response.id
const list = await api.get('/module/billing/counterparties', { params: { organizationId }, ...auth() })
expect(list.data.response.map((c: { id: number }) => c.id)).toContain(counterpartyId)
})
it('rejects an invalid payload', async () => {
const response = await api.post('/module/billing/sellers', { ...sellerPayload(), currencyCode: 'euro' }, auth())
expect(response.status).toBe(400)
})
it('creates an invoice with snapshots, lines and the project name', async () => {
const response = await api.post('/module/invoices', invoicePayload('INV-IT-1'), auth())
expect(response.status).toBe(200)
const invoice = response.data.response
invoiceId = invoice.id
expect(invoice.status).toBe('draft')
expect(invoice.goalName).toBe('billing-it-goal')
expect(invoice.seller.legalName).toBe('IT Seller LLC')
expect(invoice.counterparty.contactPerson).toBe('Anna')
expect(invoice.lines).toHaveLength(2)
expect(invoice.lines[1].quantity).toBe(10.5)
expect(invoice.discountValue).toBe(10)
expect(typeof invoice.taxRate).toBe('number')
})
it('refuses a duplicate number inside the organization', async () => {
const response = await api.post('/module/invoices', invoicePayload('INV-IT-1'), auth())
expect(response.status).toBe(409)
})
it('refuses a seller from another organization', async () => {
const response = await api.post('/module/invoices', { ...invoicePayload('INV-IT-2'), sellerId: 999999 }, auth())
expect(response.status).toBe(422)
})
it('keeps the seller snapshot when the seller is edited', async () => {
const update = await api.patch(`/module/billing/sellers/${sellerId}`, { ...sellerPayload(), organizationId: undefined, legalName: 'Renamed LLC' }, auth())
expect(update.status).toBe(200)
const invoice = await api.get(`/module/invoices/${invoiceId}`, auth())
expect(invoice.data.response.seller.legalName).toBe('IT Seller LLC')
})
it('replaces lines on update', async () => {
const data = { ...invoicePayload('INV-IT-1'), organizationId: undefined, lines: [{ taskId: null, description: 'Only', unit: 'pcs', quantity: 2, unitPrice: 5 }] }
const response = await api.patch(`/module/invoices/${invoiceId}`, data, auth())
expect(response.status).toBe(200)
expect(response.data.response.lines).toHaveLength(1)
expect(response.data.response.lines[0].description).toBe('Only')
})
it('refuses to delete a seller that has invoices', async () => {
const response = await api.delete(`/module/billing/sellers/${sellerId}`, auth())
expect(response.status).toBe(409)
})
it('hides invoices of an archived client unless asked for', async () => {
const archive = await api.patch(`/module/billing/counterparties/${counterpartyId}/archive`, { archived: true }, auth())
expect(archive.status).toBe(200)
expect(archive.data.response.archived).toBe(true)
const hidden = await api.get('/module/invoices', { params: { organizationId }, ...auth() })
expect(hidden.data.response.map((i: { id: number }) => i.id)).not.toContain(invoiceId)
const shown = await api.get('/module/invoices', { params: { organizationId, includeArchived: true }, ...auth() })
expect(shown.data.response.map((i: { id: number }) => i.id)).toContain(invoiceId)
await api.patch(`/module/billing/counterparties/${counterpartyId}/archive`, { archived: false }, auth())
})
it('refuses to pay a draft and reports what is missing before issuing', async () => {
const paid = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'paid' }, auth())
expect(paid.status).toBe(409)
expect(paid.data.error).toBe('invalid_transition')
const issued = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'issued' }, auth())
expect(issued.status).toBe(422)
expect(issued.data.missing).toContain('counterparty.address')
})
it('issues the invoice, freezes the totals and locks editing', async () => {
const fixed = await api.patch(`/module/billing/counterparties/${counterpartyId}`, { ...counterpartyPayload(), organizationId: undefined, address: 'Client street 1' }, auth())
expect(fixed.status).toBe(200)
const resnap = await api.patch(`/module/invoices/${invoiceId}`, { ...invoicePayload('INV-IT-1'), organizationId: undefined }, auth())
expect(resnap.status).toBe(200)
const issued = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'issued' }, auth())
expect(issued.status).toBe(200)
expect(issued.data.response.status).toBe('issued')
expect(issued.data.response.issuedAt).toBeTruthy()
expect(issued.data.response.totalsFrozen).toBe(true)
expect(issued.data.response.totals.total).toBe(1970.64)
const edit = await api.patch(`/module/invoices/${invoiceId}`, { ...invoicePayload('INV-IT-1'), organizationId: undefined }, auth())
expect(edit.status).toBe(409)
const del = await api.delete(`/module/invoices/${invoiceId}`, auth())
expect(del.status).toBe(409)
const back = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'draft' }, auth())
expect(back.status).toBe(409)
})
it('marks paid, unmarks and refuses to void a paid invoice', async () => {
const paid = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'paid' }, auth())
expect(paid.data.response.paidAt).toBeTruthy()
const voided = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'void' }, auth())
expect(voided.status).toBe(409)
const unpaid = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'issued' }, auth())
expect(unpaid.data.response.paidAt).toBeNull()
})
it('renders the invoice as a PDF', async () => {
const response = await api.get(`/module/invoices/${invoiceId}/pdf`, { ...auth(), params: { lang: 'ru' }, responseType: 'arraybuffer' })
expect(response.status).toBe(200)
expect(response.headers['content-type']).toContain('application/pdf')
expect(Buffer.from(response.data).subarray(0, 4).toString()).toBe('%PDF')
})
it('reissues: voids the original and creates a draft copy with the next number', async () => {
const response = await api.post(`/module/invoices/${invoiceId}/reissue`, {}, auth())
expect(response.status).toBe(200)
const copy = response.data.response
expect(copy.status).toBe('draft')
expect(copy.number).toBe('INV-IT-2')
expect(copy.replacesInvoiceId).toBe(invoiceId)
expect(copy.lines).toHaveLength(2)
const original = await api.get(`/module/invoices/${invoiceId}`, auth())
expect(original.data.response.status).toBe('void')
expect(original.data.response.voidedAt).toBeTruthy()
const revive = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'issued' }, auth())
expect(revive.status).toBe(409)
const deleted = await api.delete(`/module/invoices/${copy.id}`, auth())
expect(deleted.status).toBe(200)
})
it('keeps a seller with a voided invoice undeletable', async () => {
const response = await api.delete(`/module/billing/sellers/${sellerId}`, auth())
expect(response.status).toBe(409)
})
it('rejects an anonymous request', async () => {
const response = await api.get('/module/invoices', { params: { organizationId } })
expect(response.status).toBe(401)
})
})
@@ -1,6 +0,0 @@
import { isOrgAdminFor } from '../../billing/middlewares/is-org-admin-for'
import { InvoicesRepository } from '../InvoicesRepository'
const repository = new InvoicesRepository()
export const isOrgAdminForInvoice = isOrgAdminFor((id) => repository.fetchOrganizationId(id))
-203
View File
@@ -1,203 +0,0 @@
import { type } from 'arktype'
import type {
InvoiceCounterpartySnapshot,
InvoiceLinesSchemaTypeForSelect,
InvoiceSellerSnapshot,
InvoiceStatus,
InvoicesSchemaTypeForSelect,
} from 'taskview-db-schemas'
import type { InvoiceTotalsResult } from '../../utils/invoiceTotals'
const NumberFromString = type('string|number').pipe((v) => Number(v))
const BooleanFromString = type('string|boolean|undefined').pipe((v) => {
if (v === undefined) return undefined
if (typeof v === 'boolean') return v
return v === 'true' || v === '1'
})
const DateString = type(/^\d{4}-\d{2}-\d{2}$/)
const DateStringOrNull = DateString.or('null')
export const InvoiceLineArkType = type({
taskId: 'number|null',
description: '1<=string<=1000',
unit: "'service' | 'hours' | 'pcs'",
quantity: 'number>=0',
unitPrice: 'number>=0',
})
export type InvoiceLineArg = typeof InvoiceLineArkType.infer
export const InvoiceArkTypeCreate = type({
organizationId: 'number',
goalId: 'number|null',
sellerId: 'number',
counterpartyId: 'number',
number: '1<=string<=50',
reference: 'string<=200',
currencyCode: /^[A-Z]{3}$/,
issueDate: DateString,
paymentTerms: "'on_receipt' | 'net7' | 'net14' | 'net30' | 'custom'",
dueDate: DateStringOrNull,
periodFrom: DateStringOrNull,
periodTo: DateStringOrNull,
discountType: "'percent' | 'amount'",
discountValue: 'number>=0',
taxRate: '0<=number<=100',
taxExempt: 'boolean',
taxNote: 'string<=500',
notes: 'string<=2000',
terms: 'string<=2000',
lines: InvoiceLineArkType.array().atLeastLength(1),
})
export type InvoiceArgCreate = typeof InvoiceArkTypeCreate.infer
export const InvoiceArkTypeUpdate = InvoiceArkTypeCreate.omit('organizationId')
export type InvoiceArgUpdate = typeof InvoiceArkTypeUpdate.infer
export const InvoiceArkTypeList = type({
organizationId: NumberFromString,
'includeArchived?': BooleanFromString,
})
export type InvoiceArgList = typeof InvoiceArkTypeList.infer
export const InvoiceArkTypeId = type({
id: NumberFromString,
})
export const InvoiceArkTypeStatus = type({
status: "'draft' | 'issued' | 'paid' | 'void'",
})
export type InvoiceArgStatus = typeof InvoiceArkTypeStatus.infer
export const InvoiceArkTypePdf = type({
id: NumberFromString,
'lang?': "'en' | 'ru'",
})
export type InvoiceArgPdf = typeof InvoiceArkTypePdf.infer
export const INVOICE_TRANSITIONS: Record<InvoiceStatus, InvoiceStatus[]> = {
draft: ['issued'],
issued: ['paid', 'void'],
paid: ['issued'],
void: [],
}
export const INVOICE_TEMPLATE_VERSION = 1
export type InvoiceMissingRequisite =
| 'seller.name'
| 'seller.address'
| 'seller.bank'
| 'counterparty.name'
| 'counterparty.address'
| 'lines'
export type InvoiceStatusPatch = {
status: InvoiceStatus
issuedAt?: Date | null
paidAt?: Date | null
voidedAt?: Date | null
templateVersion?: number
subtotal?: string | null
discountAmount?: string | null
taxAmount?: string | null
total?: string | null
}
export type InvoiceStatusRepoArgs = {
invoiceId: number
patch: InvoiceStatusPatch
}
export type InvoiceSetReplacesArgs = {
invoiceId: number
replacesInvoiceId: number
}
export type InvoiceReissueArgs = {
invoiceId: number
createdBy: number | null
}
export type InvoiceTransitionError =
| { error: 'not_found' }
| { error: 'invalid_transition'; from: InvoiceStatus; to: InvoiceStatus }
| { error: 'missing_requisites'; missing: InvoiceMissingRequisite[] }
export type InvoiceTransitionResult = { invoice: InvoiceForClient } | InvoiceTransitionError
export type InvoicePdfLang = 'en' | 'ru'
export type InvoiceRenderPdfArgs = {
invoiceId: number
lang: InvoicePdfLang
}
export type PdfmakeServer = {
virtualfs: { writeFileSync(filename: string, content: Buffer): void }
setFonts(fonts: Record<string, { normal: string; bold: string; italics: string; bolditalics: string }>): void
createPdf(docDefinition: unknown): { getBuffer(): Promise<Buffer> }
}
export type InvoiceSnapshots = {
sellerSnapshot: InvoiceSellerSnapshot
counterpartySnapshot: InvoiceCounterpartySnapshot
goalName: string
}
export type InvoiceCreateRepoArgs = InvoiceSnapshots & {
data: InvoiceArgCreate
createdBy: number | null
}
export type InvoiceUpdateRepoArgs = InvoiceSnapshots & {
invoiceId: number
data: InvoiceArgUpdate
}
export type InvoiceCreateArgs = {
data: InvoiceArgCreate
createdBy: number | null
}
export type InvoiceUpdateArgs = {
invoiceId: number
data: InvoiceArgUpdate
}
export type InvoiceSetStatusArgs = {
invoiceId: number
status: InvoiceArgStatus['status']
}
export type InvoiceWithLines = InvoicesSchemaTypeForSelect & {
lines: InvoiceLinesSchemaTypeForSelect[]
}
export type InvoiceLineForClient = Omit<InvoiceLinesSchemaTypeForSelect, 'invoiceId' | 'position' | 'quantity' | 'unitPrice'> & {
quantity: number
unitPrice: number
}
export type InvoiceForClient = Omit<
InvoicesSchemaTypeForSelect,
'discountValue' | 'taxRate' | 'sellerSnapshot' | 'counterpartySnapshot' | 'subtotal' | 'discountAmount' | 'taxAmount' | 'total'
> & {
discountValue: number
taxRate: number
seller: InvoiceSellerSnapshot
counterparty: InvoiceCounterpartySnapshot
lines: InvoiceLineForClient[]
totals: InvoiceTotalsResult
totalsFrozen: boolean
}
export type InvoiceWriteError = 'seller_not_found' | 'counterparty_not_found' | 'goal_not_found' | 'duplicate_number' | 'not_found' | 'not_draft'
export type InvoiceWriteResult = { invoice: InvoiceForClient } | { error: InvoiceWriteError }
export type InvoiceDeleteResult = 'deleted' | 'not_draft' | 'not_found'
export type InvoiceNextNumberArgs = {
organizationId: number
base: string
}
-1
View File
@@ -199,7 +199,6 @@ export const GoalPermissions = {
ORG_CAN_MANAGE_MEMBERS: 'org_can_manage_members', ORG_CAN_MANAGE_MEMBERS: 'org_can_manage_members',
SSO_CAN_MANAGE: 'sso_can_manage', SSO_CAN_MANAGE: 'sso_can_manage',
WEBHOOKS_CAN_MANAGE: 'webhooks_can_manage', WEBHOOKS_CAN_MANAGE: 'webhooks_can_manage',
BILLING_CAN_MANAGE: 'billing_can_manage',
SPRINT_CAN_VIEW: 'sprint_can_view', SPRINT_CAN_VIEW: 'sprint_can_view',
SPRINT_CAN_MANAGE: 'sprint_can_manage', SPRINT_CAN_MANAGE: 'sprint_can_manage',
-4
View File
@@ -1,4 +0,0 @@
declare module 'pdfmake/build/vfs_fonts.js' {
const vfs: Record<string, string>
export default vfs
}
-33
View File
@@ -1,33 +0,0 @@
export type InvoiceTotalsInput = {
lines: { quantity: number | string; unitPrice: number | string }[]
discountType: 'percent' | 'amount'
discountValue: number | string
taxRate: number | string
taxExempt: boolean
}
export type InvoiceTotalsResult = {
subtotal: number
discount: number
taxable: number
tax: number
total: number
}
export function round2(value: number): number {
return Math.round(value * 100) / 100
}
export function invoiceLineAmount(quantity: number | string, unitPrice: number | string): number {
return round2(Number(quantity) * Number(unitPrice))
}
export function computeInvoiceTotals(input: InvoiceTotalsInput): InvoiceTotalsResult {
const subtotal = round2(input.lines.reduce((sum, line) => sum + invoiceLineAmount(line.quantity, line.unitPrice), 0))
const discountValue = Number(input.discountValue)
const rawDiscount = input.discountType === 'percent' ? subtotal * (discountValue / 100) : discountValue
const discount = round2(Math.min(Math.max(rawDiscount, 0), subtotal))
const taxable = round2(subtotal - discount)
const tax = input.taxExempt ? 0 : round2(taxable * (Number(input.taxRate) / 100))
return { subtotal, discount, taxable, tax, total: round2(taxable + tax) }
}
@@ -1,63 +0,0 @@
import TvApiBase from './base'
import type { AppResponse } from './base.types'
import type {
BillingArgArchive,
BillingArgList,
CounterpartyArgCreate,
CounterpartyArgUpdate,
CounterpartyItem,
CurrencyItem,
SellerArgCreate,
SellerArgUpdate,
SellerItem,
} from './billing.types'
export default class TvBillingApi extends TvApiBase {
protected moduleUrl = '/module/billing'
public async fetchCurrencies() {
return this.request(this.$axios.get<AppResponse<CurrencyItem[]>>(`${this.moduleUrl}/currencies`))
}
public async fetchSellers(params: BillingArgList) {
return this.request(this.$axios.get<AppResponse<SellerItem[]>>(`${this.moduleUrl}/sellers`, { params }))
}
public async createSeller(data: SellerArgCreate) {
return this.request(this.$axios.post<AppResponse<SellerItem>>(`${this.moduleUrl}/sellers`, data))
}
public async updateSeller({ id, data }: SellerArgUpdate) {
return this.request(this.$axios.patch<AppResponse<SellerItem>>(`${this.moduleUrl}/sellers/${id}`, data))
}
public async archiveSeller({ id, archived }: BillingArgArchive) {
return this.request(this.$axios.patch<AppResponse<SellerItem>>(`${this.moduleUrl}/sellers/${id}/archive`, { archived }))
}
public async deleteSeller(id: number) {
return this.request(this.$axios.delete<AppResponse<boolean>>(`${this.moduleUrl}/sellers/${id}`))
}
public async fetchCounterparties(params: BillingArgList) {
return this.request(this.$axios.get<AppResponse<CounterpartyItem[]>>(`${this.moduleUrl}/counterparties`, { params }))
}
public async createCounterparty(data: CounterpartyArgCreate) {
return this.request(this.$axios.post<AppResponse<CounterpartyItem>>(`${this.moduleUrl}/counterparties`, data))
}
public async updateCounterparty({ id, data }: CounterpartyArgUpdate) {
return this.request(this.$axios.patch<AppResponse<CounterpartyItem>>(`${this.moduleUrl}/counterparties/${id}`, data))
}
public async archiveCounterparty({ id, archived }: BillingArgArchive) {
return this.request(
this.$axios.patch<AppResponse<CounterpartyItem>>(`${this.moduleUrl}/counterparties/${id}/archive`, { archived }),
)
}
public async deleteCounterparty(id: number) {
return this.request(this.$axios.delete<AppResponse<boolean>>(`${this.moduleUrl}/counterparties/${id}`))
}
}
@@ -1,86 +0,0 @@
export type CurrencyItem = {
id: number
code: string
numericCode: number
name: string
symbol: string
decimalDigits: number
sortOrder: number
isActive: boolean
createdAt: string
}
export type BillingRequisite = {
key: string
label: string
value: string
}
export type BillingBankDetails = {
bankName: string
accountNumber: string
iban: string
swift: string
correspondentAccount: string
}
export type CounterpartyKind = 'organization' | 'person'
export type SellerItem = {
id: number
organizationId: number
name: string
legalName: string
address: string
email: string
phone: string
logoUrl: string
currencyCode: string
bank: BillingBankDetails
requisites: BillingRequisite[]
defaultTerms: string
taxNote: string
archived: boolean
createdAt: string
updatedAt: string
}
export type SellerArgCreate = Omit<SellerItem, 'id' | 'archived' | 'createdAt' | 'updatedAt'>
export type SellerArgUpdate = {
id: number
data: Omit<SellerArgCreate, 'organizationId'>
}
export type CounterpartyItem = {
id: number
organizationId: number
kind: CounterpartyKind
name: string
legalName: string
address: string
email: string
phone: string
contactPerson: string
requisites: BillingRequisite[]
archived: boolean
createdAt: string
updatedAt: string
}
export type CounterpartyArgCreate = Omit<CounterpartyItem, 'id' | 'archived' | 'createdAt' | 'updatedAt'>
export type CounterpartyArgUpdate = {
id: number
data: Omit<CounterpartyArgCreate, 'organizationId'>
}
export type BillingArgList = {
organizationId: number
includeArchived?: boolean
}
export type BillingArgArchive = {
id: number
archived: boolean
}
@@ -1,40 +0,0 @@
import TvApiBase from './base'
import type { AppResponse } from './base.types'
import type { InvoiceArgCreate, InvoiceArgList, InvoiceArgPdf, InvoiceArgStatus, InvoiceArgUpdate, InvoiceItem } from './invoices.types'
export default class TvInvoicesApi extends TvApiBase {
protected moduleUrl = '/module/invoices'
public async fetch(params: InvoiceArgList) {
return this.request(this.$axios.get<AppResponse<InvoiceItem[]>>(`${this.moduleUrl}`, { params }))
}
public async fetchById(id: number) {
return this.request(this.$axios.get<AppResponse<InvoiceItem>>(`${this.moduleUrl}/${id}`))
}
public async create(data: InvoiceArgCreate) {
return this.request(this.$axios.post<AppResponse<InvoiceItem>>(`${this.moduleUrl}`, data))
}
public async update({ id, data }: InvoiceArgUpdate) {
return this.request(this.$axios.patch<AppResponse<InvoiceItem>>(`${this.moduleUrl}/${id}`, data))
}
public async setStatus({ id, status }: InvoiceArgStatus) {
return this.request(this.$axios.patch<AppResponse<InvoiceItem>>(`${this.moduleUrl}/${id}/status`, { status }))
}
public async reissue(id: number) {
return this.request(this.$axios.post<AppResponse<InvoiceItem>>(`${this.moduleUrl}/${id}/reissue`, {}))
}
public async fetchPdf({ id, lang }: InvoiceArgPdf) {
const response = await this.$axios.get<Blob>(`${this.moduleUrl}/${id}/pdf`, { params: { lang }, responseType: 'blob' })
return response.data
}
public async delete(id: number) {
return this.request(this.$axios.delete<AppResponse<boolean>>(`${this.moduleUrl}/${id}`))
}
}
@@ -1,128 +0,0 @@
import type { BillingBankDetails, BillingRequisite, CounterpartyKind } from './billing.types'
export type InvoiceStatus = 'draft' | 'issued' | 'paid' | 'void'
export type InvoiceUnit = 'service' | 'hours' | 'pcs'
export type InvoiceDiscountType = 'percent' | 'amount'
export type InvoicePaymentTerms = 'on_receipt' | 'net7' | 'net14' | 'net30' | 'custom'
export type InvoiceSellerSnapshot = {
name: string
legalName: string
address: string
email: string
phone: string
logoUrl: string
bank: BillingBankDetails
requisites: BillingRequisite[]
}
export type InvoiceCounterpartySnapshot = {
kind: CounterpartyKind
name: string
legalName: string
address: string
email: string
phone: string
contactPerson: string
requisites: BillingRequisite[]
}
export type InvoiceLineItem = {
id: number
taskId: number | null
description: string
unit: InvoiceUnit
quantity: number
unitPrice: number
}
export type InvoiceLineArg = Omit<InvoiceLineItem, 'id'>
export type InvoiceTotals = {
subtotal: number
discount: number
taxable: number
tax: number
total: number
}
export type InvoiceMissingRequisite =
| 'seller.name'
| 'seller.address'
| 'seller.bank'
| 'counterparty.name'
| 'counterparty.address'
| 'lines'
export type InvoiceTransitionError =
| { error: 'not_found' }
| { error: 'invalid_transition'; from: InvoiceStatus; to: InvoiceStatus }
| { error: 'missing_requisites'; missing: InvoiceMissingRequisite[] }
export type InvoicePdfLang = 'en' | 'ru'
export type InvoiceArgPdf = {
id: number
lang: InvoicePdfLang
}
export type InvoiceItem = {
id: number
organizationId: number
goalId: number | null
goalName: string
sellerId: number
counterpartyId: number
number: string
status: InvoiceStatus
reference: string
currencyCode: string
issueDate: string
paymentTerms: InvoicePaymentTerms
dueDate: string | null
periodFrom: string | null
periodTo: string | null
discountType: InvoiceDiscountType
discountValue: number
taxRate: number
taxExempt: boolean
taxNote: string
notes: string
terms: string
seller: InvoiceSellerSnapshot
counterparty: InvoiceCounterpartySnapshot
lines: InvoiceLineItem[]
totals: InvoiceTotals
totalsFrozen: boolean
issuedAt: string | null
paidAt: string | null
voidedAt: string | null
replacesInvoiceId: number | null
templateVersion: number
createdBy: number | null
createdAt: string
updatedAt: string
}
export type InvoiceArgCreate = Omit<
InvoiceItem,
| 'id' | 'goalName' | 'status' | 'seller' | 'counterparty' | 'lines' | 'createdBy' | 'createdAt' | 'updatedAt'
| 'totals' | 'totalsFrozen' | 'issuedAt' | 'paidAt' | 'voidedAt' | 'replacesInvoiceId' | 'templateVersion'
> & {
lines: InvoiceLineArg[]
}
export type InvoiceArgUpdate = {
id: number
data: Omit<InvoiceArgCreate, 'organizationId'>
}
export type InvoiceArgList = {
organizationId: number
includeArchived?: boolean
}
export type InvoiceArgStatus = {
id: number
status: InvoiceStatus
}
@@ -12,8 +12,6 @@ export * from '@/api/kanban.types';
export * from '@/api/integrations.types'; export * from '@/api/integrations.types';
export * from '@/api/notifications.api.types'; export * from '@/api/notifications.api.types';
export * from '@/api/webhooks.types'; export * from '@/api/webhooks.types';
export * from '@/api/billing.types';
export * from '@/api/invoices.types';
export * from '@/api/messaging.types'; export * from '@/api/messaging.types';
export * from '@/api/api-tokens.types'; export * from '@/api/api-tokens.types';
export * from '@/api/oauth.types'; export * from '@/api/oauth.types';
-10
View File
@@ -21,8 +21,6 @@ import TvTimeTrackingApi from "./api/time-tracking";
import TvUiPreferencesApi from "./api/ui-preferences"; import TvUiPreferencesApi from "./api/ui-preferences";
import TvSprintApi from "./api/sprints"; import TvSprintApi from "./api/sprints";
import TvRecurrenceApi from "./api/recurrence"; import TvRecurrenceApi from "./api/recurrence";
import TvBillingApi from "./api/billing";
import TvInvoicesApi from "./api/invoices";
export class TvApi { export class TvApi {
@@ -70,10 +68,6 @@ export class TvApi {
public recurrence: TvRecurrenceApi; public recurrence: TvRecurrenceApi;
public billing: TvBillingApi;
public invoices: TvInvoicesApi;
constructor($axios: AxiosInstance) { constructor($axios: AxiosInstance) {
this.$axios = $axios; this.$axios = $axios;
@@ -118,10 +112,6 @@ export class TvApi {
this.sprints = new TvSprintApi(this.$axios); this.sprints = new TvSprintApi(this.$axios);
this.recurrence = new TvRecurrenceApi(this.$axios); this.recurrence = new TvRecurrenceApi(this.$axios);
this.billing = new TvBillingApi(this.$axios);
this.invoices = new TvInvoicesApi(this.$axios);
} }
public setBaseUrl(baseUrl: string) { public setBaseUrl(baseUrl: string) {
@@ -24,9 +24,6 @@ export * from './schemas/organizations.schema';
export * from './schemas/sso.schema'; export * from './schemas/sso.schema';
export * from './schemas/time-entries.schema'; export * from './schemas/time-entries.schema';
export * from './schemas/time-entries-history.schema'; export * from './schemas/time-entries-history.schema';
export * from './schemas/currencies.schema';
export * from './schemas/billing.schema';
export * from './schemas/invoices.schema';
export * from './schemas/ui-preferences.schema'; export * from './schemas/ui-preferences.schema';
export * from './schemas/sprints.schema'; export * from './schemas/sprints.schema';
export * from './schemas/sprint-task-outcomes.schema'; export * from './schemas/sprint-task-outcomes.schema';
@@ -1,57 +0,0 @@
import { boolean, char, integer, jsonb, timestamp, varchar } from 'drizzle-orm/pg-core'
import { BillingPgSchema, CurrenciesSchema } from './currencies.schema'
import { OrganizationsSchema } from './organizations.schema'
export type BillingRequisite = {
key: string
label: string
value: string
}
export type BillingBankDetails = {
bankName: string
accountNumber: string
iban: string
swift: string
correspondentAccount: string
}
export const SellersSchema = BillingPgSchema.table('sellers', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
organizationId: integer('organization_id').notNull().references(() => OrganizationsSchema.id, { onDelete: 'cascade' }),
name: varchar({ length: 200 }).notNull(),
legalName: varchar('legal_name', { length: 300 }).notNull().default(''),
address: varchar({ length: 1000 }).notNull().default(''),
email: varchar({ length: 320 }).notNull().default(''),
phone: varchar({ length: 50 }).notNull().default(''),
logoUrl: varchar('logo_url', { length: 1000 }).notNull().default(''),
currencyCode: char('currency_code', { length: 3 }).notNull().default('USD').references(() => CurrenciesSchema.code),
bank: jsonb().$type<BillingBankDetails>().notNull(),
requisites: jsonb().$type<BillingRequisite[]>().notNull(),
defaultTerms: varchar('default_terms', { length: 2000 }).notNull().default(''),
taxNote: varchar('tax_note', { length: 500 }).notNull().default(''),
archived: boolean().notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export const CounterpartiesSchema = BillingPgSchema.table('counterparties', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
organizationId: integer('organization_id').notNull().references(() => OrganizationsSchema.id, { onDelete: 'cascade' }),
kind: varchar({ length: 20 }).$type<'organization' | 'person'>().notNull().default('organization'),
name: varchar({ length: 200 }).notNull(),
legalName: varchar('legal_name', { length: 300 }).notNull().default(''),
address: varchar({ length: 1000 }).notNull().default(''),
email: varchar({ length: 320 }).notNull().default(''),
phone: varchar({ length: 50 }).notNull().default(''),
contactPerson: varchar('contact_person', { length: 200 }).notNull().default(''),
requisites: jsonb().$type<BillingRequisite[]>().notNull(),
archived: boolean().notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export type SellersSchemaTypeForSelect = typeof SellersSchema.$inferSelect
export type SellersSchemaTypeForInsert = typeof SellersSchema.$inferInsert
export type CounterpartiesSchemaTypeForSelect = typeof CounterpartiesSchema.$inferSelect
export type CounterpartiesSchemaTypeForInsert = typeof CounterpartiesSchema.$inferInsert
@@ -1,21 +0,0 @@
import { boolean, char, integer, pgSchema, smallint, timestamp, varchar } from 'drizzle-orm/pg-core'
import { createInsertSchema } from 'drizzle-arktype'
export const BillingPgSchema = pgSchema('tv_billing')
export const CurrenciesSchema = BillingPgSchema.table('currencies', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
code: char({ length: 3 }).notNull().unique(),
numericCode: smallint('numeric_code').notNull().unique(),
name: varchar({ length: 64 }).notNull(),
symbol: varchar({ length: 8 }).notNull(),
decimalDigits: smallint('decimal_digits').notNull().default(2),
sortOrder: smallint('sort_order').notNull().default(0),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at').notNull().defaultNow(),
})
export type CurrenciesSchemaTypeForSelect = typeof CurrenciesSchema.$inferSelect
export type CurrenciesSchemaTypeForInsert = typeof CurrenciesSchema.$inferInsert
export const CurrenciesSchemaArkTypeInsert = createInsertSchema(CurrenciesSchema)
@@ -1,89 +0,0 @@
import { boolean, char, date, integer, jsonb, numeric, smallint, timestamp, varchar } from 'drizzle-orm/pg-core'
import { BillingPgSchema, CurrenciesSchema } from './currencies.schema'
import { CounterpartiesSchema, SellersSchema, type BillingBankDetails, type BillingRequisite } from './billing.schema'
import { OrganizationsSchema } from './organizations.schema'
import { GoalsSchema } from './goals.schema'
import { TasksSchema } from './tasks.schema'
import { UsersSchema } from './users.schema'
export type InvoiceStatus = 'draft' | 'issued' | 'paid' | 'void'
export type InvoiceDiscountType = 'percent' | 'amount'
export type InvoicePaymentTerms = 'on_receipt' | 'net7' | 'net14' | 'net30' | 'custom'
export type InvoiceLineUnit = 'service' | 'hours' | 'pcs'
export type InvoiceSellerSnapshot = {
name: string
legalName: string
address: string
email: string
phone: string
logoUrl: string
bank: BillingBankDetails
requisites: BillingRequisite[]
}
export type InvoiceCounterpartySnapshot = {
kind: 'organization' | 'person'
name: string
legalName: string
address: string
email: string
phone: string
contactPerson: string
requisites: BillingRequisite[]
}
export const InvoicesSchema = BillingPgSchema.table('invoices', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
organizationId: integer('organization_id').notNull().references(() => OrganizationsSchema.id, { onDelete: 'cascade' }),
goalId: integer('goal_id').references(() => GoalsSchema.id, { onDelete: 'set null' }),
goalName: varchar('goal_name', { length: 500 }).notNull().default(''),
sellerId: integer('seller_id').notNull().references(() => SellersSchema.id, { onDelete: 'restrict' }),
counterpartyId: integer('counterparty_id').notNull().references(() => CounterpartiesSchema.id, { onDelete: 'restrict' }),
number: varchar({ length: 50 }).notNull(),
status: varchar({ length: 10 }).$type<InvoiceStatus>().notNull().default('draft'),
reference: varchar({ length: 200 }).notNull().default(''),
currencyCode: char('currency_code', { length: 3 }).notNull().references(() => CurrenciesSchema.code),
issueDate: date('issue_date').notNull(),
paymentTerms: varchar('payment_terms', { length: 20 }).$type<InvoicePaymentTerms>().notNull().default('net14'),
dueDate: date('due_date'),
periodFrom: date('period_from'),
periodTo: date('period_to'),
discountType: varchar('discount_type', { length: 10 }).$type<InvoiceDiscountType>().notNull().default('percent'),
discountValue: numeric('discount_value', { precision: 12, scale: 2 }).notNull().default('0'),
taxRate: numeric('tax_rate', { precision: 5, scale: 2 }).notNull().default('0'),
taxExempt: boolean('tax_exempt').notNull().default(false),
taxNote: varchar('tax_note', { length: 500 }).notNull().default(''),
notes: varchar({ length: 2000 }).notNull().default(''),
terms: varchar({ length: 2000 }).notNull().default(''),
sellerSnapshot: jsonb('seller_snapshot').$type<InvoiceSellerSnapshot>().notNull(),
counterpartySnapshot: jsonb('counterparty_snapshot').$type<InvoiceCounterpartySnapshot>().notNull(),
createdBy: integer('created_by').references(() => UsersSchema.id, { onDelete: 'set null' }),
issuedAt: timestamp('issued_at'),
paidAt: timestamp('paid_at'),
voidedAt: timestamp('voided_at'),
replacesInvoiceId: integer('replaces_invoice_id'),
templateVersion: smallint('template_version').notNull().default(1),
subtotal: numeric({ precision: 12, scale: 2 }),
discountAmount: numeric('discount_amount', { precision: 12, scale: 2 }),
taxAmount: numeric('tax_amount', { precision: 12, scale: 2 }),
total: numeric({ precision: 12, scale: 2 }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export const InvoiceLinesSchema = BillingPgSchema.table('invoice_lines', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
invoiceId: integer('invoice_id').notNull().references(() => InvoicesSchema.id, { onDelete: 'cascade' }),
position: smallint().notNull().default(0),
taskId: integer('task_id').references(() => TasksSchema.id, { onDelete: 'set null' }),
description: varchar({ length: 1000 }).notNull(),
unit: varchar({ length: 20 }).$type<InvoiceLineUnit>().notNull().default('service'),
quantity: numeric({ precision: 12, scale: 2 }).notNull().default('1'),
unitPrice: numeric('unit_price', { precision: 12, scale: 2 }).notNull().default('0'),
})
export type InvoicesSchemaTypeForSelect = typeof InvoicesSchema.$inferSelect
export type InvoicesSchemaTypeForInsert = typeof InvoicesSchema.$inferInsert
export type InvoiceLinesSchemaTypeForSelect = typeof InvoiceLinesSchema.$inferSelect
export type InvoiceLinesSchemaTypeForInsert = typeof InvoiceLinesSchema.$inferInsert
-20
View File
@@ -108,26 +108,6 @@ function buildRoutes(extensions: TvWebExtension[]): RouteRecordRaw[] {
name: 'analytics', name: 'analytics',
component: () => import('./pages/user/analytics.vue'), component: () => import('./pages/user/analytics.vue'),
}, },
{
path: 'invoices',
name: 'invoices',
component: () => import('./pages/user/invoices.vue'),
},
{
path: 'invoices/counterparties',
name: 'invoices-counterparties',
component: () => import('./pages/user/invoices-counterparties.vue'),
},
{
path: 'invoices/sellers',
name: 'invoices-sellers',
component: () => import('./pages/user/invoices-sellers.vue'),
},
{
path: 'invoices/:invoiceId(\\d+)',
name: 'invoice-preview',
component: () => import('./pages/user/invoice-preview.vue'),
},
{ {
path: 'ui-customization', path: 'ui-customization',
name: 'ui-customization', name: 'ui-customization',
@@ -1,81 +0,0 @@
<template>
<UButton
v-if="invoice.status === 'draft'"
icon="i-lucide-pencil"
:label="compact ? undefined : t('common.edit')"
color="neutral"
variant="outline"
@click="emit('edit')"
/>
<UButton
v-if="invoice.status === 'draft'"
icon="i-lucide-send"
:label="compact ? undefined : t('invoices.actions.issue')"
variant="soft"
:loading="busy"
@click="emit('transition', 'issued')"
/>
<UButton
v-if="invoice.status === 'issued'"
icon="i-lucide-badge-check"
:label="compact ? undefined : t('invoices.actions.markPaid')"
variant="soft"
:loading="busy"
@click="emit('transition', 'paid')"
/>
<UButton
v-if="invoice.status === 'issued'"
icon="i-lucide-file-pen-line"
:label="compact ? undefined : t('invoices.actions.reissue')"
color="neutral"
variant="outline"
:loading="busy"
@click="emit('reissue')"
/>
<UButton
v-if="invoice.status === 'issued'"
icon="i-lucide-ban"
:label="compact ? undefined : t('invoices.actions.void')"
color="error"
variant="ghost"
:loading="busy"
@click="emit('transition', 'void')"
/>
<UButton
v-if="invoice.status === 'paid'"
icon="i-lucide-undo-2"
:label="compact ? undefined : t('invoices.actions.unmarkPaid')"
color="neutral"
variant="outline"
:loading="busy"
@click="emit('transition', 'issued')"
/>
<UButton
v-if="invoice.status === 'void'"
icon="i-lucide-file-pen-line"
:label="compact ? undefined : t('invoices.actions.reissue')"
color="neutral"
variant="outline"
:loading="busy"
@click="emit('reissue')"
/>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { InvoiceItem, InvoiceStatus } from 'taskview-api'
defineProps<{
invoice: InvoiceItem
compact: boolean
busy: boolean
}>()
const emit = defineEmits<{
edit: []
transition: [status: InvoiceStatus]
reissue: []
}>()
const { t } = useI18n()
</script>
@@ -1,67 +0,0 @@
<template>
<div class="flex h-full min-h-[60vh] flex-col">
<div
v-if="loading"
class="flex flex-1 items-center justify-center text-muted"
>
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin"
/>
</div>
<p
v-else-if="!url"
class="py-16 text-center text-sm text-muted"
>
{{ t('invoices.preview.pdfFailed') }}
</p>
<iframe
v-else
:src="url"
:title="title"
class="h-full min-h-[70vh] w-full flex-1 rounded-10 bg-white"
/>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { InvoicePdfLang } from 'taskview-api'
import { useInvoicesStore } from '@/stores/invoices.store'
const props = defineProps<{
invoiceId: number
title: string
version: string
}>()
const emit = defineEmits<{
loaded: [blob: Blob | null]
}>()
const { t, locale } = useI18n()
const invoicesStore = useInvoicesStore()
const url = ref<string | null>(null)
const loading = ref(false)
function release() {
if (url.value) URL.revokeObjectURL(url.value.split('#')[0])
url.value = null
}
async function load() {
loading.value = true
const lang: InvoicePdfLang = locale.value === 'ru' ? 'ru' : 'en'
const blob = await invoicesStore.fetchPdf(props.invoiceId, lang)
release()
if (blob) url.value = `${URL.createObjectURL(blob)}#navpanes=0&view=FitH`
loading.value = false
emit('loaded', blob)
}
watch(() => [props.invoiceId, props.version, locale.value], load, { immediate: true })
onBeforeUnmount(release)
</script>
@@ -1,46 +0,0 @@
<template>
<div class="flex flex-wrap items-center gap-2 text-sm text-muted">
<UBadge
:label="t(`invoices.status.${invoice.status}`)"
:color="statusColors[invoice.status]"
variant="subtle"
/>
<UBadge
v-if="overdue"
:label="t('invoices.overdue')"
color="error"
variant="subtle"
/>
<span v-if="invoice.issuedAt">{{ t('invoices.dates.issued') }} {{ date(invoice.issuedAt) }}</span>
<span v-if="invoice.paidAt">· {{ t('invoices.dates.paid') }} {{ date(invoice.paidAt) }}</span>
<span v-if="invoice.voidedAt">· {{ t('invoices.dates.voided') }} {{ date(invoice.voidedAt) }}</span>
<RouterLink
v-if="invoice.replacesInvoiceId !== null"
:to="{ name: 'invoice-preview', params: { invoiceId: invoice.replacesInvoiceId } }"
class="text-primary hover:underline"
>
{{ t('invoices.replacesLink') }}
</RouterLink>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDateFormat } from '@vueuse/core'
import type { InvoiceItem } from 'taskview-api'
import { isInvoiceOverdue } from '@/helpers/invoiceDates'
const props = defineProps<{
invoice: InvoiceItem
}>()
const { t } = useI18n()
const statusColors = { draft: 'warning', issued: 'info', paid: 'success', void: 'neutral' } as const
const overdue = computed(() => isInvoiceOverdue(props.invoice))
function date(value: string): string {
return useDateFormat(new Date(value), 'DD MMM YYYY').value
}
</script>
@@ -1,20 +0,0 @@
<template>
<div class="flex flex-col items-center justify-center gap-3 py-16 px-4 text-center">
<UIcon
name="i-lucide-receipt"
class="size-12 text-muted"
/>
<p class="font-medium text-default">
{{ t('invoices.page.empty') }}
</p>
<p class="text-sm text-muted max-w-md">
{{ t('invoices.page.emptyHint') }}
</p>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
@@ -1,23 +0,0 @@
<template>
<div class="flex flex-col gap-2 p-2 lg:p-6">
<InvoicesListItem
v-for="invoice in invoices"
:key="invoice.id"
:invoice="invoice"
@open="emit('open', $event)"
/>
</div>
</template>
<script setup lang="ts">
import InvoicesListItem from './InvoicesListItem.vue'
import type { InvoiceItem } from 'taskview-api'
defineProps<{
invoices: InvoiceItem[]
}>()
const emit = defineEmits<{
open: [invoice: InvoiceItem]
}>()
</script>
@@ -1,69 +0,0 @@
<template>
<button
type="button"
class="flex w-full flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border border-default px-4 py-3 text-left hover:bg-elevated"
@click="emit('open', invoice)"
>
<UIcon
name="i-lucide-receipt"
class="size-5 shrink-0 text-muted"
/>
<div class="flex min-w-0 flex-1 basis-52 flex-col">
<span class="truncate font-medium text-default">
{{ invoice.number }} · {{ invoice.counterparty.name }}
</span>
<span class="truncate text-xs text-muted">
{{ invoice.seller.name }} · {{ invoice.goalName }} · {{ issueDate }}
</span>
</div>
<div class="ml-auto flex items-center gap-2">
<UBadge
v-if="invoice.replacesInvoiceId !== null"
:label="t('invoices.replaces')"
color="neutral"
variant="subtle"
size="sm"
/>
<UBadge
v-if="overdue"
:label="t('invoices.overdue')"
color="error"
variant="subtle"
size="sm"
/>
<UBadge
:label="t(`invoices.status.${invoice.status}`)"
:color="statusColors[invoice.status]"
variant="subtle"
size="sm"
/>
<span class="shrink-0 pl-1 text-sm font-semibold tabular-nums text-default">
{{ formatMoney({ amount: invoice.totals.total, currencyCode: invoice.currencyCode, locale }) }}
</span>
</div>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDateFormat } from '@vueuse/core'
import { formatMoney } from '@/helpers/money'
import { isInvoiceOverdue } from '@/helpers/invoiceDates'
import type { InvoiceItem } from 'taskview-api'
const props = defineProps<{
invoice: InvoiceItem
}>()
const emit = defineEmits<{
open: [invoice: InvoiceItem]
}>()
const { t, locale } = useI18n()
const statusColors = { draft: 'warning', issued: 'info', paid: 'success', void: 'neutral' } as const
const issueDate = computed(() => useDateFormat(new Date(props.invoice.issueDate), 'DD MMM YYYY').value)
const overdue = computed(() => isInvoiceOverdue(props.invoice))
</script>
@@ -1,20 +0,0 @@
<template>
<div class="flex flex-col items-center justify-center gap-3 py-16 px-4 text-center">
<UIcon
name="i-lucide-lock"
class="size-10 text-muted"
/>
<p class="font-medium text-default">
{{ t('invoices.page.noPermission') }}
</p>
<p class="text-sm text-muted max-w-md">
{{ t('invoices.page.noPermissionHint') }}
</p>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
@@ -1,63 +0,0 @@
<template>
<UDashboardPanel :id="id">
<template #header>
<UDashboardNavbar :title="title">
<template #leading>
<UDashboardSidebarCollapse />
</template>
<template #right>
<slot name="actions" />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div
v-if="showTabs"
class="flex flex-col border-b border-default px-2 lg:flex-row lg:items-center lg:justify-between lg:gap-4 lg:px-6"
>
<div class="overflow-x-auto [scrollbar-width:none]">
<UNavigationMenu
:items="tabs"
class="w-max"
/>
</div>
<USwitch
v-if="includeArchived !== undefined"
v-model="includeArchived"
:label="t('invoices.showArchived')"
size="sm"
class="self-end pb-2 lg:self-auto lg:pb-0"
/>
</div>
<slot />
</template>
</UDashboardPanel>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCurrenciesStore } from '@/stores/currencies.store'
withDefaults(
defineProps<{
id: string
title: string
showTabs?: boolean
}>(),
{ showTabs: true },
)
const includeArchived = defineModel<boolean | undefined>('includeArchived', { default: undefined })
const { t } = useI18n()
const currenciesStore = useCurrenciesStore()
onMounted(() => currenciesStore.fetch())
const tabs = computed(() => [
{ label: t('invoices.page.title'), icon: 'i-lucide-receipt', to: { name: 'invoices' } },
{ label: t('invoices.counterparty.title'), icon: 'i-lucide-users', to: { name: 'invoices-counterparties' } },
{ label: t('invoices.seller.title'), icon: 'i-lucide-building-2', to: { name: 'invoices-sellers' } },
])
</script>
@@ -1,64 +0,0 @@
<template>
<div class="flex w-full flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border border-default px-4 py-3">
<UIcon
:name="icon"
class="size-5 shrink-0 text-muted"
/>
<div class="flex min-w-0 flex-1 basis-52 flex-col">
<span class="flex items-center gap-2 truncate font-medium text-default">
{{ title }}
<UBadge
v-if="archived"
:label="t('invoices.archivedBadge')"
color="neutral"
variant="subtle"
size="sm"
/>
</span>
<span class="truncate text-xs text-muted">{{ subtitle }}</span>
</div>
<div class="ml-auto flex items-center">
<UButton
icon="i-lucide-pencil"
color="neutral"
variant="ghost"
size="sm"
@click="emit('edit')"
/>
<UButton
:icon="archived ? 'i-lucide-archive-restore' : 'i-lucide-archive'"
color="neutral"
variant="ghost"
size="sm"
:title="archived ? t('invoices.unarchive') : t('invoices.archive')"
@click="emit('archive', !archived)"
/>
<UButton
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="sm"
@click="emit('delete')"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
defineProps<{
icon: string
title: string
subtitle: string
archived: boolean
}>()
const emit = defineEmits<{
edit: []
archive: [archived: boolean]
delete: []
}>()
const { t } = useI18n()
</script>
@@ -1,77 +0,0 @@
<template>
<div :class="sectionClass">
<span class="text-sm font-medium text-default">{{ t('invoices.bank.title') }}</span>
<UFormField :label="t('invoices.bank.bankName')">
<UInput
v-model="model.bankName"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
</UFormField>
<div class="flex flex-col gap-3 lg:flex-row">
<UFormField
:label="t('invoices.bank.accountNumber')"
class="flex-1"
>
<UInput
v-model="model.accountNumber"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
</UFormField>
<UFormField
:label="t('invoices.bank.iban')"
class="flex-1"
>
<UInput
v-model="model.iban"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
</UFormField>
</div>
<div class="flex flex-col gap-3 lg:flex-row">
<UFormField
:label="t('invoices.bank.swift')"
class="flex-1"
>
<UInput
v-model="model.swift"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
</UFormField>
<UFormField
:label="t('invoices.bank.correspondentAccount')"
class="flex-1"
>
<UInput
v-model="model.correspondentAccount"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
</UFormField>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useInvoiceFieldStyle } from '@/composables/useInvoiceFieldStyle'
import type { BillingBankDetails } from 'taskview-api'
const model = defineModel<BillingBankDetails>({ required: true })
const { t } = useI18n()
const { inputVariant, inputUi, sectionClass } = useInvoiceFieldStyle()
</script>
@@ -1,41 +0,0 @@
<template>
<div class="flex flex-col gap-4">
<UFormField :label="t('invoices.counterparty.kind')">
<UTabs
v-model="model.kind"
:items="kindItems"
:content="false"
size="lg"
class="w-full lg:w-80"
:ui="{ list: 'rounded-14', trigger: 'rounded-10', indicator: 'rounded-10' }"
/>
</UFormField>
<PartyFields v-model="model" />
<UFormField :label="t('invoices.counterparty.contactPerson')">
<UInput
v-model="model.contactPerson"
class="w-full"
/>
</UFormField>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PartyFields from './PartyFields.vue'
import type { CounterpartyKind } from 'taskview-api'
import type { CounterpartyFormValue } from '@/types/invoices.types'
const model = defineModel<CounterpartyFormValue>({ required: true })
const { t } = useI18n()
const kindItems = computed(() => [
{ label: t('invoices.counterparty.kinds.organization'), value: 'organization' as CounterpartyKind },
{ label: t('invoices.counterparty.kinds.person'), value: 'person' as CounterpartyKind },
])
</script>
@@ -1,119 +0,0 @@
<template>
<UModal
v-model:open="open"
:fullscreen="isMobile"
:ui="{ content: isMobile ? 'flex flex-col' : 'lg:max-w-2xl max-h-[90vh] flex flex-col' }"
>
<template #content>
<UCard :ui="{ root: 'flex flex-col flex-1 min-h-0', body: 'flex-1 min-h-0 overflow-y-auto' }">
<template #header>
<div class="flex items-center justify-between">
<h3 class="font-semibold">
{{ isEdit ? t('invoices.counterparty.editTitle') : t('invoices.counterparty.create') }}
</h3>
<UButton
icon="i-lucide-x"
variant="ghost"
color="neutral"
@click="open = false"
/>
</div>
</template>
<CounterpartyForm v-model="formValue" />
<template #footer>
<div class="flex items-center justify-end gap-2">
<span
v-if="!canSubmit"
class="mr-auto text-xs text-muted"
>
{{ t('invoices.validation.fillIn') }}: {{ t('invoices.party.name') }}
</span>
<UButton
:label="t('common.cancel')"
color="neutral"
variant="ghost"
@click="open = false"
/>
<UButton
:label="isEdit ? t('common.save') : t('invoices.counterparty.create')"
variant="soft"
:disabled="!canSubmit"
@click="submit"
/>
</div>
</template>
</UCard>
</template>
</UModal>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import CounterpartyForm from './CounterpartyForm.vue'
import { useTaskView } from '@/composables/useTaskView'
import { useCounterpartiesStore } from '@/stores/counterparties.store'
import type { CounterpartyItem } from 'taskview-api'
import type { CounterpartyFormValue } from '@/types/invoices.types'
const open = defineModel<boolean>('open', { default: false })
const props = defineProps<{
organizationId: number
counterparty?: CounterpartyItem | null
}>()
const emit = defineEmits<{
saved: [counterparty: CounterpartyItem]
}>()
const { t } = useI18n()
const toast = useToast()
const { isMobile } = useTaskView()
const counterpartiesStore = useCounterpartiesStore()
const isEdit = computed(() => !!props.counterparty)
function emptyValue(): CounterpartyFormValue {
return { kind: 'organization', name: '', legalName: '', address: '', email: '', phone: '', contactPerson: '', requisites: [] }
}
function fromCounterparty(counterparty: CounterpartyItem): CounterpartyFormValue {
return {
kind: counterparty.kind,
name: counterparty.name,
legalName: counterparty.legalName,
address: counterparty.address,
email: counterparty.email,
phone: counterparty.phone,
contactPerson: counterparty.contactPerson,
requisites: counterparty.requisites.map((item) => ({ ...item })),
}
}
const formValue = ref<CounterpartyFormValue>(props.counterparty ? fromCounterparty(props.counterparty) : emptyValue())
const canSubmit = computed(() => formValue.value.name.trim().length > 0)
watch(open, (value) => {
if (value) formValue.value = props.counterparty ? fromCounterparty(props.counterparty) : emptyValue()
})
async function submit() {
if (!canSubmit.value) return
const counterparty = isEdit.value && props.counterparty
? await counterpartiesStore.updateCounterparty({ counterpartyId: props.counterparty.id, value: formValue.value })
: await counterpartiesStore.createCounterparty({ organizationId: props.organizationId, value: formValue.value })
if (!counterparty) {
toast.add({ title: t('invoices.toasts.saveFailed'), color: 'error' })
return
}
toast.add({
title: t(isEdit.value ? 'invoices.counterparty.toasts.updated' : 'invoices.counterparty.toasts.created'),
color: 'success',
})
emit('saved', counterparty)
open.value = false
}
</script>
@@ -1,74 +0,0 @@
<template>
<UFormField
:label="label"
:required="required"
class="flex-1"
>
<UPopover v-model:open="open">
<UButton
icon="i-lucide-calendar"
:label="formatted"
color="neutral"
:variant="dateButtonVariant"
size="xl"
:disabled="disabled"
class="w-full"
:ui="dateButtonUi"
/>
<template #content>
<UCalendar
v-model="dateModel"
:min-value="toCalendarDate(minDate ?? null)"
:week-starts-on="weekStart"
class="p-2"
/>
</template>
</UPopover>
</UFormField>
</template>
<script setup lang="ts">
import { computed, ref, shallowRef, watch } from 'vue'
import { useDateFormat } from '@vueuse/core'
import { CalendarDate } from '@internationalized/date'
import { useWeekStart } from '@/composables/useWeekStart'
import { useInvoiceFieldStyle } from '@/composables/useInvoiceFieldStyle'
const model = defineModel<string | null>({ required: true })
const props = defineProps<{
label: string
minDate?: string | null
disabled?: boolean
required?: boolean
}>()
const weekStart = useWeekStart()
const { dateButtonVariant, dateButtonUi } = useInvoiceFieldStyle()
const open = ref(false)
function toCalendarDate(value: string | null): CalendarDate | undefined {
if (!value) return undefined
const [year, month, day] = value.split('-').map(Number)
if (!year || !month || !day) return undefined
return new CalendarDate(year, month, day)
}
const dateModel = shallowRef<CalendarDate | undefined>(toCalendarDate(model.value))
watch(dateModel, (value) => {
model.value = value ? value.toString() : null
if (value) open.value = false
})
watch(model, (value) => {
const next = toCalendarDate(value)
if (next?.toString() !== dateModel.value?.toString()) dateModel.value = next
})
const formatted = computed(() =>
dateModel.value
? useDateFormat(new Date(dateModel.value.toString()), 'DD MMM YYYY').value
: props.label,
)
</script>
@@ -1,149 +0,0 @@
<template>
<div class="flex flex-col gap-5">
<InvoiceFormHeader
v-model="model"
:projects="projects"
:sellers="sellers"
:counterparties="counterparties"
@select-project="selectProject"
@select-seller="selectSeller"
@create-seller="emit('create-seller')"
@create-counterparty="emit('create-counterparty')"
/>
<InvoiceFormDates v-model="model" />
<InvoiceTaskPicker
:goal-id="model.goalId"
:currency-code="model.currencyCode"
:selected-ids="selectedTaskIds"
@toggle="toggleTask"
/>
<InvoiceLinesEditor
v-model="model.lines"
:currency-code="model.currencyCode"
@add="addManualLine"
@remove="removeLine"
/>
<InvoiceTotalsEditor v-model="model" />
<div class="flex flex-col gap-3 lg:flex-row">
<UFormField
:label="t('invoices.fields.terms')"
class="flex-1"
>
<UTextarea
v-model="model.terms"
:placeholder="t('invoices.fields.termsPlaceholder')"
:rows="2"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
</UFormField>
<UFormField
:label="t('invoices.fields.notes')"
class="flex-1"
>
<UTextarea
v-model="model.notes"
:placeholder="t('invoices.fields.notesPlaceholder')"
:rows="2"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
</UFormField>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useInvoiceFieldStyle } from '@/composables/useInvoiceFieldStyle'
import InvoiceFormHeader from './InvoiceFormHeader.vue'
import InvoiceFormDates from './InvoiceFormDates.vue'
import InvoiceTaskPicker from './InvoiceTaskPicker.vue'
import InvoiceLinesEditor from './InvoiceLinesEditor.vue'
import InvoiceTotalsEditor from './InvoiceTotalsEditor.vue'
import { useSellersStore } from '@/stores/sellers.store'
import { useInvoicesStore } from '@/stores/invoices.store'
import type { InvoiceFormValue, InvoiceSelectOption, InvoiceTaskOption } from '@/types/invoices.types'
const model = defineModel<InvoiceFormValue>({ required: true })
defineProps<{
projects: InvoiceSelectOption[]
sellers: InvoiceSelectOption[]
counterparties: InvoiceSelectOption[]
}>()
const emit = defineEmits<{
'create-seller': []
'create-counterparty': []
}>()
const { t } = useI18n()
const { inputVariant, inputUi } = useInvoiceFieldStyle()
const sellersStore = useSellersStore()
const invoicesStore = useInvoicesStore()
const selectedTaskIds = computed(
() => new Set(model.value.lines.filter((line) => line.taskId !== null).map((line) => line.taskId as number)),
)
function selectProject(goalId: number | null) {
if (goalId === model.value.goalId) return
model.value.goalId = goalId
model.value.lines = model.value.lines.filter((line) => line.taskId === null)
if (goalId !== null && model.value.counterpartyId === null) {
model.value.counterpartyId = invoicesStore.lastCounterpartyForGoal(goalId)
}
}
function selectSeller(sellerId: number | null) {
model.value.sellerId = sellerId
const seller = sellerId === null ? null : sellersStore.byId(sellerId)
if (!seller) return
model.value.currencyCode = seller.currencyCode
if (!model.value.terms.trim()) model.value.terms = seller.defaultTerms
if (!model.value.taxNote.trim()) model.value.taxNote = seller.taxNote
}
function toggleTask(task: InvoiceTaskOption) {
const index = model.value.lines.findIndex((line) => line.taskId === task.id)
if (index >= 0) {
model.value.lines.splice(index, 1)
return
}
model.value.lines.push({
key: `task-${task.id}`,
taskId: task.id,
description: task.description,
unit: 'service',
quantity: 1,
unitPrice: task.amount,
})
}
function addManualLine() {
model.value.lines.push({
key: `manual-${Date.now()}-${model.value.lines.length}`,
taskId: null,
description: '',
unit: 'service',
quantity: 1,
unitPrice: 0,
})
}
function removeLine(key: string) {
model.value.lines = model.value.lines.filter((line) => line.key !== key)
}
</script>
@@ -1,73 +0,0 @@
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-3 lg:flex-row">
<InvoiceDateField
v-model="model.issueDate"
:label="t('invoices.fields.issueDate')"
required
/>
<UFormField
:label="t('invoices.fields.paymentTerms')"
class="flex-1"
>
<USelect
v-model="model.paymentTerms"
:items="termsItems"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
</UFormField>
<InvoiceDateField
v-model="model.dueDate"
:label="t('invoices.fields.dueDate')"
:min-date="model.issueDate"
:disabled="model.paymentTerms !== 'custom'"
/>
</div>
<div class="flex flex-col gap-3 lg:flex-row">
<InvoiceDateField
v-model="model.periodFrom"
:label="t('invoices.fields.periodFrom')"
/>
<InvoiceDateField
v-model="model.periodTo"
:label="t('invoices.fields.periodTo')"
:min-date="model.periodFrom"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useInvoiceFieldStyle } from '@/composables/useInvoiceFieldStyle'
import InvoiceDateField from './InvoiceDateField.vue'
import { addDays } from '@/helpers/invoiceDates'
import type { InvoicePaymentTerms } from 'taskview-api'
import { PAYMENT_TERMS_DAYS, type InvoiceFormValue } from '@/types/invoices.types'
const model = defineModel<InvoiceFormValue>({ required: true })
const { t } = useI18n()
const { inputVariant, inputUi } = useInvoiceFieldStyle()
const termsItems = computed(() =>
(Object.keys(PAYMENT_TERMS_DAYS) as InvoicePaymentTerms[]).map((value) => ({
label: t(`invoices.paymentTerms.${value}`),
value,
})),
)
watch(
() => [model.value.issueDate, model.value.paymentTerms] as const,
([issueDate, terms]) => {
const days = PAYMENT_TERMS_DAYS[terms]
if (days === null || !issueDate) return
model.value.dueDate = addDays({ date: issueDate, days })
},
{ immediate: true },
)
</script>
@@ -1,148 +0,0 @@
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-3 lg:flex-row">
<UFormField
:label="t('invoices.fields.number')"
required
class="flex-1"
>
<UInput
v-model="model.number"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
</UFormField>
<UFormField
:label="t('invoices.fields.reference')"
class="flex-1"
>
<UInput
v-model="model.reference"
:placeholder="t('invoices.fields.referencePlaceholder')"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
</UFormField>
<UFormField
:label="t('invoices.fields.currency')"
class="lg:w-40"
>
<USelectMenu
v-model="model.currencyCode"
:items="currencyItems"
value-key="value"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
</UFormField>
</div>
<div class="flex flex-col gap-3 lg:flex-row">
<UFormField
:label="t('invoices.seller.title')"
required
class="flex-1"
>
<div class="flex gap-2">
<USelectMenu
:model-value="model.sellerId ?? undefined"
:items="sellers"
value-key="value"
:placeholder="t('invoices.fields.sellerPlaceholder')"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
@update:model-value="emit('select-seller', $event ?? null)"
/>
<UButton
icon="i-lucide-plus"
color="neutral"
:variant="inputVariant"
size="xl"
:ui="inputUi"
@click="emit('create-seller')"
/>
</div>
</UFormField>
<UFormField
:label="t('invoices.counterparty.title')"
required
class="flex-1"
>
<div class="flex gap-2">
<USelectMenu
:model-value="model.counterpartyId ?? undefined"
:items="counterparties"
value-key="value"
:placeholder="t('invoices.fields.counterpartyPlaceholder')"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
@update:model-value="model.counterpartyId = $event ?? null"
/>
<UButton
icon="i-lucide-plus"
color="neutral"
:variant="inputVariant"
size="xl"
:ui="inputUi"
@click="emit('create-counterparty')"
/>
</div>
</UFormField>
<UFormField
:label="t('invoices.fields.project')"
required
class="flex-1"
>
<USelectMenu
:model-value="model.goalId ?? undefined"
:items="projects"
value-key="value"
:placeholder="t('invoices.fields.project')"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
@update:model-value="emit('select-project', $event ?? null)"
/>
</UFormField>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useInvoiceFieldStyle } from '@/composables/useInvoiceFieldStyle'
import { storeToRefs } from 'pinia'
import { useCurrenciesStore } from '@/stores/currencies.store'
import type { InvoiceFormValue, InvoiceSelectOption } from '@/types/invoices.types'
const model = defineModel<InvoiceFormValue>({ required: true })
defineProps<{
projects: InvoiceSelectOption[]
sellers: InvoiceSelectOption[]
counterparties: InvoiceSelectOption[]
}>()
const emit = defineEmits<{
'select-project': [goalId: number | null]
'select-seller': [sellerId: number | null]
'create-seller': []
'create-counterparty': []
}>()
const { t } = useI18n()
const { inputVariant, inputUi } = useInvoiceFieldStyle()
const { options: currencyItems } = storeToRefs(useCurrenciesStore())
</script>
@@ -1,204 +0,0 @@
<template>
<UModal
v-model:open="open"
:fullscreen="isMobile"
:ui="{ content: isMobile ? 'flex flex-col' : 'lg:max-w-4xl max-h-[90vh] flex flex-col' }"
>
<template #content>
<UCard :ui="{ root: 'flex flex-col flex-1 min-h-0', body: 'flex-1 min-h-0 overflow-y-auto' }">
<template #header>
<div class="flex items-center justify-between">
<h3 class="font-semibold">
{{ isEdit ? t('invoices.editTitle') : t('invoices.create') }}
</h3>
<UButton
icon="i-lucide-x"
variant="ghost"
color="neutral"
@click="open = false"
/>
</div>
</template>
<InvoiceForm
v-model="formValue"
:projects="projects"
:sellers="sellerOptions"
:counterparties="counterpartyOptions"
@create-seller="sellerModalOpen = true"
@create-counterparty="counterpartyModalOpen = true"
/>
<template #footer>
<div class="flex items-center justify-end gap-2">
<span
v-if="missing.length > 0"
class="mr-auto text-xs text-muted"
>
{{ t('invoices.validation.fillIn') }}: {{ missing.join(', ') }}
</span>
<UButton
:label="t('common.cancel')"
color="neutral"
variant="ghost"
@click="open = false"
/>
<UButton
:label="isEdit ? t('common.save') : t('invoices.create')"
variant="soft"
:disabled="!canSubmit"
:loading="saving"
@click="submit"
/>
</div>
</template>
</UCard>
<SellerFormModal
v-model:open="sellerModalOpen"
:organization-id="organizationId"
@saved="formValue.sellerId = $event.id"
/>
<CounterpartyFormModal
v-model:open="counterpartyModalOpen"
:organization-id="organizationId"
@saved="formValue.counterpartyId = $event.id"
/>
</template>
</UModal>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import InvoiceForm from './InvoiceForm.vue'
import SellerFormModal from './SellerFormModal.vue'
import CounterpartyFormModal from './CounterpartyFormModal.vue'
import { useTaskView } from '@/composables/useTaskView'
import { useInvoicesStore } from '@/stores/invoices.store'
import { useSellersStore } from '@/stores/sellers.store'
import { useCounterpartiesStore } from '@/stores/counterparties.store'
import { todayIso } from '@/helpers/invoiceDates'
import { useInvoiceValidation } from '@/composables/useInvoiceValidation'
import type { InvoiceItem } from 'taskview-api'
import type { InvoiceFormValue, InvoiceSelectOption } from '@/types/invoices.types'
const open = defineModel<boolean>('open', { default: false })
const props = defineProps<{
organizationId: number
projects: InvoiceSelectOption[]
invoice?: InvoiceItem | null
}>()
const emit = defineEmits<{
saved: [invoice: InvoiceItem]
}>()
const { t } = useI18n()
const toast = useToast()
const { isMobile } = useTaskView()
const invoicesStore = useInvoicesStore()
const sellersStore = useSellersStore()
const counterpartiesStore = useCounterpartiesStore()
const isEdit = computed(() => !!props.invoice)
const sellerModalOpen = ref(false)
const counterpartyModalOpen = ref(false)
const sellerOptions = computed<InvoiceSelectOption[]>(() =>
sellersStore.active.map((seller) => ({ label: seller.name, value: seller.id })),
)
const counterpartyOptions = computed<InvoiceSelectOption[]>(() =>
counterpartiesStore.active.map((item) => ({ label: item.name, value: item.id })),
)
function emptyValue(): InvoiceFormValue {
const sellers = sellersStore.active
const seller = sellers.length === 1 ? sellers[0] : null
return {
number: invoicesStore.suggestedNumber(),
reference: '',
goalId: props.projects.length === 1 ? props.projects[0].value : null,
sellerId: seller?.id ?? null,
counterpartyId: null,
currencyCode: seller?.currencyCode ?? 'USD',
issueDate: todayIso(),
paymentTerms: 'net14',
dueDate: null,
periodFrom: null,
periodTo: null,
lines: [],
discountType: 'percent',
discountValue: 0,
taxRate: 0,
taxExempt: false,
taxNote: seller?.taxNote ?? '',
notes: '',
terms: seller?.defaultTerms ?? '',
}
}
function fromInvoice(invoice: InvoiceItem): InvoiceFormValue {
return {
number: invoice.number,
reference: invoice.reference,
goalId: invoice.goalId,
sellerId: invoice.sellerId,
counterpartyId: invoice.counterpartyId,
currencyCode: invoice.currencyCode,
issueDate: invoice.issueDate,
paymentTerms: invoice.paymentTerms,
dueDate: invoice.dueDate,
periodFrom: invoice.periodFrom,
periodTo: invoice.periodTo,
discountType: invoice.discountType,
discountValue: invoice.discountValue,
taxRate: invoice.taxRate,
taxExempt: invoice.taxExempt,
taxNote: invoice.taxNote,
notes: invoice.notes,
terms: invoice.terms,
lines: invoice.lines.map((line) => ({
key: line.taskId !== null ? `task-${line.taskId}` : `manual-${line.id}`,
taskId: line.taskId,
description: line.description,
unit: line.unit,
quantity: line.quantity,
unitPrice: line.unitPrice,
})),
}
}
const formValue = ref<InvoiceFormValue>(props.invoice ? fromInvoice(props.invoice) : emptyValue())
const { missing, canSubmit } = useInvoiceValidation(formValue)
watch(open, async (value) => {
if (!value) return
await Promise.all([
sellersStore.sellers.length ? null : sellersStore.fetch(props.organizationId),
counterpartiesStore.counterparties.length ? null : counterpartiesStore.fetch(props.organizationId),
])
formValue.value = props.invoice ? fromInvoice(props.invoice) : emptyValue()
})
const saving = ref(false)
async function submit() {
if (!canSubmit.value || saving.value) return
saving.value = true
const result = isEdit.value && props.invoice
? await invoicesStore.updateInvoice({ invoiceId: props.invoice.id, value: formValue.value })
: await invoicesStore.createInvoice({ organizationId: props.organizationId, value: formValue.value })
saving.value = false
if ('error' in result) {
const key = result.error === 'duplicate_number' ? 'duplicateNumber' : result.error === 'not_draft' ? 'notDraft' : 'saveFailed'
toast.add({ title: t(`invoices.toasts.${key}`), color: 'error' })
return
}
toast.add({ title: t(isEdit.value ? 'invoices.toasts.updated' : 'invoices.toasts.created'), color: 'success' })
emit('saved', result.invoice)
open.value = false
}
</script>
@@ -1,140 +0,0 @@
<template>
<div :class="sectionClass">
<span class="text-sm font-medium text-default">{{ t('invoices.lines.title') }} <span class="text-error">*</span></span>
<p
v-if="lines.length === 0"
class="py-3 text-sm text-muted"
>
{{ t('invoices.lines.empty') }}
</p>
<div
v-else
class="hidden lg:grid lg:grid-cols-[1fr_9rem_5.5rem_7.5rem_6.5rem_2.5rem] gap-2 px-1 text-xs text-muted"
>
<span>{{ t('invoices.lines.description') }}</span>
<span>{{ t('invoices.lines.unit') }}</span>
<span>{{ t('invoices.lines.quantity') }}</span>
<span>{{ t('invoices.lines.price') }}</span>
<span class="text-right">{{ t('invoices.lines.amount') }}</span>
<span />
</div>
<div
v-for="line in lines"
:key="line.key"
class="grid grid-cols-[1fr_auto] gap-2 rounded-10 bg-elevated/40 p-2 lg:grid-cols-[1fr_9rem_5.5rem_7.5rem_6.5rem_2.5rem] lg:items-center lg:bg-transparent lg:p-0"
>
<UInput
v-model="line.description"
:placeholder="t('invoices.lines.description')"
:disabled="line.taskId !== null"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
<UButton
icon="i-lucide-x"
color="neutral"
variant="ghost"
size="sm"
class="lg:order-last"
@click="emit('remove', line.key)"
/>
<div class="col-span-2 grid grid-cols-[1.4fr_1fr_1fr] gap-2 lg:contents">
<UFormField
:label="t('invoices.lines.unit')"
:ui="fieldUi"
>
<USelect
v-model="line.unit"
:items="unitItems"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
</UFormField>
<UFormField
:label="t('invoices.lines.quantity')"
:ui="fieldUi"
>
<UInput
:model-value="String(line.quantity)"
type="number"
min="0"
step="0.01"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
@update:model-value="line.quantity = toNumber($event)"
/>
</UFormField>
<UFormField
:label="t('invoices.lines.price')"
:ui="fieldUi"
>
<UInput
:model-value="String(line.unitPrice)"
type="number"
min="0"
step="0.01"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
@update:model-value="line.unitPrice = toNumber($event)"
/>
</UFormField>
</div>
<span class="col-span-2 text-right text-sm tabular-nums text-default lg:col-span-1">
{{ formatMoney({ amount: lineAmount(line.quantity, line.unitPrice), currencyCode, locale }) }}
</span>
</div>
<div>
<UButton
icon="i-lucide-plus"
:label="t('invoices.lines.addManual')"
color="neutral"
variant="ghost"
size="sm"
@click="emit('add')"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useInvoiceFieldStyle } from '@/composables/useInvoiceFieldStyle'
import { formatMoney, lineAmount } from '@/helpers/money'
import { INVOICE_UNITS, type InvoiceFormLine } from '@/types/invoices.types'
const lines = defineModel<InvoiceFormLine[]>({ required: true })
defineProps<{
currencyCode: string
}>()
const emit = defineEmits<{
add: []
remove: [key: string]
}>()
const { t, locale } = useI18n()
const { inputVariant, inputUi, sectionClass } = useInvoiceFieldStyle()
const fieldUi = { label: 'lg:hidden', container: 'lg:mt-0' }
const unitItems = computed(() => INVOICE_UNITS.map((unit) => ({ label: t(`invoices.units.${unit}`), value: unit })))
function toNumber(value: string | number): number {
const parsed = Number(String(value).trim())
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
}
</script>
@@ -1,105 +0,0 @@
<template>
<div :class="sectionClass">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-default">{{ t('invoices.tasks.title') }}</span>
<span class="text-xs text-muted">{{ t('invoices.tasks.hint') }}</span>
</div>
<div
v-if="loading"
class="flex items-center gap-2 py-3 text-sm text-muted"
>
<UIcon
name="i-lucide-loader-circle"
class="size-4 animate-spin"
/>
{{ t('invoices.tasks.loading') }}
</div>
<p
v-else-if="goalId === null"
class="py-3 text-sm text-muted"
>
{{ t('invoices.tasks.selectProject') }}
</p>
<p
v-else-if="tasks.length === 0"
class="py-3 text-sm text-muted"
>
{{ t('invoices.tasks.empty') }}
</p>
<div
v-else
class="flex flex-col divide-y divide-default rounded-10 bg-elevated/50 max-h-64 overflow-y-auto"
>
<label
v-for="task in tasks"
:key="task.id"
class="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-elevated"
>
<UCheckbox
:model-value="selectedIds.has(task.id)"
@update:model-value="emit('toggle', task)"
/>
<span class="flex-1 truncate text-sm text-default">{{ task.description }}</span>
<UBadge
v-if="task.complete"
:label="t('invoices.tasks.done')"
color="success"
variant="subtle"
size="sm"
/>
<span class="text-sm tabular-nums text-muted">{{ formatMoney({ amount: task.amount, currencyCode, locale }) }}</span>
</label>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ALL_TASKS_LIST_ID, TaskIncomeType } from 'taskview-api'
import { $tvApi } from '@/plugins/axios'
import { logError } from '@/helpers/Helper'
import { formatMoney } from '@/helpers/money'
import { useInvoiceFieldStyle } from '@/composables/useInvoiceFieldStyle'
import type { InvoiceTaskOption } from '@/types/invoices.types'
const props = defineProps<{
goalId: number | null
currencyCode: string
selectedIds: Set<number>
}>()
const emit = defineEmits<{
toggle: [task: InvoiceTaskOption]
}>()
const { t, locale } = useI18n()
const { sectionClass } = useInvoiceFieldStyle()
const tasks = ref<InvoiceTaskOption[]>([])
const loading = ref(false)
async function loadTasks(goalId: number) {
loading.value = true
const result = await $tvApi.tasks
.fetch({ goalId, componentId: ALL_TASKS_LIST_ID, page: 0, showCompleted: 1, firstNew: 1, unlimited: true })
.catch(logError)
.finally(() => { loading.value = false })
if (!result) return
tasks.value = result
.filter((task) => task.transactionType === TaskIncomeType && task.amount !== null && task.amount !== '')
.map((task) => ({ id: task.id, description: task.description, amount: Number(task.amount), complete: task.complete }))
}
watch(
() => props.goalId,
(goalId) => {
tasks.value = []
if (goalId !== null) loadTasks(goalId)
},
{ immediate: true },
)
</script>
@@ -1,141 +0,0 @@
<template>
<div class="flex flex-col gap-3 lg:flex-row lg:items-start">
<div class="flex flex-1 flex-col gap-3">
<div class="flex gap-2">
<UFormField
:label="t('invoices.totals.discount')"
class="flex-1"
>
<UInput
:model-value="String(model.discountValue)"
type="number"
min="0"
step="0.01"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
@update:model-value="model.discountValue = toNumber($event)"
/>
</UFormField>
<UFormField
:label="t('invoices.totals.discountType')"
class="w-32"
>
<USelect
v-model="model.discountType"
:items="discountItems"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
</UFormField>
</div>
<div class="flex items-end gap-2">
<UFormField
:label="t('invoices.totals.taxRate')"
class="w-32"
>
<UInput
:model-value="String(model.taxRate)"
type="number"
min="0"
max="100"
step="0.01"
:disabled="model.taxExempt"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
@update:model-value="model.taxRate = Math.min(toNumber($event), 100)"
>
<template #trailing>
<span class="text-xs text-dimmed">%</span>
</template>
</UInput>
</UFormField>
<UFormField
:label="t('invoices.totals.taxExempt')"
class="flex-1"
>
<div class="flex h-8 items-center">
<USwitch v-model="model.taxExempt" />
</div>
</UFormField>
</div>
<UFormField
v-if="model.taxExempt"
:label="t('invoices.totals.taxNote')"
>
<UInput
v-model="model.taxNote"
:placeholder="t('invoices.seller.taxNotePlaceholder')"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
</UFormField>
</div>
<dl class="grid grid-cols-[1fr_auto] gap-x-6 gap-y-1 rounded-14 bg-elevated px-4 py-3 text-sm lg:w-72">
<dt class="text-muted">
{{ t('invoices.totals.subtotal') }}
</dt>
<dd class="text-right tabular-nums">
{{ money(totals.subtotal) }}
</dd>
<dt class="text-muted">
{{ t('invoices.totals.discount') }}
</dt>
<dd class="text-right tabular-nums">
{{ money(totals.discount) }}
</dd>
<dt class="text-muted">
{{ t('invoices.totals.tax') }}
</dt>
<dd class="text-right tabular-nums">
{{ money(totals.tax) }}
</dd>
<dt class="font-semibold text-default">
{{ t('invoices.total') }}
</dt>
<dd class="text-right font-semibold tabular-nums text-default">
{{ money(totals.total) }}
</dd>
</dl>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useInvoiceFieldStyle } from '@/composables/useInvoiceFieldStyle'
import { computeInvoiceTotals, formatMoney } from '@/helpers/money'
import type { InvoiceDiscountType } from 'taskview-api'
import type { InvoiceFormValue } from '@/types/invoices.types'
const model = defineModel<InvoiceFormValue>({ required: true })
const { t, locale } = useI18n()
const { inputVariant, inputUi } = useInvoiceFieldStyle()
const discountItems = computed(() => [
{ label: '%', value: 'percent' as InvoiceDiscountType },
{ label: model.value.currencyCode, value: 'amount' as InvoiceDiscountType },
])
const totals = computed(() => computeInvoiceTotals(model.value))
function toNumber(value: string | number): number {
const parsed = Number(String(value).trim())
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
}
function money(amount: number): string {
return formatMoney({ amount, currencyCode: model.value.currencyCode, locale: locale.value })
}
</script>
@@ -1,88 +0,0 @@
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-3 lg:flex-row">
<UFormField
:label="t('invoices.party.name')"
required
class="flex-1"
>
<UInput
v-model="model.name"
:placeholder="t('invoices.party.namePlaceholder')"
autofocus
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
</UFormField>
<UFormField
:label="t('invoices.party.legalName')"
class="flex-1"
>
<UInput
v-model="model.legalName"
:placeholder="t('invoices.party.legalNamePlaceholder')"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
</UFormField>
</div>
<UFormField :label="t('invoices.party.address')">
<UTextarea
v-model="model.address"
:rows="2"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
</UFormField>
<div class="flex flex-col gap-3 lg:flex-row">
<UFormField
:label="t('invoices.party.email')"
class="flex-1"
>
<UInput
v-model="model.email"
type="email"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
</UFormField>
<UFormField
:label="t('invoices.party.phone')"
class="flex-1"
>
<UInput
v-model="model.phone"
type="tel"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
</UFormField>
</div>
<RequisitesEditor v-model="model.requisites" />
</div>
</template>
<script setup lang="ts" generic="T extends PartyFields">
import { useI18n } from 'vue-i18n'
import { useInvoiceFieldStyle } from '@/composables/useInvoiceFieldStyle'
import RequisitesEditor from './RequisitesEditor.vue'
import type { PartyFields } from '@/types/invoices.types'
const model = defineModel<T>({ required: true })
const { t } = useI18n()
const { inputVariant, inputUi } = useInvoiceFieldStyle()
</script>
@@ -1,82 +0,0 @@
<template>
<div :class="sectionClass">
<span class="text-sm font-medium text-default">{{ t('invoices.requisites.title') }}</span>
<div
v-for="item in model"
:key="item.key"
class="grid grid-cols-[1fr_auto] gap-2 lg:grid-cols-[10rem_1fr_auto]"
>
<UInput
v-model="item.label"
:placeholder="t('invoices.requisites.label')"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
<UInput
v-model="item.value"
:placeholder="t('invoices.requisites.value')"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full col-start-1 lg:col-start-auto"
/>
<UButton
icon="i-lucide-x"
color="neutral"
variant="ghost"
size="sm"
class="row-start-1 col-start-2 lg:row-start-auto lg:col-start-auto"
@click="remove(item.key)"
/>
</div>
<div class="flex flex-wrap items-center gap-1">
<UButton
v-for="preset in availablePresets"
:key="preset"
:label="preset"
color="neutral"
variant="soft"
size="xs"
@click="add(preset)"
/>
<UButton
icon="i-lucide-plus"
:label="t('invoices.requisites.add')"
color="neutral"
variant="ghost"
size="xs"
@click="add('')"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useInvoiceFieldStyle } from '@/composables/useInvoiceFieldStyle'
import type { BillingRequisite } from 'taskview-api'
import { REQUISITE_PRESETS } from '@/types/invoices.types'
const model = defineModel<BillingRequisite[]>({ required: true })
const { t } = useI18n()
const { inputVariant, inputUi, sectionClass } = useInvoiceFieldStyle()
const availablePresets = computed(() => {
const used = new Set(model.value.map((item) => item.label.trim().toLowerCase()))
return REQUISITE_PRESETS.filter((preset) => !used.has(preset.toLowerCase()))
})
function add(label: string) {
model.value.push({ key: `req-${Date.now()}-${model.value.length}`, label, value: '' })
}
function remove(key: string) {
model.value = model.value.filter((item) => item.key !== key)
}
</script>
@@ -1,78 +0,0 @@
<template>
<div class="flex flex-col gap-4">
<PartyFields v-model="model" />
<div class="flex flex-col gap-3 lg:flex-row">
<UFormField
:label="t('invoices.seller.logoUrl')"
class="flex-1"
>
<UInput
v-model="model.logoUrl"
placeholder="https://"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
</UFormField>
<UFormField
:label="t('invoices.fields.currency')"
class="lg:w-40"
>
<USelectMenu
v-model="model.currencyCode"
:items="currencyItems"
value-key="value"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
</UFormField>
</div>
<BankDetailsFields v-model="model.bank" />
<UFormField :label="t('invoices.seller.taxNote')">
<UInput
v-model="model.taxNote"
:placeholder="t('invoices.seller.taxNotePlaceholder')"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
</UFormField>
<UFormField :label="t('invoices.seller.defaultTerms')">
<UTextarea
v-model="model.defaultTerms"
:placeholder="t('invoices.seller.defaultTermsPlaceholder')"
:rows="2"
size="xl"
:variant="inputVariant"
:ui="inputUi"
class="w-full"
/>
</UFormField>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useInvoiceFieldStyle } from '@/composables/useInvoiceFieldStyle'
import PartyFields from './PartyFields.vue'
import BankDetailsFields from './BankDetailsFields.vue'
import { storeToRefs } from 'pinia'
import { useCurrenciesStore } from '@/stores/currencies.store'
import type { SellerFormValue } from '@/types/invoices.types'
const model = defineModel<SellerFormValue>({ required: true })
const { t } = useI18n()
const { inputVariant, inputUi } = useInvoiceFieldStyle()
const { options: currencyItems } = storeToRefs(useCurrenciesStore())
</script>
@@ -1,131 +0,0 @@
<template>
<UModal
v-model:open="open"
:fullscreen="isMobile"
:ui="{ content: isMobile ? 'flex flex-col' : 'lg:max-w-2xl max-h-[90vh] flex flex-col' }"
>
<template #content>
<UCard :ui="{ root: 'flex flex-col flex-1 min-h-0', body: 'flex-1 min-h-0 overflow-y-auto' }">
<template #header>
<div class="flex items-center justify-between">
<h3 class="font-semibold">
{{ isEdit ? t('invoices.seller.editTitle') : t('invoices.seller.create') }}
</h3>
<UButton
icon="i-lucide-x"
variant="ghost"
color="neutral"
@click="open = false"
/>
</div>
</template>
<SellerForm v-model="formValue" />
<template #footer>
<div class="flex items-center justify-end gap-2">
<span
v-if="!canSubmit"
class="mr-auto text-xs text-muted"
>
{{ t('invoices.validation.fillIn') }}: {{ t('invoices.party.name') }}
</span>
<UButton
:label="t('common.cancel')"
color="neutral"
variant="ghost"
@click="open = false"
/>
<UButton
:label="isEdit ? t('common.save') : t('invoices.seller.create')"
variant="soft"
:disabled="!canSubmit"
@click="submit"
/>
</div>
</template>
</UCard>
</template>
</UModal>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import SellerForm from './SellerForm.vue'
import { useTaskView } from '@/composables/useTaskView'
import { useSellersStore } from '@/stores/sellers.store'
import type { SellerItem } from 'taskview-api'
import type { SellerFormValue } from '@/types/invoices.types'
const open = defineModel<boolean>('open', { default: false })
const props = defineProps<{
organizationId: number
seller?: SellerItem | null
}>()
const emit = defineEmits<{
saved: [seller: SellerItem]
}>()
const { t } = useI18n()
const toast = useToast()
const { isMobile } = useTaskView()
const sellersStore = useSellersStore()
const isEdit = computed(() => !!props.seller)
function emptyValue(): SellerFormValue {
return {
name: '',
legalName: '',
address: '',
email: '',
phone: '',
requisites: [],
logoUrl: '',
currencyCode: 'USD',
bank: { bankName: '', accountNumber: '', iban: '', swift: '', correspondentAccount: '' },
defaultTerms: '',
taxNote: '',
}
}
function fromSeller(seller: SellerItem): SellerFormValue {
return {
name: seller.name,
legalName: seller.legalName,
address: seller.address,
email: seller.email,
phone: seller.phone,
requisites: seller.requisites.map((item) => ({ ...item })),
logoUrl: seller.logoUrl,
currencyCode: seller.currencyCode,
bank: { ...seller.bank },
defaultTerms: seller.defaultTerms,
taxNote: seller.taxNote,
}
}
const formValue = ref<SellerFormValue>(props.seller ? fromSeller(props.seller) : emptyValue())
const canSubmit = computed(() => formValue.value.name.trim().length > 0)
watch(open, (value) => {
if (value) formValue.value = props.seller ? fromSeller(props.seller) : emptyValue()
})
async function submit() {
if (!canSubmit.value) return
const seller = isEdit.value && props.seller
? await sellersStore.updateSeller({ sellerId: props.seller.id, value: formValue.value })
: await sellersStore.createSeller({ organizationId: props.organizationId, value: formValue.value })
if (!seller) {
toast.add({ title: t('invoices.toasts.saveFailed'), color: 'error' })
return
}
toast.add({ title: t(isEdit.value ? 'invoices.seller.toasts.updated' : 'invoices.seller.toasts.created'), color: 'success' })
emit('saved', seller)
open.value = false
}
</script>
@@ -20,19 +20,13 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import { useOrganizationStore } from '@/stores/organization.store'
import { useOrgPermissions } from '@/composables/useOrgPermissions'
import TvGoalLikeItem from '@/components/features/base/TvGoalLikeItem.vue' import TvGoalLikeItem from '@/components/features/base/TvGoalLikeItem.vue'
const { t } = useI18n() const { t } = useI18n()
const route = useRoute() const route = useRoute()
const { currentOrg } = storeToRefs(useOrganizationStore())
const { isAdmin } = useOrgPermissions(() => currentOrg.value)
const links = computed(() => [ const links = computed(() => [
{ name: 'analytics', icon: 'i-lucide-bar-chart-3', label: t('userMenu.analytics') }, { name: 'analytics', icon: 'i-lucide-bar-chart-3', label: t('userMenu.analytics') },
...(isAdmin.value ? [{ name: 'invoices', icon: 'i-lucide-receipt', label: t('userMenu.invoices') }] : []),
{ name: 'time-reports', icon: 'i-lucide-clock-4', label: t('userMenu.timeReports') }, { name: 'time-reports', icon: 'i-lucide-clock-4', label: t('userMenu.timeReports') },
]) ])
</script> </script>
@@ -1,13 +0,0 @@
// Field look shared by every invoices form: xl controls, soft variant (filled
// background, no ring) in both themes, 14px radius.
export function useInvoiceFieldStyle() {
const inputVariant = 'soft' as const
const inputUi = { base: 'rounded-14' }
const dateButtonVariant = 'soft' as const
const dateButtonUi = { base: 'rounded-14 justify-start', leadingIcon: 'size-4.5' }
const sectionClass = 'flex flex-col gap-3 rounded-14 p-3 shadow-sm dark:bg-tv-ui-bg-elevated'
return { inputVariant, inputUi, dateButtonVariant, dateButtonUi, sectionClass }
}
@@ -1,35 +0,0 @@
import { useI18n } from 'vue-i18n'
import type { InvoiceMissingRequisite, InvoiceStatus } from 'taskview-api'
import type { InvoiceTransitionResult } from '@/types/invoices.types'
const REQUISITE_KEYS: Record<InvoiceMissingRequisite, string> = {
'seller.name': 'sellerName',
'seller.address': 'sellerAddress',
'seller.bank': 'sellerBank',
'counterparty.name': 'counterpartyName',
'counterparty.address': 'counterpartyAddress',
lines: 'lines',
}
export function useInvoiceTransitionFeedback() {
const { t } = useI18n()
const toast = useToast()
function report(result: InvoiceTransitionResult, status: InvoiceStatus) {
if ('invoice' in result) {
toast.add({ title: t(`invoices.toasts.transition.${status}`), color: 'success' })
return
}
if (result.error === 'missing_requisites') {
const items = result.missing.map((key) => t(`invoices.requisiteNames.${REQUISITE_KEYS[key]}`)).join(', ')
toast.add({ title: t('invoices.toasts.missingRequisites'), description: items, color: 'warning' })
return
}
toast.add({
title: t(result.error === 'invalid_transition' ? 'invoices.toasts.invalidTransition' : 'invoices.toasts.saveFailed'),
color: 'error',
})
}
return { report }
}
@@ -1,24 +0,0 @@
import { computed, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { InvoiceFormValue } from '@/types/invoices.types'
export function useInvoiceValidation(value: Ref<InvoiceFormValue>) {
const { t } = useI18n()
const missing = computed(() => {
const form = value.value
const items: string[] = []
if (!form.number.trim()) items.push(t('invoices.fields.number'))
if (form.sellerId === null) items.push(t('invoices.seller.title'))
if (form.counterpartyId === null) items.push(t('invoices.counterparty.title'))
if (form.goalId === null) items.push(t('invoices.fields.project'))
if (form.issueDate === null) items.push(t('invoices.fields.issueDate'))
if (form.lines.length === 0) items.push(t('invoices.validation.lines'))
else if (form.lines.some((line) => !line.description.trim())) items.push(t('invoices.validation.lineDescriptions'))
return items
})
const canSubmit = computed(() => missing.value.length === 0)
return { missing, canSubmit }
}
-5
View File
@@ -1,5 +0,0 @@
import { isAxiosError } from 'axios'
export function httpStatusOf(error: unknown): number | null {
return isAxiosError(error) ? (error.response?.status ?? null) : null
}
-16
View File
@@ -1,16 +0,0 @@
import type { InvoiceItem } from 'taskview-api'
import type { AddDaysArgs } from '@/types/invoices.types'
export function todayIso(): string {
return new Date().toISOString().slice(0, 10)
}
export function isInvoiceOverdue(invoice: Pick<InvoiceItem, 'status' | 'dueDate'>): boolean {
return invoice.status === 'issued' && invoice.dueDate !== null && invoice.dueDate < todayIso()
}
export function addDays({ date, days }: AddDaysArgs): string {
const [year, month, day] = date.split('-').map(Number)
const next = new Date(Date.UTC(year, month - 1, day + days))
return next.toISOString().slice(0, 10)
}
-26
View File
@@ -1,26 +0,0 @@
import type { ComputeTotalsArgs, FormatMoneyArgs, InvoiceTotals } from '@/types/invoices.types'
function round2(value: number): number {
return Math.round(value * 100) / 100
}
export function formatMoney({ amount, currencyCode, locale }: FormatMoneyArgs): string {
try {
return new Intl.NumberFormat(locale, { style: 'currency', currency: currencyCode }).format(amount)
} catch {
return `${amount.toFixed(2)} ${currencyCode}`
}
}
export function lineAmount(quantity: number, unitPrice: number): number {
return round2(quantity * unitPrice)
}
export function computeInvoiceTotals({ lines, discountType, discountValue, taxRate, taxExempt }: ComputeTotalsArgs): InvoiceTotals {
const subtotal = round2(lines.reduce((sum, line) => sum + lineAmount(line.quantity, line.unitPrice), 0))
const rawDiscount = discountType === 'percent' ? subtotal * (discountValue / 100) : discountValue
const discount = round2(Math.min(Math.max(rawDiscount, 0), subtotal))
const taxable = round2(subtotal - discount)
const tax = taxExempt ? 0 : round2(taxable * (taxRate / 100))
return { subtotal, discount, taxable, tax, total: round2(taxable + tax) }
}
-186
View File
@@ -638,7 +638,6 @@ export default {
organizations: 'Organisationen', organizations: 'Organisationen',
analytics: 'Analytik', analytics: 'Analytik',
timeReports: 'Zeitberichte', timeReports: 'Zeitberichte',
invoices: 'Rechnungen',
switchOrganization: 'Organisation wechseln', switchOrganization: 'Organisation wechseln',
logout: 'Abmelden', logout: 'Abmelden',
logoutFailed: 'Abmeldung fehlgeschlagen', logoutFailed: 'Abmeldung fehlgeschlagen',
@@ -1084,191 +1083,6 @@ export default {
noPermissionHint: 'Bitten Sie einen Projekt-Administrator um die Berechtigung zur Anzeige der Zeiterfassung, um auf Berichte zuzugreifen.', noPermissionHint: 'Bitten Sie einen Projekt-Administrator um die Berechtigung zur Anzeige der Zeiterfassung, um auf Berichte zuzugreifen.',
}, },
}, },
invoices: {
actions: {
issue: 'Ausstellen',
markPaid: 'Als bezahlt markieren',
unmarkPaid: 'Bezahlt-Markierung entfernen',
void: 'Stornieren',
reissue: 'Korrigieren',
},
overdue: 'Überfällig',
replaces: 'Ersatz',
replacesLink: 'Ersetzt die vorherige Rechnung',
dates: {
issued: 'Ausgestellt',
paid: 'Bezahlt',
voided: 'Storniert',
},
requisiteNames: {
sellerName: 'Firmenname',
sellerAddress: 'Firmenadresse',
sellerBank: 'Kontonummer oder IBAN der Firma',
counterpartyName: 'Kundenname',
counterpartyAddress: 'Kundenadresse',
lines: 'mindestens eine Position',
},
archive: 'Archivieren',
unarchive: 'Aus dem Archiv holen',
showArchived: 'Archivierte anzeigen',
archivedBadge: 'Archiviert',
page: {
title: 'Rechnungen',
empty: 'Noch keine Rechnungen',
emptyHint: 'Ausgestellte und geplante Rechnungen für die Projekte der Organisation erscheinen hier.',
noPermission: 'Kein Zugriff auf Rechnungen',
noPermissionHint: 'Rechnungen sind für den Eigentümer und die Administratoren der Organisation verfügbar.',
},
create: 'Rechnung erstellen',
editTitle: 'Rechnung bearbeiten',
total: 'Gesamt',
fields: {
number: 'Nummer',
reference: 'Vertrag / Bestellung',
referencePlaceholder: 'Vertrags- oder Bestellnummer',
project: 'Projekt',
currency: 'Währung',
sellerPlaceholder: 'Firma auswählen',
counterpartyPlaceholder: 'Kunde auswählen',
issueDate: 'Rechnungsdatum',
paymentTerms: 'Zahlungsbedingungen',
dueDate: 'Fälligkeitsdatum',
periodFrom: 'Zeitraum von',
periodTo: 'Zeitraum bis',
notes: 'Notizen',
notesPlaceholder: 'Kommentar für den Empfänger',
terms: 'Bedingungen',
termsPlaceholder: 'Zahlungsablauf, Verzugsgebühren, sonstige Bedingungen',
},
paymentTerms: {
on_receipt: 'Sofort fällig',
net7: '7 Tage',
net14: '14 Tage',
net30: '30 Tage',
custom: 'Eigenes Datum',
},
units: {
service: 'Leistung',
hours: 'Stunden',
pcs: 'Stück',
},
tasks: {
title: 'Projektaufgaben',
hint: 'Angezeigt werden Aufgaben mit Einnahmebetrag',
loading: 'Aufgaben werden geladen…',
selectProject: 'Zuerst ein Projekt auswählen',
empty: 'Keine Aufgaben mit Einnahmebetrag in diesem Projekt',
done: 'Erledigt',
},
lines: {
title: 'Positionen',
empty: 'Aufgaben oben markieren oder Position manuell hinzufügen',
description: 'Beschreibung',
unit: 'Einheit',
quantity: 'Menge',
price: 'Preis',
amount: 'Betrag',
addManual: 'Position hinzufügen',
},
totals: {
subtotal: 'Zwischensumme',
discount: 'Rabatt',
discountType: 'Art',
taxRate: 'Steuer',
tax: 'Steuer',
taxExempt: 'Steuerfrei',
taxNote: 'Begründung',
},
requisites: {
title: 'Registrierungsdaten',
label: 'Bezeichnung',
value: 'Wert',
add: 'Weitere Angabe',
},
party: {
name: 'Name',
namePlaceholder: 'Anzeige in Listen',
legalName: 'Rechtlicher Name',
legalNamePlaceholder: 'Wie im Vertrag',
address: 'Adresse',
email: 'E-Mail',
phone: 'Telefon',
},
bank: {
title: 'Bankverbindung',
bankName: 'Bank',
accountNumber: 'Kontonummer',
iban: 'IBAN',
swift: 'SWIFT / BIC',
correspondentAccount: 'Korrespondenzkonto',
},
seller: {
title: 'Meine Firmen',
create: 'Firma hinzufügen',
editTitle: 'Firma bearbeiten',
empty: 'Noch keine Firmen',
emptyHint: 'Firma hinzufügen, die Rechnungen ausstellt: Angaben, Bank, Standardbedingungen.',
logoUrl: 'Logo (URL)',
taxNote: 'Steuerhinweis',
taxNotePlaceholder: 'Umsatzsteuerfrei',
defaultTerms: 'Standardbedingungen',
defaultTermsPlaceholder: 'Werden in jede neue Rechnung übernommen',
toasts: {
created: 'Firma hinzugefügt',
updated: 'Firma gespeichert',
},
},
counterparty: {
title: 'Kunden',
create: 'Kunde hinzufügen',
editTitle: 'Kunde bearbeiten',
empty: 'Noch keine Kunden',
emptyHint: 'Kunden hinzufügen, denen Rechnungen gestellt werden.',
kind: 'Typ',
kinds: {
organization: 'Organisation',
person: 'Privatperson',
},
contactPerson: 'Ansprechpartner',
toasts: {
created: 'Kunde hinzugefügt',
updated: 'Kunde gespeichert',
},
},
preview: {
download: 'PDF herunterladen',
pdfFailed: 'PDF konnte nicht erstellt werden',
back: 'Zur Liste',
notFound: 'Rechnung nicht gefunden',
},
validation: {
fillIn: 'Ausfüllen',
lines: 'mindestens eine Position',
lineDescriptions: 'Beschreibung für jede Position',
},
status: {
draft: 'Entwurf',
issued: 'Ausgestellt',
paid: 'Bezahlt',
void: 'Storniert',
},
toasts: {
missingRequisites: 'Vor dem Ausstellen ausfüllen',
invalidTransition: 'Dieser Statuswechsel ist nicht erlaubt',
notDraft: 'Eine ausgestellte Rechnung kann nicht bearbeitet werden. „Korrigieren“ verwenden',
transition: {
issued: 'Rechnung ausgestellt',
paid: 'Als bezahlt markiert',
void: 'Rechnung storniert, Ersatzentwurf erstellt',
draft: 'Entwurf',
},
inUse: 'Löschen nicht möglich: es gibt Rechnungen. Stattdessen archivieren.',
duplicateNumber: 'Eine Rechnung mit dieser Nummer existiert bereits',
saveFailed: 'Speichern fehlgeschlagen',
created: 'Rechnung erstellt',
updated: 'Rechnung gespeichert',
},
},
sprints: { sprints: {
title: 'Sprints', title: 'Sprints',
create: 'Neuer Sprint', create: 'Neuer Sprint',
-186
View File
@@ -652,7 +652,6 @@ export default {
organizations: 'Organizations', organizations: 'Organizations',
analytics: 'Analytics', analytics: 'Analytics',
timeReports: 'Time reports', timeReports: 'Time reports',
invoices: 'Invoices',
switchOrganization: 'Switch organization', switchOrganization: 'Switch organization',
logout: 'Log out', logout: 'Log out',
logoutFailed: 'Logout failed', logoutFailed: 'Logout failed',
@@ -1098,191 +1097,6 @@ export default {
noPermissionHint: 'Ask a project admin for the time-tracking view permission to access reports.', noPermissionHint: 'Ask a project admin for the time-tracking view permission to access reports.',
}, },
}, },
invoices: {
actions: {
issue: 'Issue',
markPaid: 'Mark paid',
unmarkPaid: 'Unmark paid',
void: 'Void',
reissue: 'Correct',
},
overdue: 'Overdue',
replaces: 'Replacement',
replacesLink: 'Replaces the previous invoice',
dates: {
issued: 'Issued',
paid: 'Paid',
voided: 'Voided',
},
requisiteNames: {
sellerName: 'company name',
sellerAddress: 'company address',
sellerBank: 'company account number or IBAN',
counterpartyName: 'client name',
counterpartyAddress: 'client address',
lines: 'at least one line',
},
archive: 'Archive',
unarchive: 'Restore from archive',
showArchived: 'Show archived',
archivedBadge: 'Archived',
page: {
title: 'Invoices',
empty: 'No invoices yet',
emptyHint: 'Issued and scheduled invoices for the organizations projects will appear here.',
noPermission: 'No access to invoices',
noPermissionHint: 'Invoices are available to the organization owner and admins.',
},
create: 'Create invoice',
editTitle: 'Edit invoice',
total: 'Total',
fields: {
number: 'Number',
reference: 'Contract / PO',
referencePlaceholder: 'Contract or purchase order number',
project: 'Project',
currency: 'Currency',
sellerPlaceholder: 'Select a company',
counterpartyPlaceholder: 'Select a client',
issueDate: 'Issue date',
paymentTerms: 'Payment terms',
dueDate: 'Due date',
periodFrom: 'Period from',
periodTo: 'Period to',
notes: 'Notes',
notesPlaceholder: 'Comment for the counterparty',
terms: 'Terms',
termsPlaceholder: 'Payment procedure, late fees, other terms',
},
paymentTerms: {
on_receipt: 'Due on receipt',
net7: 'Net 7',
net14: 'Net 14',
net30: 'Net 30',
custom: 'Custom date',
},
units: {
service: 'service',
hours: 'hours',
pcs: 'pieces',
},
tasks: {
title: 'Project tasks',
hint: 'Tasks with an income amount are shown',
loading: 'Loading tasks…',
selectProject: 'Select a project first',
empty: 'No tasks with an income amount in this project',
done: 'Done',
},
lines: {
title: 'Lines',
empty: 'Tick tasks above or add a line manually',
description: 'Description',
unit: 'Unit',
quantity: 'Qty',
price: 'Price',
amount: 'Amount',
addManual: 'Add line',
},
totals: {
subtotal: 'Subtotal',
discount: 'Discount',
discountType: 'Type',
taxRate: 'Tax',
tax: 'Tax',
taxExempt: 'No tax',
taxNote: 'Reason',
},
requisites: {
title: 'Registration details',
label: 'Label',
value: 'Value',
add: 'Other detail',
},
party: {
name: 'Name',
namePlaceholder: 'Shown in lists',
legalName: 'Legal name',
legalNamePlaceholder: 'As in the contract',
address: 'Address',
email: 'Email',
phone: 'Phone',
},
bank: {
title: 'Bank details',
bankName: 'Bank',
accountNumber: 'Account number',
iban: 'IBAN',
swift: 'SWIFT / BIC',
correspondentAccount: 'Correspondent account',
},
seller: {
title: 'My companies',
create: 'Add company',
editTitle: 'Edit company',
empty: 'No companies yet',
emptyHint: 'Add a company that issues invoices: details, bank, default terms.',
logoUrl: 'Logo (URL)',
taxNote: 'Tax note',
taxNotePlaceholder: 'VAT not applicable',
defaultTerms: 'Default terms',
defaultTermsPlaceholder: 'Prefilled into every new invoice',
toasts: {
created: 'Company added',
updated: 'Company saved',
},
},
counterparty: {
title: 'Clients',
create: 'Add client',
editTitle: 'Edit client',
empty: 'No clients yet',
emptyHint: 'Add the clients you invoice.',
kind: 'Type',
kinds: {
organization: 'Organization',
person: 'Individual',
},
contactPerson: 'Contact person',
toasts: {
created: 'Client added',
updated: 'Client saved',
},
},
preview: {
download: 'Download PDF',
pdfFailed: 'Could not render the PDF',
back: 'Back to list',
notFound: 'Invoice not found',
},
validation: {
fillIn: 'Fill in',
lines: 'at least one line',
lineDescriptions: 'a description for every line',
},
status: {
draft: 'Draft',
issued: 'Issued',
paid: 'Paid',
void: 'Void',
},
toasts: {
missingRequisites: 'Fill in before issuing',
invalidTransition: 'This status change is not allowed',
notDraft: 'An issued invoice cannot be edited. Use “Correct”',
transition: {
issued: 'Invoice issued',
paid: 'Marked as paid',
void: 'Invoice voided, a replacement draft was created',
draft: 'Draft',
},
inUse: 'Cannot delete: there are invoices. Archive it instead.',
duplicateNumber: 'An invoice with this number already exists',
saveFailed: 'Could not save',
created: 'Invoice created',
updated: 'Invoice saved',
},
},
sprints: { sprints: {
title: 'Sprints', title: 'Sprints',
create: 'New sprint', create: 'New sprint',
-186
View File
@@ -638,7 +638,6 @@ export default {
organizations: 'Organizaciones', organizations: 'Organizaciones',
analytics: 'Analíticas', analytics: 'Analíticas',
timeReports: 'Informes de tiempo', timeReports: 'Informes de tiempo',
invoices: 'Facturas',
switchOrganization: 'Cambiar de organización', switchOrganization: 'Cambiar de organización',
logout: 'Cerrar sesión', logout: 'Cerrar sesión',
logoutFailed: 'Error al cerrar sesión', logoutFailed: 'Error al cerrar sesión',
@@ -1084,191 +1083,6 @@ export default {
noPermissionHint: 'Pide a un administrador del proyecto el permiso de visualización de registro de tiempo para acceder a los informes.', noPermissionHint: 'Pide a un administrador del proyecto el permiso de visualización de registro de tiempo para acceder a los informes.',
}, },
}, },
invoices: {
actions: {
issue: 'Emitir',
markPaid: 'Marcar pagada',
unmarkPaid: 'Quitar marca de pago',
void: 'Anular',
reissue: 'Corregir',
},
overdue: 'Vencida',
replaces: 'Sustitución',
replacesLink: 'Sustituye a la factura anterior',
dates: {
issued: 'Emitida',
paid: 'Pagada',
voided: 'Anulada',
},
requisiteNames: {
sellerName: 'nombre de la empresa',
sellerAddress: 'dirección de la empresa',
sellerBank: 'número de cuenta o IBAN de la empresa',
counterpartyName: 'nombre del cliente',
counterpartyAddress: 'dirección del cliente',
lines: 'al menos una línea',
},
archive: 'Archivar',
unarchive: 'Restaurar del archivo',
showArchived: 'Mostrar archivados',
archivedBadge: 'Archivado',
page: {
title: 'Facturas',
empty: 'Aún no hay facturas',
emptyHint: 'Aquí aparecerán las facturas emitidas y programadas de los proyectos de la organización.',
noPermission: 'Sin acceso a las facturas',
noPermissionHint: 'Las facturas están disponibles para el propietario y los administradores de la organización.',
},
create: 'Crear factura',
editTitle: 'Editar factura',
total: 'Total',
fields: {
number: 'Número',
reference: 'Contrato / PO',
referencePlaceholder: 'Número de contrato o pedido',
project: 'Proyecto',
currency: 'Moneda',
sellerPlaceholder: 'Selecciona una empresa',
counterpartyPlaceholder: 'Selecciona un cliente',
issueDate: 'Fecha de emisión',
paymentTerms: 'Condiciones de pago',
dueDate: 'Fecha de vencimiento',
periodFrom: 'Periodo desde',
periodTo: 'Periodo hasta',
notes: 'Notas',
notesPlaceholder: 'Comentario para el cliente',
terms: 'Condiciones',
termsPlaceholder: 'Forma de pago, recargos por demora, otras condiciones',
},
paymentTerms: {
on_receipt: 'Al recibir',
net7: '7 días',
net14: '14 días',
net30: '30 días',
custom: 'Fecha propia',
},
units: {
service: 'servicio',
hours: 'horas',
pcs: 'unidades',
},
tasks: {
title: 'Tareas del proyecto',
hint: 'Se muestran las tareas con importe de ingreso',
loading: 'Cargando tareas…',
selectProject: 'Selecciona primero un proyecto',
empty: 'No hay tareas con importe de ingreso en este proyecto',
done: 'Hecha',
},
lines: {
title: 'Líneas',
empty: 'Marca tareas arriba o añade una línea manualmente',
description: 'Descripción',
unit: 'Ud.',
quantity: 'Cant.',
price: 'Precio',
amount: 'Importe',
addManual: 'Añadir línea',
},
totals: {
subtotal: 'Subtotal',
discount: 'Descuento',
discountType: 'Tipo',
taxRate: 'Impuesto',
tax: 'Impuesto',
taxExempt: 'Sin impuesto',
taxNote: 'Motivo',
},
requisites: {
title: 'Datos de registro',
label: 'Etiqueta',
value: 'Valor',
add: 'Otro dato',
},
party: {
name: 'Nombre',
namePlaceholder: 'Como se muestra en las listas',
legalName: 'Razón social',
legalNamePlaceholder: 'Como en el contrato',
address: 'Dirección',
email: 'Email',
phone: 'Teléfono',
},
bank: {
title: 'Datos bancarios',
bankName: 'Banco',
accountNumber: 'Número de cuenta',
iban: 'IBAN',
swift: 'SWIFT / BIC',
correspondentAccount: 'Cuenta corresponsal',
},
seller: {
title: 'Mis empresas',
create: 'Añadir empresa',
editTitle: 'Editar empresa',
empty: 'Aún no hay empresas',
emptyHint: 'Añade la empresa que emite las facturas: datos, banco, condiciones por defecto.',
logoUrl: 'Logotipo (URL)',
taxNote: 'Nota fiscal',
taxNotePlaceholder: 'IVA no aplicable',
defaultTerms: 'Condiciones por defecto',
defaultTermsPlaceholder: 'Se añaden a cada nueva factura',
toasts: {
created: 'Empresa añadida',
updated: 'Empresa guardada',
},
},
counterparty: {
title: 'Clientes',
create: 'Añadir cliente',
editTitle: 'Editar cliente',
empty: 'Aún no hay clientes',
emptyHint: 'Añade los clientes a los que facturas.',
kind: 'Tipo',
kinds: {
organization: 'Organización',
person: 'Persona física',
},
contactPerson: 'Persona de contacto',
toasts: {
created: 'Cliente añadido',
updated: 'Cliente guardado',
},
},
preview: {
download: 'Descargar PDF',
pdfFailed: 'No se pudo generar el PDF',
back: 'Volver a la lista',
notFound: 'Factura no encontrada',
},
validation: {
fillIn: 'Completa',
lines: 'al menos una línea',
lineDescriptions: 'descripción en cada línea',
},
status: {
draft: 'Borrador',
issued: 'Emitida',
paid: 'Pagada',
void: 'Anulada',
},
toasts: {
missingRequisites: 'Completa antes de emitir',
invalidTransition: 'Este cambio de estado no está permitido',
notDraft: 'Una factura emitida no se puede editar. Usa «Corregir»',
transition: {
issued: 'Factura emitida',
paid: 'Marcada como pagada',
void: 'Factura anulada, se creó un borrador de sustitución',
draft: 'Borrador',
},
inUse: 'No se puede eliminar: hay facturas. Archívalo en su lugar.',
duplicateNumber: 'Ya existe una factura con este número',
saveFailed: 'No se pudo guardar',
created: 'Factura creada',
updated: 'Factura guardada',
},
},
sprints: { sprints: {
title: 'Sprints', title: 'Sprints',
create: 'Nuevo sprint', create: 'Nuevo sprint',
-186
View File
@@ -651,7 +651,6 @@ export default {
organizations: 'Organizações', organizations: 'Organizações',
analytics: 'Análises', analytics: 'Análises',
timeReports: 'Relatórios de tempo', timeReports: 'Relatórios de tempo',
invoices: 'Faturas',
switchOrganization: 'Trocar organização', switchOrganization: 'Trocar organização',
logout: 'Sair', logout: 'Sair',
logoutFailed: 'Falha ao sair', logoutFailed: 'Falha ao sair',
@@ -1095,191 +1094,6 @@ export default {
noPermissionHint: 'Peça a um administrador do projeto permissão de visualização de controle de tempo para acessar relatórios.', noPermissionHint: 'Peça a um administrador do projeto permissão de visualização de controle de tempo para acessar relatórios.',
}, },
}, },
invoices: {
actions: {
issue: 'Emitir',
markPaid: 'Marcar como paga',
unmarkPaid: 'Remover marca de pagamento',
void: 'Cancelar',
reissue: 'Corrigir',
},
overdue: 'Vencida',
replaces: 'Substituição',
replacesLink: 'Substitui a fatura anterior',
dates: {
issued: 'Emitida',
paid: 'Paga',
voided: 'Cancelada',
},
requisiteNames: {
sellerName: 'nome da empresa',
sellerAddress: 'endereço da empresa',
sellerBank: 'número da conta ou IBAN da empresa',
counterpartyName: 'nome do cliente',
counterpartyAddress: 'endereço do cliente',
lines: 'pelo menos um item',
},
archive: 'Arquivar',
unarchive: 'Restaurar do arquivo',
showArchived: 'Mostrar arquivados',
archivedBadge: 'Arquivado',
page: {
title: 'Faturas',
empty: 'Ainda não há faturas',
emptyHint: 'As faturas emitidas e agendadas dos projetos da organização aparecerão aqui.',
noPermission: 'Sem acesso às faturas',
noPermissionHint: 'As faturas estão disponíveis para o proprietário e os administradores da organização.',
},
create: 'Criar fatura',
editTitle: 'Editar fatura',
total: 'Total',
fields: {
number: 'Número',
reference: 'Contrato / PO',
referencePlaceholder: 'Número do contrato ou pedido',
project: 'Projeto',
currency: 'Moeda',
sellerPlaceholder: 'Selecione uma empresa',
counterpartyPlaceholder: 'Selecione um cliente',
issueDate: 'Data de emissão',
paymentTerms: 'Condições de pagamento',
dueDate: 'Data de vencimento',
periodFrom: 'Período de',
periodTo: 'Período até',
notes: 'Observações',
notesPlaceholder: 'Comentário para o cliente',
terms: 'Condições',
termsPlaceholder: 'Forma de pagamento, multa por atraso, outras condições',
},
paymentTerms: {
on_receipt: 'No recebimento',
net7: '7 dias',
net14: '14 dias',
net30: '30 dias',
custom: 'Data personalizada',
},
units: {
service: 'serviço',
hours: 'horas',
pcs: 'unidades',
},
tasks: {
title: 'Tarefas do projeto',
hint: 'São exibidas tarefas com valor de receita',
loading: 'Carregando tarefas…',
selectProject: 'Selecione um projeto primeiro',
empty: 'Nenhuma tarefa com valor de receita neste projeto',
done: 'Concluída',
},
lines: {
title: 'Itens',
empty: 'Marque tarefas acima ou adicione um item manualmente',
description: 'Descrição',
unit: 'Un.',
quantity: 'Qtd.',
price: 'Preço',
amount: 'Valor',
addManual: 'Adicionar item',
},
totals: {
subtotal: 'Subtotal',
discount: 'Desconto',
discountType: 'Tipo',
taxRate: 'Imposto',
tax: 'Imposto',
taxExempt: 'Sem imposto',
taxNote: 'Motivo',
},
requisites: {
title: 'Dados cadastrais',
label: 'Rótulo',
value: 'Valor',
add: 'Outro dado',
},
party: {
name: 'Nome',
namePlaceholder: 'Como aparece nas listas',
legalName: 'Razão social',
legalNamePlaceholder: 'Como no contrato',
address: 'Endereço',
email: 'E-mail',
phone: 'Telefone',
},
bank: {
title: 'Dados bancários',
bankName: 'Banco',
accountNumber: 'Número da conta',
iban: 'IBAN',
swift: 'SWIFT / BIC',
correspondentAccount: 'Conta correspondente',
},
seller: {
title: 'Minhas empresas',
create: 'Adicionar empresa',
editTitle: 'Editar empresa',
empty: 'Ainda não há empresas',
emptyHint: 'Adicione a empresa que emite as faturas: dados, banco, condições padrão.',
logoUrl: 'Logotipo (URL)',
taxNote: 'Observação fiscal',
taxNotePlaceholder: 'Isento de imposto',
defaultTerms: 'Condições padrão',
defaultTermsPlaceholder: 'Preenchidas em cada nova fatura',
toasts: {
created: 'Empresa adicionada',
updated: 'Empresa salva',
},
},
counterparty: {
title: 'Clientes',
create: 'Adicionar cliente',
editTitle: 'Editar cliente',
empty: 'Ainda não há clientes',
emptyHint: 'Adicione os clientes para quem você emite faturas.',
kind: 'Tipo',
kinds: {
organization: 'Organização',
person: 'Pessoa física',
},
contactPerson: 'Pessoa de contato',
toasts: {
created: 'Cliente adicionado',
updated: 'Cliente salvo',
},
},
preview: {
download: 'Baixar PDF',
pdfFailed: 'Não foi possível gerar o PDF',
back: 'Voltar à lista',
notFound: 'Fatura não encontrada',
},
validation: {
fillIn: 'Preencha',
lines: 'pelo menos um item',
lineDescriptions: 'descrição em cada item',
},
status: {
draft: 'Rascunho',
issued: 'Emitida',
paid: 'Paga',
void: 'Cancelada',
},
toasts: {
missingRequisites: 'Preencha antes de emitir',
invalidTransition: 'Esta mudança de status não é permitida',
notDraft: 'Uma fatura emitida não pode ser editada. Use “Corrigir”',
transition: {
issued: 'Fatura emitida',
paid: 'Marcada como paga',
void: 'Fatura cancelada, rascunho de substituição criado',
draft: 'Rascunho',
},
inUse: 'Não é possível excluir: existem faturas. Arquive em vez disso.',
duplicateNumber: 'Já existe uma fatura com este número',
saveFailed: 'Não foi possível salvar',
created: 'Fatura criada',
updated: 'Fatura salva',
},
},
sprints: { sprints: {
title: 'Sprints', title: 'Sprints',
create: 'Nova sprint', create: 'Nova sprint',
-186
View File
@@ -625,7 +625,6 @@ export default {
organizations: 'Организации', organizations: 'Организации',
analytics: 'Аналитика', analytics: 'Аналитика',
timeReports: 'Отчёты по времени', timeReports: 'Отчёты по времени',
invoices: 'Инвойсы',
switchOrganization: 'Переключить организацию', switchOrganization: 'Переключить организацию',
logout: 'Выйти', logout: 'Выйти',
logoutFailed: 'Не удалось выйти', logoutFailed: 'Не удалось выйти',
@@ -1031,191 +1030,6 @@ export default {
noPermissionHint: 'Попросите администратора проекта выдать вам право на просмотр учёта времени.', noPermissionHint: 'Попросите администратора проекта выдать вам право на просмотр учёта времени.',
}, },
}, },
invoices: {
actions: {
issue: 'Выставить',
markPaid: 'Оплачен',
unmarkPaid: 'Снять отметку об оплате',
void: 'Аннулировать',
reissue: 'Исправить',
},
overdue: 'Просрочен',
replaces: 'Замена',
replacesLink: 'Заменяет предыдущий счёт',
dates: {
issued: 'Выставлен',
paid: 'Оплачен',
voided: 'Аннулирован',
},
requisiteNames: {
sellerName: 'название компании',
sellerAddress: 'адрес компании',
sellerBank: 'расчётный счёт или IBAN компании',
counterpartyName: 'название клиента',
counterpartyAddress: 'адрес клиента',
lines: 'хотя бы одна строка',
},
archive: 'В архив',
unarchive: 'Вернуть из архива',
showArchived: 'Показать архивные',
archivedBadge: 'В архиве',
page: {
title: 'Инвойсы',
empty: 'Инвойсов пока нет',
emptyHint: 'Здесь появятся выставленные и запланированные счета по проектам организации.',
noPermission: 'Нет доступа к инвойсам',
noPermissionHint: 'Инвойсы доступны владельцу и администраторам организации.',
},
create: 'Создать инвойс',
editTitle: 'Редактировать инвойс',
total: 'Итого',
fields: {
number: 'Номер',
reference: 'Договор / PO',
referencePlaceholder: 'Номер договора или заказа',
project: 'Проект',
currency: 'Валюта',
sellerPlaceholder: 'Выберите компанию',
counterpartyPlaceholder: 'Выберите клиента',
issueDate: 'Дата выставления',
paymentTerms: 'Условия оплаты',
dueDate: 'Срок оплаты',
periodFrom: 'Период с',
periodTo: 'Период по',
notes: 'Примечание',
notesPlaceholder: 'Комментарий для контрагента',
terms: 'Условия',
termsPlaceholder: 'Порядок оплаты, пени, прочие условия',
},
paymentTerms: {
on_receipt: 'По получении',
net7: '7 дней',
net14: '14 дней',
net30: '30 дней',
custom: 'Своя дата',
},
units: {
service: 'услуга',
hours: 'часы',
pcs: 'шт.',
},
tasks: {
title: 'Задачи проекта',
hint: 'Показаны задачи с указанной суммой дохода',
loading: 'Загрузка задач…',
selectProject: 'Сначала выберите проект',
empty: 'В проекте нет задач с суммой дохода',
done: 'Выполнена',
},
lines: {
title: 'Строки',
empty: 'Отметьте задачи выше или добавьте строку вручную',
description: 'Описание',
unit: 'Ед.',
quantity: 'Кол-во',
price: 'Цена',
amount: 'Сумма',
addManual: 'Добавить строку',
},
totals: {
subtotal: 'Промежуточная сумма',
discount: 'Скидка',
discountType: 'Тип',
taxRate: 'Налог',
tax: 'Налог',
taxExempt: 'Без налога',
taxNote: 'Основание',
},
requisites: {
title: 'Реквизиты',
label: 'Название',
value: 'Значение',
add: 'Другой реквизит',
},
party: {
name: 'Название',
namePlaceholder: 'Как показывать в списках',
legalName: 'Юридическое название',
legalNamePlaceholder: 'Как в договоре',
address: 'Адрес',
email: 'Email',
phone: 'Телефон',
},
bank: {
title: 'Банковские реквизиты',
bankName: 'Банк',
accountNumber: 'Расчётный счёт',
iban: 'IBAN',
swift: 'SWIFT / БИК',
correspondentAccount: 'Корр. счёт',
},
seller: {
title: 'Мои компании',
create: 'Добавить компанию',
editTitle: 'Редактировать компанию',
empty: 'Компаний пока нет',
emptyHint: 'Добавьте компанию, от имени которой выставляются счета: реквизиты, банк, условия по умолчанию.',
logoUrl: 'Логотип (URL)',
taxNote: 'Отметка о налоге',
taxNotePlaceholder: 'НДС не облагается / VAT not applicable',
defaultTerms: 'Условия по умолчанию',
defaultTermsPlaceholder: 'Подставляются в каждый новый инвойс',
toasts: {
created: 'Компания добавлена',
updated: 'Компания сохранена',
},
},
counterparty: {
title: 'Клиенты',
create: 'Добавить клиента',
editTitle: 'Редактировать клиента',
empty: 'Клиентов пока нет',
emptyHint: 'Добавьте клиентов, которым выставляете счета.',
kind: 'Тип',
kinds: {
organization: 'Организация',
person: 'Физлицо',
},
contactPerson: 'Контактное лицо',
toasts: {
created: 'Клиент добавлен',
updated: 'Клиент сохранён',
},
},
preview: {
download: 'Скачать PDF',
pdfFailed: 'Не удалось собрать PDF',
back: 'К списку',
notFound: 'Инвойс не найден',
},
validation: {
fillIn: 'Заполните',
lines: 'хотя бы одна строка',
lineDescriptions: 'описание у каждой строки',
},
status: {
draft: 'Черновик',
issued: 'Выставлен',
paid: 'Оплачен',
void: 'Аннулирован',
},
toasts: {
missingRequisites: 'Заполните перед выставлением',
invalidTransition: 'Этот переход статуса недоступен',
notDraft: 'Выставленный счёт нельзя редактировать. Используйте «Исправить»',
transition: {
issued: 'Счёт выставлен',
paid: 'Оплата отмечена',
void: 'Счёт аннулирован, создан черновик замены',
draft: 'Черновик',
},
inUse: 'Нельзя удалить: есть инвойсы. Отправьте в архив.',
duplicateNumber: 'Инвойс с таким номером уже есть',
saveFailed: 'Не удалось сохранить',
created: 'Инвойс создан',
updated: 'Инвойс сохранён',
},
},
sprints: { sprints: {
title: 'Спринты', title: 'Спринты',
create: 'Новый спринт', create: 'Новый спринт',
-146
View File
@@ -1,146 +0,0 @@
<template>
<InvoicesPageShell
id="invoice-preview"
:title="invoice ? invoice.number : t('invoices.page.title')"
:show-tabs="false"
>
<template #actions>
<UButton
icon="i-lucide-arrow-left"
:label="isMobile ? undefined : t('invoices.preview.back')"
color="neutral"
variant="ghost"
:to="{ name: 'invoices' }"
/>
<InvoiceActions
v-if="invoice"
:invoice="invoice"
:compact="isMobile"
:busy="busy"
@edit="formOpen = true"
@transition="transition"
@reissue="reissue"
/>
<UButton
v-if="invoice"
icon="i-lucide-download"
:label="isMobile ? undefined : t('invoices.preview.download')"
:disabled="!pdfBlob"
@click="download"
/>
</template>
<div
v-if="!invoice"
class="flex flex-col items-center justify-center gap-3 py-16 px-4 text-center"
>
<UIcon
:name="loading ? 'i-lucide-loader-circle' : 'i-lucide-file-x'"
class="size-12 text-muted"
:class="{ 'animate-spin': loading }"
/>
<p
v-if="!loading"
class="font-medium text-default"
>
{{ t('invoices.preview.notFound') }}
</p>
</div>
<div
v-else
class="flex flex-col gap-3 p-2 lg:p-6"
>
<InvoiceStatusBar :invoice="invoice" />
<InvoicePdfViewer
:invoice-id="invoice.id"
:title="invoice.number"
:version="invoice.updatedAt"
@loaded="pdfBlob = $event"
/>
</div>
<InvoiceFormModal
v-if="invoice"
v-model:open="formOpen"
:organization-id="invoice.organizationId"
:projects="projectOptions"
:invoice="invoice"
/>
</InvoicesPageShell>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import type { InvoiceStatus } from 'taskview-api'
import { useTaskView } from '@/composables/useTaskView'
import { useGoalsStore } from '@/stores/goals.store'
import { useInvoicesStore } from '@/stores/invoices.store'
import { useInvoiceTransitionFeedback } from '@/composables/useInvoiceTransitionFeedback'
import InvoicesPageShell from '@/components/features/invoices/InvoicesPageShell.vue'
import InvoicePdfViewer from '@/components/features/invoices/InvoicePdfViewer.vue'
import InvoiceActions from '@/components/features/invoices/InvoiceActions.vue'
import InvoiceStatusBar from '@/components/features/invoices/InvoiceStatusBar.vue'
import InvoiceFormModal from '@/components/features/invoices/parts/InvoiceFormModal.vue'
import type { InvoiceSelectOption } from '@/types/invoices.types'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const invoicesStore = useInvoicesStore()
const { goals } = storeToRefs(useGoalsStore())
const { isMobile } = useTaskView()
const { report } = useInvoiceTransitionFeedback()
const invoiceId = computed(() => Number(route.params.invoiceId))
const invoice = computed(() => invoicesStore.byId(invoiceId.value))
const loading = ref(false)
const busy = ref(false)
const formOpen = ref(false)
const pdfBlob = ref<Blob | null>(null)
watch(
invoiceId,
async (id) => {
if (invoicesStore.byId(id)) return
loading.value = true
await invoicesStore.fetchById(id)
loading.value = false
},
{ immediate: true },
)
async function transition(status: InvoiceStatus) {
if (!invoice.value || busy.value) return
busy.value = true
const result = await invoicesStore.transition({ invoiceId: invoice.value.id, status })
busy.value = false
report(result, status)
}
async function reissue() {
if (!invoice.value || busy.value) return
busy.value = true
const result = await invoicesStore.reissue(invoice.value.id)
busy.value = false
report(result, 'void')
if ('invoice' in result) router.push({ name: 'invoice-preview', params: { invoiceId: result.invoice.id } })
}
function download() {
if (!pdfBlob.value || !invoice.value) return
const link = document.createElement('a')
link.href = URL.createObjectURL(pdfBlob.value)
link.download = `${invoice.value.number}.pdf`
link.click()
URL.revokeObjectURL(link.href)
}
const projectOptions = computed<InvoiceSelectOption[]>(() =>
goals.value
.filter((goal) => goal.organizationId === invoice.value?.organizationId && !goal.archive)
.map((goal) => ({ label: goal.name, value: goal.id })),
)
</script>
@@ -1,108 +0,0 @@
<template>
<InvoicesPageShell
id="invoices-counterparties"
v-model:include-archived="store.includeArchived"
:title="t('invoices.counterparty.title')"
:show-tabs="isAdmin"
@update:include-archived="reload"
>
<template #actions>
<UButton
v-if="isAdmin"
icon="i-lucide-plus"
:label="t('invoices.counterparty.create')"
@click="openCreate"
/>
</template>
<InvoicesNoPermission v-if="!isAdmin" />
<template v-else>
<div
v-if="counterparties.length === 0 && !store.loading"
class="flex flex-col items-center justify-center gap-3 py-16 px-4 text-center"
>
<UIcon
name="i-lucide-users"
class="size-12 text-muted"
/>
<p class="font-medium text-default">
{{ t('invoices.counterparty.empty') }}
</p>
<p class="text-sm text-muted max-w-md">
{{ t('invoices.counterparty.emptyHint') }}
</p>
</div>
<div
v-else
class="flex flex-col gap-2 p-2 lg:p-6"
>
<PartyListItem
v-for="item in counterparties"
:key="item.id"
:icon="item.kind === 'person' ? 'i-lucide-user' : 'i-lucide-building'"
:title="item.name"
:subtitle="[item.legalName, item.email].filter(Boolean).join(' · ')"
:archived="item.archived"
@edit="openEdit(item)"
@archive="store.setArchived({ id: item.id, archived: $event })"
@delete="remove(item.id)"
/>
</div>
</template>
<CounterpartyFormModal
v-if="orgId !== null"
v-model:open="formOpen"
:organization-id="orgId"
:counterparty="editing"
/>
</InvoicesPageShell>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import type { CounterpartyItem } from 'taskview-api'
import { useOrganizationStore } from '@/stores/organization.store'
import { useCounterpartiesStore } from '@/stores/counterparties.store'
import { useOrgPermissions } from '@/composables/useOrgPermissions'
import InvoicesPageShell from '@/components/features/invoices/InvoicesPageShell.vue'
import InvoicesNoPermission from '@/components/features/invoices/InvoicesNoPermission.vue'
import PartyListItem from '@/components/features/invoices/PartyListItem.vue'
import CounterpartyFormModal from '@/components/features/invoices/parts/CounterpartyFormModal.vue'
const { t } = useI18n()
const toast = useToast()
const { currentOrg } = storeToRefs(useOrganizationStore())
const store = useCounterpartiesStore()
const { counterparties } = storeToRefs(store)
const { isAdmin } = useOrgPermissions(() => currentOrg.value)
const orgId = computed(() => currentOrg.value?.id ?? null)
function reload() {
if (orgId.value !== null && isAdmin.value) store.fetch(orgId.value)
}
watch([orgId, isAdmin], reload, { immediate: true })
const formOpen = ref(false)
const editing = ref<CounterpartyItem | null>(null)
function openCreate() {
editing.value = null
formOpen.value = true
}
function openEdit(item: CounterpartyItem) {
editing.value = item
formOpen.value = true
}
async function remove(id: number) {
const result = await store.deleteCounterparty(id)
if (result === 'in_use') toast.add({ title: t('invoices.toasts.inUse'), color: 'warning' })
else if (result === 'failed') toast.add({ title: t('invoices.toasts.saveFailed'), color: 'error' })
}
</script>
-108
View File
@@ -1,108 +0,0 @@
<template>
<InvoicesPageShell
id="invoices-sellers"
v-model:include-archived="store.includeArchived"
:title="t('invoices.seller.title')"
:show-tabs="isAdmin"
@update:include-archived="reload"
>
<template #actions>
<UButton
v-if="isAdmin"
icon="i-lucide-plus"
:label="t('invoices.seller.create')"
@click="openCreate"
/>
</template>
<InvoicesNoPermission v-if="!isAdmin" />
<template v-else>
<div
v-if="sellers.length === 0 && !store.loading"
class="flex flex-col items-center justify-center gap-3 py-16 px-4 text-center"
>
<UIcon
name="i-lucide-building-2"
class="size-12 text-muted"
/>
<p class="font-medium text-default">
{{ t('invoices.seller.empty') }}
</p>
<p class="text-sm text-muted max-w-md">
{{ t('invoices.seller.emptyHint') }}
</p>
</div>
<div
v-else
class="flex flex-col gap-2 p-2 lg:p-6"
>
<PartyListItem
v-for="item in sellers"
:key="item.id"
icon="i-lucide-building-2"
:title="item.name"
:subtitle="[item.legalName, item.currencyCode].filter(Boolean).join(' · ')"
:archived="item.archived"
@edit="openEdit(item)"
@archive="store.setArchived({ id: item.id, archived: $event })"
@delete="remove(item.id)"
/>
</div>
</template>
<SellerFormModal
v-if="orgId !== null"
v-model:open="formOpen"
:organization-id="orgId"
:seller="editing"
/>
</InvoicesPageShell>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import type { SellerItem } from 'taskview-api'
import { useOrganizationStore } from '@/stores/organization.store'
import { useSellersStore } from '@/stores/sellers.store'
import { useOrgPermissions } from '@/composables/useOrgPermissions'
import InvoicesPageShell from '@/components/features/invoices/InvoicesPageShell.vue'
import InvoicesNoPermission from '@/components/features/invoices/InvoicesNoPermission.vue'
import PartyListItem from '@/components/features/invoices/PartyListItem.vue'
import SellerFormModal from '@/components/features/invoices/parts/SellerFormModal.vue'
const { t } = useI18n()
const toast = useToast()
const { currentOrg } = storeToRefs(useOrganizationStore())
const store = useSellersStore()
const { sellers } = storeToRefs(store)
const { isAdmin } = useOrgPermissions(() => currentOrg.value)
const orgId = computed(() => currentOrg.value?.id ?? null)
function reload() {
if (orgId.value !== null && isAdmin.value) store.fetch(orgId.value)
}
watch([orgId, isAdmin], reload, { immediate: true })
const formOpen = ref(false)
const editing = ref<SellerItem | null>(null)
function openCreate() {
editing.value = null
formOpen.value = true
}
function openEdit(item: SellerItem) {
editing.value = item
formOpen.value = true
}
async function remove(id: number) {
const result = await store.deleteSeller(id)
if (result === 'in_use') toast.add({ title: t('invoices.toasts.inUse'), color: 'warning' })
else if (result === 'failed') toast.add({ title: t('invoices.toasts.saveFailed'), color: 'error' })
}
</script>
-82
View File
@@ -1,82 +0,0 @@
<template>
<InvoicesPageShell
id="invoices"
v-model:include-archived="invoicesStore.includeArchived"
:title="t('invoices.page.title')"
:show-tabs="isAdmin"
@update:include-archived="reload"
>
<template #actions>
<UButton
v-if="isAdmin"
icon="i-lucide-plus"
:label="t('invoices.create')"
@click="formOpen = true"
/>
</template>
<InvoicesNoPermission v-if="!isAdmin" />
<template v-else>
<InvoicesEmptyState v-if="invoices.length === 0 && !invoicesStore.loading" />
<InvoicesList
v-else
:invoices="invoices"
@open="openPreview"
/>
</template>
<InvoiceFormModal
v-if="orgId !== null"
v-model:open="formOpen"
:organization-id="orgId"
:projects="projectOptions"
@saved="openPreview"
/>
</InvoicesPageShell>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import type { InvoiceItem } from 'taskview-api'
import { useOrganizationStore } from '@/stores/organization.store'
import { useGoalsStore } from '@/stores/goals.store'
import { useInvoicesStore } from '@/stores/invoices.store'
import { useOrgPermissions } from '@/composables/useOrgPermissions'
import InvoicesPageShell from '@/components/features/invoices/InvoicesPageShell.vue'
import InvoicesEmptyState from '@/components/features/invoices/InvoicesEmptyState.vue'
import InvoicesNoPermission from '@/components/features/invoices/InvoicesNoPermission.vue'
import InvoicesList from '@/components/features/invoices/InvoicesList.vue'
import InvoiceFormModal from '@/components/features/invoices/parts/InvoiceFormModal.vue'
import type { InvoiceSelectOption } from '@/types/invoices.types'
const { t } = useI18n()
const router = useRouter()
const { currentOrg } = storeToRefs(useOrganizationStore())
const { goals } = storeToRefs(useGoalsStore())
const invoicesStore = useInvoicesStore()
const { invoices } = storeToRefs(invoicesStore)
const { isAdmin } = useOrgPermissions(() => currentOrg.value)
const orgId = computed(() => currentOrg.value?.id ?? null)
const projectOptions = computed<InvoiceSelectOption[]>(() =>
goals.value
.filter((goal) => goal.organizationId === orgId.value && !goal.archive)
.map((goal) => ({ label: goal.name, value: goal.id })),
)
const formOpen = ref(false)
function reload() {
if (orgId.value !== null && isAdmin.value) invoicesStore.fetch(orgId.value)
}
watch([orgId, isAdmin], reload, { immediate: true })
function openPreview(invoice: InvoiceItem) {
router.push({ name: 'invoice-preview', params: { invoiceId: invoice.id } })
}
</script>
-74
View File
@@ -1,74 +0,0 @@
import { defineStore } from 'pinia'
import type { CounterpartyItem } from 'taskview-api'
import { $tvApi } from '@/plugins/axios'
import { logError } from '@/helpers/Helper'
import { httpStatusOf } from '@/helpers/billingErrors'
import type {
ArchiveArgs,
BillingDeleteResult,
CounterpartiesStoreState,
CreateCounterpartyArgs,
UpdateCounterpartyArgs,
} from '@/types/invoices.types'
export const useCounterpartiesStore = defineStore('counterparties', {
state: (): CounterpartiesStoreState => ({
counterparties: [],
loading: false,
includeArchived: false,
}),
getters: {
active: (state) => state.counterparties.filter((item) => !item.archived),
byId: (state) => (counterpartyId: number) => state.counterparties.find((item) => item.id === counterpartyId) ?? null,
},
actions: {
async fetch(organizationId: number) {
this.loading = true
const result = await $tvApi.billing
.fetchCounterparties({ organizationId, includeArchived: this.includeArchived })
.catch(logError)
.finally(() => { this.loading = false })
if (result) this.counterparties = result
},
async createCounterparty({ organizationId, value }: CreateCounterpartyArgs): Promise<CounterpartyItem | null> {
const counterparty = await $tvApi.billing.createCounterparty({ organizationId, ...value }).catch(logError)
if (!counterparty) return null
this.counterparties.push(counterparty)
return counterparty
},
async updateCounterparty({ counterpartyId, value }: UpdateCounterpartyArgs): Promise<CounterpartyItem | null> {
const counterparty = await $tvApi.billing.updateCounterparty({ id: counterpartyId, data: value }).catch(logError)
if (!counterparty) return null
this.replace(counterparty)
return counterparty
},
async setArchived({ id, archived }: ArchiveArgs): Promise<CounterpartyItem | null> {
const counterparty = await $tvApi.billing.archiveCounterparty({ id, archived }).catch(logError)
if (!counterparty) return null
if (!archived || this.includeArchived) this.replace(counterparty)
else this.counterparties = this.counterparties.filter((item) => item.id !== id)
return counterparty
},
async deleteCounterparty(counterpartyId: number): Promise<BillingDeleteResult> {
try {
await $tvApi.billing.deleteCounterparty(counterpartyId)
} catch (error) {
return httpStatusOf(error) === 409 ? 'in_use' : 'failed'
}
this.counterparties = this.counterparties.filter((item) => item.id !== counterpartyId)
return 'deleted'
},
replace(counterparty: CounterpartyItem) {
const index = this.counterparties.findIndex((item) => item.id === counterparty.id)
if (index >= 0) this.counterparties[index] = counterparty
else this.counterparties.push(counterparty)
},
},
})
-30
View File
@@ -1,30 +0,0 @@
import { defineStore } from 'pinia'
import { $tvApi } from '@/plugins/axios'
import { logError } from '@/helpers/Helper'
import type { CurrenciesStoreState } from '@/types/invoices.types'
export const useCurrenciesStore = defineStore('currencies', {
state: (): CurrenciesStoreState => ({
currencies: [],
loaded: false,
}),
getters: {
options: (state) =>
state.currencies.map((currency) => ({ label: `${currency.code} ${currency.symbol}`, value: currency.code })),
},
actions: {
async fetch() {
if (this.loaded) return
const result = await $tvApi.billing.fetchCurrencies().catch(logError)
if (!result) return
this.currencies = result.map((currency) => ({
code: currency.code.trim(),
symbol: currency.symbol,
decimalDigits: currency.decimalDigits,
}))
this.loaded = true
},
},
})
-153
View File
@@ -1,153 +0,0 @@
import { defineStore } from 'pinia'
import { isAxiosError } from 'axios'
import type { InvoiceArgCreate, InvoiceItem, InvoicePdfLang, InvoiceTransitionError } from 'taskview-api'
import { $tvApi } from '@/plugins/axios'
import { logError } from '@/helpers/Helper'
import { httpStatusOf } from '@/helpers/billingErrors'
import type {
CreateInvoiceArgs,
InvoiceFormValue,
InvoiceSaveResult,
InvoiceTransitionResult,
InvoicesStoreState,
TransitionInvoiceArgs,
UpdateInvoiceArgs,
} from '@/types/invoices.types'
export const useInvoicesStore = defineStore('invoices', {
state: (): InvoicesStoreState => ({
invoices: [],
loading: false,
includeArchived: false,
}),
getters: {
byId: (state) => (invoiceId: number) => state.invoices.find((invoice) => invoice.id === invoiceId) ?? null,
lastCounterpartyForGoal: (state) => (goalId: number) =>
state.invoices.find((invoice) => invoice.goalId === goalId)?.counterpartyId ?? null,
suggestedNumber: (state) => () =>
`INV-${new Date().getFullYear()}-${String(state.invoices.length + 1).padStart(4, '0')}`,
},
actions: {
async fetch(organizationId: number) {
this.loading = true
const result = await $tvApi.invoices
.fetch({ organizationId, includeArchived: this.includeArchived })
.catch(logError)
.finally(() => { this.loading = false })
if (result) this.invoices = result
},
async fetchById(invoiceId: number): Promise<InvoiceItem | null> {
const invoice = await $tvApi.invoices.fetchById(invoiceId).catch(logError)
if (!invoice) return null
this.replace(invoice)
return invoice
},
toPayload(value: InvoiceFormValue): Omit<InvoiceArgCreate, 'organizationId'> {
return {
goalId: value.goalId,
sellerId: value.sellerId as number,
counterpartyId: value.counterpartyId as number,
number: value.number.trim(),
reference: value.reference.trim(),
currencyCode: value.currencyCode,
issueDate: value.issueDate as string,
paymentTerms: value.paymentTerms,
dueDate: value.dueDate,
periodFrom: value.periodFrom,
periodTo: value.periodTo,
discountType: value.discountType,
discountValue: value.discountValue,
taxRate: value.taxRate,
taxExempt: value.taxExempt,
taxNote: value.taxNote.trim(),
notes: value.notes.trim(),
terms: value.terms.trim(),
lines: value.lines.map((line) => ({
taskId: line.taskId,
description: line.description.trim(),
unit: line.unit,
quantity: line.quantity,
unitPrice: line.unitPrice,
})),
}
},
async createInvoice({ organizationId, value }: CreateInvoiceArgs): Promise<InvoiceSaveResult> {
try {
const invoice = await $tvApi.invoices.create({ organizationId, ...this.toPayload(value) })
this.invoices.unshift(invoice)
return { invoice }
} catch (error) {
logError(error)
return { error: httpStatusOf(error) === 409 ? 'duplicate_number' : 'failed' }
}
},
async updateInvoice({ invoiceId, value }: UpdateInvoiceArgs): Promise<InvoiceSaveResult> {
try {
const invoice = await $tvApi.invoices.update({ id: invoiceId, data: this.toPayload(value) })
this.replace(invoice)
return { invoice }
} catch (error) {
logError(error)
const status = httpStatusOf(error)
const body = isAxiosError(error) ? (error.response?.data as { error?: string } | undefined) : undefined
if (status === 409 && body?.error === 'not_draft') return { error: 'not_draft' }
return { error: status === 409 ? 'duplicate_number' : 'failed' }
}
},
async transition({ invoiceId, status }: TransitionInvoiceArgs): Promise<InvoiceTransitionResult> {
try {
const invoice = await $tvApi.invoices.setStatus({ id: invoiceId, status })
this.replace(invoice)
return { invoice }
} catch (error) {
return this.toTransitionError(error)
}
},
async reissue(invoiceId: number): Promise<InvoiceTransitionResult> {
try {
const copy = await $tvApi.invoices.reissue(invoiceId)
this.replace(copy)
await this.fetchById(invoiceId)
return { invoice: copy }
} catch (error) {
return this.toTransitionError(error)
}
},
async fetchPdf(invoiceId: number, lang: InvoicePdfLang): Promise<Blob | null> {
return $tvApi.invoices.fetchPdf({ id: invoiceId, lang }).catch((error) => {
logError(error)
return null
})
},
toTransitionError(error: unknown): InvoiceTransitionResult {
logError(error)
const body = isAxiosError(error) ? (error.response?.data as InvoiceTransitionError | undefined) : undefined
return body && typeof body === 'object' && 'error' in body ? body : { error: 'failed' }
},
async deleteInvoice(invoiceId: number): Promise<boolean> {
const result = await $tvApi.invoices.delete(invoiceId).catch(logError)
if (!result) return false
this.invoices = this.invoices.filter((invoice) => invoice.id !== invoiceId)
return true
},
replace(invoice: InvoiceItem) {
const index = this.invoices.findIndex((item) => item.id === invoice.id)
if (index >= 0) this.invoices[index] = invoice
else this.invoices.unshift(invoice)
},
},
})
-74
View File
@@ -1,74 +0,0 @@
import { defineStore } from 'pinia'
import type { SellerItem } from 'taskview-api'
import { $tvApi } from '@/plugins/axios'
import { logError } from '@/helpers/Helper'
import { httpStatusOf } from '@/helpers/billingErrors'
import type {
ArchiveArgs,
BillingDeleteResult,
CreateSellerArgs,
SellersStoreState,
UpdateSellerArgs,
} from '@/types/invoices.types'
export const useSellersStore = defineStore('sellers', {
state: (): SellersStoreState => ({
sellers: [],
loading: false,
includeArchived: false,
}),
getters: {
active: (state) => state.sellers.filter((seller) => !seller.archived),
byId: (state) => (sellerId: number) => state.sellers.find((seller) => seller.id === sellerId) ?? null,
},
actions: {
async fetch(organizationId: number) {
this.loading = true
const result = await $tvApi.billing
.fetchSellers({ organizationId, includeArchived: this.includeArchived })
.catch(logError)
.finally(() => { this.loading = false })
if (result) this.sellers = result
},
async createSeller({ organizationId, value }: CreateSellerArgs): Promise<SellerItem | null> {
const seller = await $tvApi.billing.createSeller({ organizationId, ...value }).catch(logError)
if (!seller) return null
this.sellers.push(seller)
return seller
},
async updateSeller({ sellerId, value }: UpdateSellerArgs): Promise<SellerItem | null> {
const seller = await $tvApi.billing.updateSeller({ id: sellerId, data: value }).catch(logError)
if (!seller) return null
this.replace(seller)
return seller
},
async setArchived({ id, archived }: ArchiveArgs): Promise<SellerItem | null> {
const seller = await $tvApi.billing.archiveSeller({ id, archived }).catch(logError)
if (!seller) return null
if (!archived || this.includeArchived) this.replace(seller)
else this.sellers = this.sellers.filter((item) => item.id !== id)
return seller
},
async deleteSeller(sellerId: number): Promise<BillingDeleteResult> {
try {
await $tvApi.billing.deleteSeller(sellerId)
} catch (error) {
return httpStatusOf(error) === 409 ? 'in_use' : 'failed'
}
this.sellers = this.sellers.filter((seller) => seller.id !== sellerId)
return 'deleted'
},
replace(seller: SellerItem) {
const index = this.sellers.findIndex((item) => item.id === seller.id)
if (index >= 0) this.sellers[index] = seller
else this.sellers.push(seller)
},
},
})
-162
View File
@@ -1,162 +0,0 @@
import type {
CounterpartyArgCreate,
CounterpartyItem,
InvoiceDiscountType,
InvoiceItem,
InvoiceLineItem,
InvoicePaymentTerms,
InvoiceStatus,
InvoiceTransitionError,
InvoiceUnit,
SellerArgCreate,
SellerItem,
} from 'taskview-api'
export type SellerFormValue = Omit<SellerArgCreate, 'organizationId'>
export type PartyFields = Pick<SellerFormValue, 'name' | 'legalName' | 'address' | 'email' | 'phone' | 'requisites'>
export type CounterpartyFormValue = Omit<CounterpartyArgCreate, 'organizationId'>
export type InvoiceFormLine = Omit<InvoiceLineItem, 'id'> & {
key: string
}
export type InvoiceFormValue = {
number: string
reference: string
goalId: number | null
sellerId: number | null
counterpartyId: number | null
currencyCode: string
issueDate: string | null
paymentTerms: InvoicePaymentTerms
dueDate: string | null
periodFrom: string | null
periodTo: string | null
lines: InvoiceFormLine[]
discountType: InvoiceDiscountType
discountValue: number
taxRate: number
taxExempt: boolean
taxNote: string
notes: string
terms: string
}
export type InvoiceTotals = {
subtotal: number
discount: number
taxable: number
tax: number
total: number
}
export type ComputeTotalsArgs = {
lines: Pick<InvoiceLineItem, 'quantity' | 'unitPrice'>[]
discountType: InvoiceDiscountType
discountValue: number
taxRate: number
taxExempt: boolean
}
export type InvoiceTaskOption = {
id: number
description: string
amount: number
complete: boolean
}
export type InvoiceSelectOption = {
label: string
value: number
}
export type FormatMoneyArgs = {
amount: number
currencyCode: string
locale: string
}
export type AddDaysArgs = {
date: string
days: number
}
export type InvoicesStoreState = {
invoices: InvoiceItem[]
loading: boolean
includeArchived: boolean
}
export type SellersStoreState = {
sellers: SellerItem[]
loading: boolean
includeArchived: boolean
}
export type CounterpartiesStoreState = {
counterparties: CounterpartyItem[]
loading: boolean
includeArchived: boolean
}
export type CurrenciesStoreState = {
currencies: { code: string, symbol: string, decimalDigits: number }[]
loaded: boolean
}
export type CreateInvoiceArgs = {
organizationId: number
value: InvoiceFormValue
}
export type UpdateInvoiceArgs = {
invoiceId: number
value: InvoiceFormValue
}
export type CreateSellerArgs = {
organizationId: number
value: SellerFormValue
}
export type UpdateSellerArgs = {
sellerId: number
value: SellerFormValue
}
export type CreateCounterpartyArgs = {
organizationId: number
value: CounterpartyFormValue
}
export type UpdateCounterpartyArgs = {
counterpartyId: number
value: CounterpartyFormValue
}
export type ArchiveArgs = {
id: number
archived: boolean
}
export type BillingDeleteResult = 'deleted' | 'in_use' | 'failed'
export type InvoiceSaveResult = { invoice: InvoiceItem } | { error: 'duplicate_number' | 'not_draft' | 'failed' }
export type InvoiceTransitionResult = { invoice: InvoiceItem } | InvoiceTransitionError | { error: 'failed' }
export type TransitionInvoiceArgs = {
invoiceId: number
status: InvoiceStatus
}
export const INVOICE_UNITS: InvoiceUnit[] = ['service', 'hours', 'pcs']
export const PAYMENT_TERMS_DAYS: Record<InvoicePaymentTerms, number | null> = {
on_receipt: 0,
net7: 7,
net14: 14,
net30: 30,
custom: null,
}
export const REQUISITE_PRESETS = ['ИНН', 'КПП', 'ОГРН', 'VAT ID', 'EIN', 'Reg. No.']