diff --git a/package.json b/package.json index e2b718c7..be131452 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", "@types/node": "^22.0.0", + "@types/pako": "^2.0.4", "@types/react": "^19.2.7", "@types/react-dom": "^19.0.0", "@types/uuid": "^11.0.0", @@ -78,6 +79,7 @@ "lz-string": "^1.0.0", "next": "^15.5.8", "next-sitemap": "^4.2.3", + "pako": "^2.1.0", "pixi.js": "^8.14.3", "qrcode.react": "^4.2.0", "react": "^19.1.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58c6410e..14381b4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: next-sitemap: specifier: ^4.2.3 version: 4.2.3(next@15.5.8(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)) + pako: + specifier: ^2.1.0 + version: 2.1.0 pixi.js: specifier: ^8.14.3 version: 8.14.3 @@ -111,6 +114,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.1 + '@types/pako': + specifier: ^2.0.4 + version: 2.0.4 '@types/react': specifier: ^19.2.7 version: 19.2.7 @@ -1911,6 +1917,9 @@ packages: '@types/node@22.19.1': resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==} + '@types/pako@2.0.4': + resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -4040,6 +4049,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -5076,6 +5088,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} @@ -7442,6 +7455,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/pako@2.0.4': {} + '@types/react-dom@19.2.3(@types/react@19.2.7)': dependencies: '@types/react': 19.2.7 @@ -10305,6 +10320,8 @@ snapshots: p-try@2.2.0: {} + pako@2.1.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index e0492f4d..1cd4beb0 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -7,6 +7,7 @@ import { generateAndTrackInvoice } from '@/features/generate-link' export default function CreateInvoicePage() { const activeDraft = useCreatorStore((s) => s.activeDraft) + const lineItems = useCreatorStore((s) => s.lineItems) const createNewDraft = useCreatorStore((s) => s.createNewDraft) const clearDraft = useCreatorStore((s) => s.clearDraft) @@ -14,13 +15,13 @@ export default function CreateInvoicePage() { const [showNewDialog, setShowNewDialog] = useState(false) const [localInvoiceId, setLocalInvoiceId] = useState('') - const [localRecipientName, setLocalRecipientName] = useState('') + const [localClientName, setLocalClientName] = useState('') // Restore draft on page load useEffect(() => { if (activeDraft) { - setLocalInvoiceId(activeDraft.invoiceId) - setLocalRecipientName(activeDraft.recipient.name) + setLocalInvoiceId(activeDraft.data.invoiceId ?? '') + setLocalClientName(activeDraft.data.client?.name ?? '') } }, [activeDraft]) @@ -36,15 +37,15 @@ export default function CreateInvoicePage() { // Update local state immediately (optimistic UI) if (field === 'invoiceId') { setLocalInvoiceId(value) - } else if (field === 'recipientName') { - setLocalRecipientName(value) + } else if (field === 'clientName') { + setLocalClientName(value) } // Debounced save to store if (field === 'invoiceId') { autoSave({ invoiceId: value }) - } else if (field === 'recipientName') { - autoSave({ recipient: { name: value } }) + } else if (field === 'clientName') { + autoSave({ client: { name: value } }) } } @@ -55,7 +56,7 @@ export default function CreateInvoicePage() { } try { - const url = await generateAndTrackInvoice(activeDraft) + const url = await generateAndTrackInvoice(activeDraft, lineItems) alert(`Invoice generated! URL: ${url}\n\nCheck the History page to see the entry.`) clearDraft() } catch (error) { @@ -74,7 +75,7 @@ export default function CreateInvoicePage() {
{activeDraft && ( - Draft ID: {activeDraft.draftId.slice(0, 8)}... + Draft ID: {activeDraft.meta.draftId.slice(0, 8)}... )} {isPending() && ( @@ -107,12 +108,12 @@ export default function CreateInvoicePage() {
handleFieldChange('recipientName', e.target.value)} + value={localClientName} + onChange={(e) => handleFieldChange('clientName', e.target.value)} placeholder="Acme Corp" className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-700 dark:text-white" /> @@ -120,11 +121,11 @@ export default function CreateInvoicePage() {

- 💡 Your draft is automatically saved every 500ms + Your draft is automatically saved every 500ms

{activeDraft && (

- Last modified: {new Date(activeDraft.lastModified).toLocaleString()} + Last modified: {new Date(activeDraft.meta.lastModified).toLocaleString()}

)}
@@ -156,7 +157,7 @@ export default function CreateInvoicePage() { createNewDraft() setShowNewDialog(false) setLocalInvoiceId('') - setLocalRecipientName('') + setLocalClientName('') }} />
diff --git a/src/entities/creator/model/migrations.ts b/src/entities/creator/model/migrations.ts deleted file mode 100644 index eecd0dec..00000000 --- a/src/entities/creator/model/migrations.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Creator Store Migrations - * - * Schema migration logic for CreatorStore. - * Handles version upgrades while maintaining backward compatibility. - * - * Constitution Principle IV: Backward Compatibility & Schema Versioning - */ - -import type { CreatorStoreV1 } from './types' - -/** - * Migrate persisted state to current version - * - * @param persistedState - The persisted state from LocalStorage - * @param version - The version number of the persisted state - * @returns Migrated state matching current schema - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function migrateCreatorStore(persistedState: any, version: number): CreatorStoreV1 { - // Version 0 (no version field) → Version 1 - if (version === 0 || !persistedState.version) { - console.warn('[CreatorStore] Migrating from v0 to v1') - - return { - version: 1, - activeDraft: persistedState.activeDraft || null, - templates: persistedState.templates || [], - history: persistedState.history || [], - preferences: persistedState.preferences || {}, - idCounter: persistedState.idCounter || { - currentValue: 1, - prefix: 'INV', - }, - } - } - - // Version 1 (current) - no migration needed - if (version === 1) { - return persistedState as CreatorStoreV1 - } - - // Future migrations go here - // Example: Version 1 → Version 2 - // if (version === 1) { - // console.warn('[CreatorStore] Migrating from v1 to v2'); - // return { - // ...persistedState, - // version: 2, - // newField: defaultValue, - // }; - // } - - // Fallback: return persisted state as-is - console.warn(`[CreatorStore] Unknown version ${version}, using as-is`) - return persistedState -} - -/** - * Validate migrated state - * - * Ensures the migrated state has all required fields. - * Throws an error if validation fails. - * - * @param state - The migrated state to validate - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function validateCreatorStore(state: any): asserts state is CreatorStoreV1 { - if (typeof state !== 'object' || state === null) { - throw new Error('Invalid state: must be an object') - } - - if (state.version !== 1) { - throw new Error(`Invalid version: expected 1, got ${state.version}`) - } - - if (!Array.isArray(state.templates)) { - throw new Error('Invalid templates: must be an array') - } - - if (!Array.isArray(state.history)) { - throw new Error('Invalid history: must be an array') - } - - if (typeof state.preferences !== 'object' || state.preferences === null) { - throw new Error('Invalid preferences: must be an object') - } - - if (typeof state.idCounter !== 'object' || state.idCounter === null) { - throw new Error('Invalid idCounter: must be an object') - } - - if (typeof state.idCounter.currentValue !== 'number' || state.idCounter.currentValue < 1) { - throw new Error('Invalid idCounter.currentValue: must be a number >= 1') - } - - if (typeof state.idCounter.prefix !== 'string') { - throw new Error('Invalid idCounter.prefix: must be a string') - } -} - -/** - * Migration Rules (Constitution Principle IV) - * - * 1. Migrations MUST be additive only (no field removal) - * 2. Old data MUST remain functional after migration - * 3. Migration MUST be tested with real v1 data - * 4. Migration errors MUST be logged and handled gracefully - * 5. Default values MUST be provided for new fields - * 6. Version number MUST be incremented - * 7. Migration function MUST be idempotent (safe to run multiple times) - */ diff --git a/src/entities/creator/model/types.ts b/src/entities/creator/model/types.ts index b57d3d23..58935c27 100644 --- a/src/entities/creator/model/types.ts +++ b/src/entities/creator/model/types.ts @@ -6,9 +6,11 @@ */ import type { - InvoiceDraft, + Invoice, + DraftState, InvoiceTemplate, CreationHistoryEntry, + LineItem, } from '@/entities/invoice' /** @@ -37,8 +39,8 @@ export interface UserPreferences { /** Default currency symbol (e.g., "USDC") */ defaultCurrency?: string - /** Default chain ID (e.g., 42161 for Arbitrum) */ - defaultChainId?: number + /** Default network ID / chain ID (e.g., 42161 for Arbitrum) */ + defaultNetworkId?: number // ========== Invoice Defaults ========== @@ -77,7 +79,10 @@ export interface CreatorStoreV1 { version: 1 /** Active draft (single in-progress invoice) */ - activeDraft: InvoiceDraft | null + activeDraft: DraftState | null + + /** UI-specific: Line items with IDs for React keys */ + lineItems: LineItem[] /** Saved templates for reuse */ templates: InvoiceTemplate[] @@ -92,5 +97,5 @@ export interface CreatorStoreV1 { idCounter: InvoiceIDCounter } -// Re-export invoice types for convenience -export type { InvoiceDraft, InvoiceTemplate, CreationHistoryEntry } +// Re-export types for convenience +export type { Invoice, DraftState, InvoiceTemplate, CreationHistoryEntry, LineItem } diff --git a/src/entities/creator/model/useCreatorStore.ts b/src/entities/creator/model/useCreatorStore.ts index 1ba9fa65..b466352b 100644 --- a/src/entities/creator/model/useCreatorStore.ts +++ b/src/entities/creator/model/useCreatorStore.ts @@ -11,9 +11,14 @@ import { create } from 'zustand' import { persist, createJSONStorage } from 'zustand/middleware' import { v4 as uuidv4 } from 'uuid' +import { + invoiceItemsToLineItems, + type Invoice, + type DraftState, + type LineItem, +} from '@/entities/invoice' import type { CreatorStoreV1, - InvoiceDraft, InvoiceTemplate, CreationHistoryEntry, UserPreferences, @@ -27,9 +32,9 @@ interface CreatorStoreActions { // ========== Draft Management ========== /** - * Update the active draft (debounced in UI layer) + * Update the active draft data (debounced in UI layer) */ - updateDraft: (draft: Partial) => void + updateDraft: (data: Partial) => void /** * Clear the active draft (called after URL generation) @@ -41,6 +46,28 @@ interface CreatorStoreActions { */ createNewDraft: () => string + // ========== Line Items Management ========== + + /** + * Update line items (separate from draft for UI) + */ + updateLineItems: (items: LineItem[]) => void + + /** + * Add a new empty line item + */ + addLineItem: () => void + + /** + * Remove a line item by id + */ + removeLineItem: (id: string) => void + + /** + * Update a single line item + */ + updateLineItem: (id: string, updates: Partial>) => void + // ========== Template Management ========== /** @@ -122,12 +149,27 @@ interface CreatorStoreActions { */ type CreatorStore = CreatorStoreV1 & CreatorStoreActions +/** + * Get current Unix timestamp in seconds + */ +function nowUnix(): number { + return Math.floor(Date.now() / 1000) +} + +/** + * Get Unix timestamp for a date N days from now + */ +function daysFromNowUnix(days: number): number { + return nowUnix() + days * 24 * 60 * 60 +} + /** * Initial state */ const initialState: CreatorStoreV1 = { version: 1, activeDraft: null, + lineItems: [], templates: [], history: [], preferences: {}, @@ -137,16 +179,144 @@ const initialState: CreatorStoreV1 = { }, } +/** + * Create default draft with preferences + */ +function createDefaultDraft( + draftId: string, + invoiceId: string, + preferences: UserPreferences +): DraftState { + return { + meta: { + draftId, + lastModified: new Date().toISOString(), + }, + data: { + version: 2, + invoiceId, + issuedAt: nowUnix(), + dueAt: daysFromNowUnix(30), // Default: 30 days from now + networkId: preferences.defaultNetworkId ?? 1, + currency: preferences.defaultCurrency ?? 'USDC', + decimals: 6, // Default for USDC + from: { + name: preferences.defaultSenderName ?? '', + walletAddress: preferences.defaultSenderWallet ?? '', + ...(preferences.defaultSenderEmail && { email: preferences.defaultSenderEmail }), + ...(preferences.defaultSenderAddress && { + physicalAddress: preferences.defaultSenderAddress, + }), + }, + client: { + name: '', + }, + items: [], + ...(preferences.defaultTaxRate && { tax: preferences.defaultTaxRate }), + }, + } +} + +/** + * Create default line item + */ +function createDefaultLineItem(): LineItem { + return { + id: uuidv4(), + description: '', + quantity: 1, + rate: '0', + } +} + /** * Migration function for future schema versions */ // eslint-disable-next-line @typescript-eslint/no-explicit-any const migrate = (persistedState: any, version: number): CreatorStoreV1 => { - // Version 0 (no version) → Version 1 + // Version 0 (no version) -> Version 1 if (version === 0 || !persistedState.version) { + // Migrate from old InvoiceDraft format to new DraftState format + const oldDraft = persistedState.activeDraft + + let newDraft: DraftState | null = null + let lineItems: LineItem[] = [] + + if (oldDraft) { + // Extract line items with ids + lineItems = (oldDraft.lineItems ?? []).map( + (item: { id?: string; description: string; quantity: number; rate: string }) => ({ + id: item.id ?? uuidv4(), + description: item.description, + quantity: item.quantity, + rate: item.rate, + }) + ) + + // Convert dates from ISO to Unix timestamps + const issuedAt = oldDraft.issueDate + ? Math.floor(new Date(oldDraft.issueDate).getTime() / 1000) + : nowUnix() + + const dueAt = oldDraft.dueDate + ? Math.floor(new Date(oldDraft.dueDate).getTime() / 1000) + : daysFromNowUnix(30) + + newDraft = { + meta: { + draftId: oldDraft.draftId ?? uuidv4(), + lastModified: oldDraft.lastModified ?? new Date().toISOString(), + }, + data: { + version: 2, + invoiceId: oldDraft.invoiceId ?? '', + issuedAt, + dueAt, + networkId: oldDraft.chainId ?? 1, + currency: oldDraft.currencySymbol ?? 'USDC', + tokenAddress: oldDraft.tokenAddress, + decimals: oldDraft.decimals ?? 6, + from: { + name: oldDraft.sender?.name ?? '', + walletAddress: oldDraft.sender?.wallet ?? '', + ...(oldDraft.sender?.email && { email: oldDraft.sender.email }), + ...(oldDraft.sender?.address && { physicalAddress: oldDraft.sender.address }), + }, + client: { + name: oldDraft.recipient?.name ?? '', + ...(oldDraft.recipient?.wallet && { walletAddress: oldDraft.recipient.wallet }), + ...(oldDraft.recipient?.email && { email: oldDraft.recipient.email }), + ...(oldDraft.recipient?.address && { physicalAddress: oldDraft.recipient.address }), + }, + items: lineItems.map(({ description, quantity, rate }) => ({ + description, + quantity, + rate, + })), + ...(oldDraft.taxRate && oldDraft.taxRate !== '0' && { tax: oldDraft.taxRate }), + ...(oldDraft.discountAmount && + oldDraft.discountAmount !== '0' && { discount: oldDraft.discountAmount }), + ...(oldDraft.notes && { notes: oldDraft.notes }), + }, + } + } + + // Migrate preferences + const oldPrefs = persistedState.preferences ?? {} + const newPrefs: UserPreferences = { + ...oldPrefs, + // Rename defaultChainId to defaultNetworkId if it exists + ...(oldPrefs.defaultChainId && { defaultNetworkId: oldPrefs.defaultChainId }), + } + // Remove old key + delete (newPrefs as Record).defaultChainId + return { ...persistedState, version: 1, + activeDraft: newDraft, + lineItems, + preferences: newPrefs, } } @@ -184,105 +354,193 @@ export const useCreatorStore = create()( // ========== Draft Management ========== - updateDraft: (draft) => { + updateDraft: (data) => { set((state) => { const currentDraft = state.activeDraft // If no active draft, create a new one if (!currentDraft) { - const newDraft: InvoiceDraft = { - draftId: uuidv4(), - lastModified: new Date().toISOString(), - invoiceId: '', - issueDate: '', - dueDate: '', - chainId: state.preferences.defaultChainId || 1, - currencySymbol: state.preferences.defaultCurrency || 'USDC', - decimals: 6, - sender: { - name: state.preferences.defaultSenderName || '', - wallet: state.preferences.defaultSenderWallet || '', - ...(state.preferences.defaultSenderEmail && { - email: state.preferences.defaultSenderEmail, - }), - ...(state.preferences.defaultSenderAddress && { - address: state.preferences.defaultSenderAddress, - }), - }, - recipient: { - name: '', + const draftId = uuidv4() + const invoiceId = state.generateNextInvoiceId() + const newDraft = createDefaultDraft(draftId, invoiceId, state.preferences) + + return { + activeDraft: { + ...newDraft, + data: { ...newDraft.data, ...data }, }, - lineItems: [], - taxRate: state.preferences.defaultTaxRate || '0', - discountAmount: '0', - ...draft, + lineItems: [createDefaultLineItem()], } - - return { activeDraft: newDraft } } // Update existing draft return { activeDraft: { - ...currentDraft, - ...draft, - lastModified: new Date().toISOString(), + meta: { + ...currentDraft.meta, + lastModified: new Date().toISOString(), + }, + data: { + ...currentDraft.data, + ...data, + }, }, } }) }, clearDraft: () => { - set({ activeDraft: null }) + set({ activeDraft: null, lineItems: [] }) }, createNewDraft: () => { const draftId = uuidv4() const state = get() + const invoiceId = state.generateNextInvoiceId() - const newDraft: InvoiceDraft = { - draftId, - lastModified: new Date().toISOString(), - invoiceId: state.generateNextInvoiceId(), - issueDate: new Date().toISOString().split('T')[0] || '', - dueDate: '', - chainId: state.preferences.defaultChainId || 1, - currencySymbol: state.preferences.defaultCurrency || 'USDC', - decimals: 6, - sender: { - name: state.preferences.defaultSenderName || '', - wallet: state.preferences.defaultSenderWallet || '', - ...(state.preferences.defaultSenderEmail && { - email: state.preferences.defaultSenderEmail, - }), - ...(state.preferences.defaultSenderAddress && { - address: state.preferences.defaultSenderAddress, - }), - }, - recipient: { - name: '', - }, - lineItems: [ - { - id: uuidv4(), - description: '', - quantity: 1, - rate: '0', - }, - ], - taxRate: state.preferences.defaultTaxRate || '0', - discountAmount: '0', - } + const newDraft = createDefaultDraft(draftId, invoiceId, state.preferences) + + set({ + activeDraft: newDraft, + lineItems: [createDefaultLineItem()], + }) - set({ activeDraft: newDraft }) return draftId }, + // ========== Line Items Management ========== + + updateLineItems: (items) => { + set((state) => { + // Also sync to draft.data.items (without ids) + const invoiceItems = items.map(({ description, quantity, rate }) => ({ + description, + quantity, + rate, + })) + + if (!state.activeDraft) { + return { lineItems: items } + } + + return { + lineItems: items, + activeDraft: { + ...state.activeDraft, + meta: { + ...state.activeDraft.meta, + lastModified: new Date().toISOString(), + }, + data: { + ...state.activeDraft.data, + items: invoiceItems, + }, + }, + } + }) + }, + + addLineItem: () => { + set((state) => { + const newItem = createDefaultLineItem() + const newItems = [...state.lineItems, newItem] + + const invoiceItems = newItems.map(({ description, quantity, rate }) => ({ + description, + quantity, + rate, + })) + + if (!state.activeDraft) { + return { lineItems: newItems } + } + + return { + lineItems: newItems, + activeDraft: { + ...state.activeDraft, + meta: { + ...state.activeDraft.meta, + lastModified: new Date().toISOString(), + }, + data: { + ...state.activeDraft.data, + items: invoiceItems, + }, + }, + } + }) + }, + + removeLineItem: (id) => { + set((state) => { + const newItems = state.lineItems.filter((item) => item.id !== id) + + const invoiceItems = newItems.map(({ description, quantity, rate }) => ({ + description, + quantity, + rate, + })) + + if (!state.activeDraft) { + return { lineItems: newItems } + } + + return { + lineItems: newItems, + activeDraft: { + ...state.activeDraft, + meta: { + ...state.activeDraft.meta, + lastModified: new Date().toISOString(), + }, + data: { + ...state.activeDraft.data, + items: invoiceItems, + }, + }, + } + }) + }, + + updateLineItem: (id, updates) => { + set((state) => { + const newItems = state.lineItems.map((item) => + item.id === id ? { ...item, ...updates } : item + ) + + const invoiceItems = newItems.map(({ description, quantity, rate }) => ({ + description, + quantity, + rate, + })) + + if (!state.activeDraft) { + return { lineItems: newItems } + } + + return { + lineItems: newItems, + activeDraft: { + ...state.activeDraft, + meta: { + ...state.activeDraft.meta, + lastModified: new Date().toISOString(), + }, + data: { + ...state.activeDraft.data, + items: invoiceItems, + }, + }, + } + }) + }, + // ========== Template Management ========== saveAsTemplate: (name) => { const state = get() - const { activeDraft } = state + const { activeDraft, lineItems } = state if (!activeDraft) { throw new Error('No active draft to save as template') @@ -290,30 +548,29 @@ export const useCreatorStore = create()( const templateId = uuidv4() - // Auto-generate name if not provided - const templateName = - name || - `${activeDraft.recipient.name || 'Untitled'} - ${activeDraft.issueDate || new Date().toISOString().split('T')[0]}` + // Get client name for auto-generated template name + const clientName = activeDraft.data.client?.name ?? 'Untitled' + const dateStr = activeDraft.data.issuedAt + ? new Date(activeDraft.data.issuedAt * 1000).toISOString().split('T')[0] + : new Date().toISOString().split('T')[0] + + const templateName = name ?? `${clientName} - ${dateStr}` + + // Include line items in template data + const templateData: Partial = { + ...activeDraft.data, + items: lineItems.map(({ description, quantity, rate }) => ({ + description, + quantity, + rate, + })), + } const template: InvoiceTemplate = { templateId, name: templateName, createdAt: new Date().toISOString(), - invoiceData: { - invoiceId: activeDraft.invoiceId, - issueDate: activeDraft.issueDate, - dueDate: activeDraft.dueDate, - ...(activeDraft.notes && { notes: activeDraft.notes }), - chainId: activeDraft.chainId, - currencySymbol: activeDraft.currencySymbol, - ...(activeDraft.tokenAddress && { tokenAddress: activeDraft.tokenAddress }), - decimals: activeDraft.decimals, - sender: activeDraft.sender, - recipient: activeDraft.recipient, - lineItems: activeDraft.lineItems, - taxRate: activeDraft.taxRate, - discountAmount: activeDraft.discountAmount, - }, + invoiceData: templateData, } set((state) => ({ @@ -331,13 +588,33 @@ export const useCreatorStore = create()( throw new Error(`Template ${templateId} not found`) } - const newDraft: InvoiceDraft = { - draftId: uuidv4(), - lastModified: new Date().toISOString(), - ...template.invoiceData, + const draftId = uuidv4() + + // Convert template items to LineItems with ids + const lineItems: LineItem[] = (template.invoiceData.items ?? []).map((item) => ({ + id: uuidv4(), + description: item.description, + quantity: typeof item.quantity === 'string' ? parseFloat(item.quantity) : item.quantity, + rate: item.rate, + })) + + const newDraft: DraftState = { + meta: { + draftId, + lastModified: new Date().toISOString(), + }, + data: { + ...template.invoiceData, + // Update dates to current + issuedAt: nowUnix(), + dueAt: daysFromNowUnix(30), + }, } - set({ activeDraft: newDraft }) + set({ + activeDraft: newDraft, + lineItems, + }) }, deleteTemplate: (templateId) => { @@ -385,44 +662,32 @@ export const useCreatorStore = create()( } // Create a new draft from the history entry - // Note: We don't have full invoice data in history, so this is a simplified version + // Use full invoice data from history entry const draftId = uuidv4() - const newDraft: InvoiceDraft = { - draftId, - lastModified: new Date().toISOString(), - invoiceId: entry.invoiceId, - issueDate: new Date().toISOString().split('T')[0] || '', - dueDate: '', - chainId: state.preferences.defaultChainId || 1, - currencySymbol: state.preferences.defaultCurrency || 'USDC', - decimals: 6, - sender: { - name: state.preferences.defaultSenderName || '', - wallet: state.preferences.defaultSenderWallet || '', - ...(state.preferences.defaultSenderEmail && { - email: state.preferences.defaultSenderEmail, - }), - ...(state.preferences.defaultSenderAddress && { - address: state.preferences.defaultSenderAddress, - }), + const newDraft: DraftState = { + meta: { + draftId, + lastModified: new Date().toISOString(), }, - recipient: { - name: entry.recipientName, + data: { + ...entry.invoice, + // Reset dates for new invoice + issuedAt: nowUnix(), + dueAt: daysFromNowUnix(30), }, - lineItems: [ - { - id: uuidv4(), - description: '', - quantity: 1, - rate: '0', - }, - ], - taxRate: state.preferences.defaultTaxRate || '0', - discountAmount: '0', } - set({ activeDraft: newDraft }) + // Convert invoice items to line items with IDs + const restoredLineItems = entry.invoice.items?.length + ? invoiceItemsToLineItems(entry.invoice.items) + : [createDefaultLineItem()] + + set({ + activeDraft: newDraft, + lineItems: restoredLineItems, + }) + return draftId }, @@ -509,6 +774,7 @@ export const useCreatorStore = create()( partialize: (state) => ({ version: state.version, activeDraft: state.activeDraft, + lineItems: state.lineItems, templates: state.templates, history: state.history, preferences: state.preferences, diff --git a/src/entities/invoice/index.ts b/src/entities/invoice/index.ts index ef21dd35..dc2c6fe0 100644 --- a/src/entities/invoice/index.ts +++ b/src/entities/invoice/index.ts @@ -5,17 +5,22 @@ * across the application following FSD public API conventions. */ -// Types (from model layer) -export type { InvoiceSchemaV1 } from './model/schema' +// Core Invoice type (from schema) +export type { Invoice } from './model/schema' +// Types (from model layer) export type { - InvoiceDraft, - InvoiceTemplate, LineItem, + DraftMetadata, + DraftState, + InvoiceTemplate, PaymentReceipt, CreationHistoryEntry, } from './model/types' +// Helper functions for LineItem conversion and formatting +export { lineItemsToInvoiceItems, invoiceItemsToLineItems, formatInvoiceTotal } from './model/types' + // Validation (from lib layer) export { invoiceSchema } from './lib/validation' diff --git a/src/entities/invoice/lib/validation.ts b/src/entities/invoice/lib/validation.ts index ca1cbd55..8c7af1ff 100644 --- a/src/entities/invoice/lib/validation.ts +++ b/src/entities/invoice/lib/validation.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { InvoiceSchemaV1 } from '../model/schema' +import { Invoice } from '../model/schema' import { ETH_ADDRESS_REGEX } from '@/shared/lib/validation' import { NUMERIC_STRING_REGEX } from './constants' @@ -7,40 +7,42 @@ import { NUMERIC_STRING_REGEX } from './constants' export { ETH_ADDRESS_REGEX } from '@/shared/lib/validation' export const invoiceSchema = z.object({ - v: z.literal(1), - id: z.string().min(1), - iss: z.number().int().positive(), - due: z.number().int().positive(), - nt: z.string().max(280).optional(), - net: z.number().int().positive(), - cur: z.string().min(1), - t: z.string().regex(ETH_ADDRESS_REGEX, 'Invalid token address').optional(), - dec: z.number().int().nonnegative(), - f: z.object({ - n: z.string().min(1), - a: z.string().regex(ETH_ADDRESS_REGEX, 'Invalid sender address'), - e: z.string().email().optional(), - ads: z.string().optional(), - ph: z.string().optional(), + version: z.literal(2), + invoiceId: z.string().min(1), + issuedAt: z.number().int().positive(), + dueAt: z.number().int().positive(), + notes: z.string().max(280).optional(), + networkId: z.number().int().positive(), + currency: z.string().min(1), + tokenAddress: z.string().regex(ETH_ADDRESS_REGEX, 'Invalid token address').optional(), + decimals: z.number().int().nonnegative(), + from: z.object({ + name: z.string().min(1), + walletAddress: z.string().regex(ETH_ADDRESS_REGEX, 'Invalid sender address'), + email: z.string().email().optional(), + physicalAddress: z.string().optional(), + phone: z.string().optional(), + taxId: z.string().optional(), }), - c: z.object({ - n: z.string().min(1), - a: z.string().regex(ETH_ADDRESS_REGEX, 'Invalid client address').optional(), - e: z.string().email().optional(), - ads: z.string().optional(), - ph: z.string().optional(), + client: z.object({ + name: z.string().min(1), + walletAddress: z.string().regex(ETH_ADDRESS_REGEX, 'Invalid client address').optional(), + email: z.string().email().optional(), + physicalAddress: z.string().optional(), + phone: z.string().optional(), + taxId: z.string().optional(), }), - it: z + items: z .array( z.object({ - d: z.string().min(1), - q: z.union([z.string().regex(NUMERIC_STRING_REGEX), z.number().positive()]), - r: z.string().regex(NUMERIC_STRING_REGEX, 'Invalid rate'), + description: z.string().min(1), + quantity: z.union([z.string().regex(NUMERIC_STRING_REGEX), z.number().positive()]), + rate: z.string().regex(NUMERIC_STRING_REGEX, 'Invalid rate'), }) ) .min(1), tax: z.string().optional(), - dsc: z.string().optional(), + discount: z.string().optional(), meta: z.record(z.string(), z.unknown()).optional(), _future: z.unknown().optional(), }) @@ -48,4 +50,4 @@ export const invoiceSchema = z.object({ // Verify that the Zod schema matches the TypeScript interface // This is a type-level check // eslint-disable-next-line @typescript-eslint/no-unused-vars -type InvoiceSchemaCheck = z.infer extends InvoiceSchemaV1 ? true : false +type InvoiceSchemaCheck = z.infer extends Invoice ? true : false diff --git a/src/entities/invoice/model/schema.ts b/src/entities/invoice/model/schema.ts index 1da0c917..f0ae2381 100644 --- a/src/entities/invoice/model/schema.ts +++ b/src/entities/invoice/model/schema.ts @@ -1,61 +1,65 @@ -export interface InvoiceSchemaV1 { - /** Schema Version (Fixed: 1) */ - v: 1 +export interface Invoice { + /** Schema Version (Fixed: 2) */ + version: 2 /** Invoice ID (UUID or unique string) */ - id: string + invoiceId: string /** Issue Date (Unix Timestamp in seconds) */ - iss: number + issuedAt: number /** Due Date (Unix Timestamp in seconds) */ - due: number + dueAt: number /** Notes (Max 280 chars) */ - nt?: string | undefined + notes?: string | undefined /** Network Chain ID (e.g., 1, 137) */ - net: number + networkId: number /** Currency Symbol (e.g., "USDC", "ETH") */ - cur: string + currency: string /** Token Address (Optional, undefined for native) */ - t?: string | undefined + tokenAddress?: string | undefined /** Token Decimals (Required for precision) */ - dec: number + decimals: number /** Sender Info */ - f: { + from: { /** Name */ - n: string + name: string /** Wallet Address */ - a: string + walletAddress: string /** Email (Optional) */ - e?: string | undefined + email?: string | undefined /** Physical Address (Optional, multi-line allowed) */ - ads?: string | undefined + physicalAddress?: string | undefined /** Phone (Optional) */ - ph?: string | undefined + phone?: string | undefined + /** Tax ID (Optional) */ + taxId?: string | undefined } /** Client Info */ - c: { + client: { /** Name */ - n: string + name: string /** Wallet Address (Optional) */ - a?: string | undefined + walletAddress?: string | undefined /** Email (Optional) */ - e?: string | undefined + email?: string | undefined /** Physical Address (Optional, multi-line allowed) */ - ads?: string | undefined + physicalAddress?: string | undefined /** Phone (Optional) */ - ph?: string | undefined + phone?: string | undefined + /** Tax ID (Optional) */ + taxId?: string | undefined } /** Line Items */ - it: Array<{ + items: Array<{ /** Description */ - d: string + description: string /** Quantity (BigInt string or number) */ - q: string | number + quantity: string | number /** Rate/Price (BigInt string) */ - r: string + rate: string }> /** Tax Rate (Percentage string e.g. "10%" or Fixed Amount string) */ tax?: string | undefined /** Discount (Percentage string e.g. "10%" or Fixed Amount string) */ - dsc?: string | undefined + discount?: string | undefined /** Reserved: Metadata (Extensibility) */ meta?: Record | undefined /** Reserved: Future use */ diff --git a/src/entities/invoice/model/types.ts b/src/entities/invoice/model/types.ts index c45d9290..eeb6c6e8 100644 --- a/src/entities/invoice/model/types.ts +++ b/src/entities/invoice/model/types.ts @@ -5,115 +5,69 @@ * These types are used across the application for invoice creation and management. */ +import type { Invoice } from './schema' + +// Re-export Invoice for convenience +export type { Invoice } + /** - * LineItem + * LineItem (UI version with ID for React keys) * - * Represents an individual line item in an invoice + * Used in forms for tracking individual line items. + * When encoding to Invoice, the `id` is stripped. */ export interface LineItem { - /** Unique identifier (UUID v4) */ + /** Unique identifier for React key (UUID v4) */ id: string - /** Item description */ description: string - /** Quantity (must be > 0) */ quantity: number - /** Rate per unit (decimal string) */ rate: string } /** - * InvoiceDraft + * DraftMetadata * - * Represents the single active in-progress invoice. - * This structure matches the InvoiceSchemaV1 from the URL state codec. + * Metadata for an in-progress invoice draft. + * Stored separately from invoice data. */ -export interface InvoiceDraft { +export interface DraftMetadata { /** Unique draft identifier (UUID v4) */ draftId: string - /** Last modification timestamp (ISO 8601) */ lastModified: string +} - // ========== Invoice Fields ========== - - /** Invoice ID (max 50 chars) */ - invoiceId: string - - /** Issue date (ISO 8601) */ - issueDate: string - - /** Due date (ISO 8601) */ - dueDate: string - - /** Optional notes (max 280 chars) */ - notes?: string - - // ========== Network & Currency ========== - - /** Chain ID (e.g., 1 for Ethereum, 42161 for Arbitrum) */ - chainId: number - - /** Currency symbol (e.g., "USDC", "ETH") */ - currencySymbol: string - - /** Token contract address (undefined = native token) */ - tokenAddress?: string - - /** Token decimals */ - decimals: number - - // ========== Parties ========== - - /** Sender (invoice creator) information */ - sender: { - name: string - wallet: string - email?: string - address?: string - } - - /** Recipient (payer) information */ - recipient: { - name: string - wallet?: string - email?: string - address?: string - } - - // ========== Line Items ========== - - /** Line items (at least 1 required) */ - lineItems: LineItem[] - - // ========== Calculations ========== - - /** Tax rate (e.g., "10%" or "50") */ - taxRate: string - - /** Discount amount (e.g., "10%" or "50") */ - discountAmount: string +/** + * DraftState + * + * Complete draft state combining metadata and partial invoice data. + * The invoice data may be incomplete during editing. + */ +export interface DraftState { + /** Draft metadata */ + meta: DraftMetadata + /** Partial invoice data (may be incomplete) */ + data: Partial } /** * InvoiceTemplate * * Saved invoice template for reuse. + * Contains partial invoice data that can be loaded and completed. */ export interface InvoiceTemplate { /** Unique template identifier (UUID v4) */ templateId: string - /** Template name (user-provided or auto-generated) */ name: string - /** Creation timestamp (ISO 8601) */ createdAt: string - - /** Full invoice data (minus draft-specific fields) */ - invoiceData: Omit + /** Invoice data (partial, merged with defaults when loaded) */ + invoiceData: Partial> } /** @@ -124,29 +78,14 @@ export interface InvoiceTemplate { export interface CreationHistoryEntry { /** Unique entry identifier (UUID v4) */ entryId: string - /** Creation timestamp (ISO 8601) */ createdAt: string - - // ========== Key Details ========== - - /** Invoice ID (e.g., "INV-001") */ - invoiceId: string - - /** Recipient name for display */ - recipientName: string - - /** Total amount string with currency (e.g., "1250.50 USDC") */ - totalAmount: string - + /** Full invoice data */ + invoice: Invoice /** Full URL for quick access (contains compressed data) */ invoiceUrl: string - - // ========== Transaction Discovery ========== - /** Transaction Hash (if discovered via polling) */ txHash?: string - /** Payment timestamp (if discovered via polling) */ paidAt?: string } @@ -159,29 +98,74 @@ export interface CreationHistoryEntry { export interface PaymentReceipt { /** Unique receipt identifier (UUID v4) */ receiptId: string - /** Payment timestamp (ISO 8601) */ paidAt: string - - // ========== Invoice Details ========== - /** Invoice ID */ invoiceId: string - /** Recipient name */ recipientName: string - /** Payment amount string with currency (e.g., "1250.50 USDC") */ paymentAmount: string - - // ========== Transaction Details ========== - /** Transaction Hash (0x...) */ transactionHash: string - /** Chain ID */ chainId: number - /** Original invoice URL (for reference) */ invoiceUrl: string } + +// ============ Helpers ============ + +/** + * Convert LineItem[] (with IDs) to Invoice items format (without IDs) + */ +export function lineItemsToInvoiceItems(lineItems: LineItem[]): Invoice['items'] { + return lineItems.map(({ description, quantity, rate }) => ({ + description, + quantity, + rate, + })) +} + +/** + * Convert Invoice items to LineItem[] (adding IDs) + */ +export function invoiceItemsToLineItems(items: Invoice['items']): LineItem[] { + return items.map((item) => ({ + id: crypto.randomUUID(), + description: item.description, + quantity: typeof item.quantity === 'number' ? item.quantity : parseFloat(item.quantity), + rate: item.rate, + })) +} + +/** + * Calculate and format total amount from invoice + * @returns Formatted string like "1250.50 USDC" + */ +export function formatInvoiceTotal(invoice: Invoice): string { + const subtotal = invoice.items.reduce((sum, item) => { + const qty = + typeof item.quantity === 'number' ? item.quantity : parseFloat(String(item.quantity)) + const rate = parseFloat(item.rate) + return sum + qty * rate + }, 0) + + let total = subtotal + + if (invoice.tax) { + const taxValue = invoice.tax.endsWith('%') + ? (subtotal * parseFloat(invoice.tax)) / 100 + : parseFloat(invoice.tax) + total += taxValue + } + + if (invoice.discount) { + const discountValue = invoice.discount.endsWith('%') + ? (subtotal * parseFloat(invoice.discount)) / 100 + : parseFloat(invoice.discount) + total -= discountValue + } + + return `${total.toFixed(2)} ${invoice.currency}` +} diff --git a/src/entities/invoice/model/viewed-invoice-store.ts b/src/entities/invoice/model/viewed-invoice-store.ts index f7d2ff9d..dafb4def 100644 --- a/src/entities/invoice/model/viewed-invoice-store.ts +++ b/src/entities/invoice/model/viewed-invoice-store.ts @@ -8,7 +8,7 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import { INVOICE_VIEW_STORE_KEY } from '@/shared/config' -import type { InvoiceSchemaV1 } from './schema' +import type { Invoice } from './schema' /** * Status of a viewed invoice @@ -24,7 +24,7 @@ export interface ViewedInvoice { /** Generated URL for sharing */ invoiceUrl: string /** Original invoice data */ - data: InvoiceSchemaV1 + data: Invoice /** Current payment status */ status: ViewedInvoiceStatus /** Transaction hash (if paid) */ diff --git a/src/entities/network/config/networks.ts b/src/entities/network/config/networks.ts index 464b7478..c40a610f 100644 --- a/src/entities/network/config/networks.ts +++ b/src/entities/network/config/networks.ts @@ -31,3 +31,20 @@ export const NETWORKS = { } as const export type NetworkId = keyof typeof NETWORKS + +/** + * Short network codes for compact URLs (OG previews, etc.) + */ +export const NETWORK_CODES: Record = { + [mainnet.id]: 'eth', + [arbitrum.id]: 'arb', + [optimism.id]: 'op', + [polygon.id]: 'poly', +} + +export const NETWORK_CODES_REVERSE: Record = { + eth: mainnet.id, + arb: arbitrum.id, + op: optimism.id, + poly: polygon.id, +} diff --git a/src/entities/network/index.ts b/src/entities/network/index.ts index 2c4e96e5..be0eca66 100644 --- a/src/entities/network/index.ts +++ b/src/entities/network/index.ts @@ -6,7 +6,7 @@ */ // Network chain configuration (from config layer) -export { NETWORKS } from './config/networks' +export { NETWORKS, NETWORK_CODES, NETWORK_CODES_REVERSE } from './config/networks' export type { NetworkId } from './config/networks' // Network UI configuration (from config layer) diff --git a/src/features/data-export/model/import.ts b/src/features/data-export/model/import.ts index 5b9f3239..0e58e903 100644 --- a/src/features/data-export/model/import.ts +++ b/src/features/data-export/model/import.ts @@ -58,10 +58,8 @@ export const importUserData = (data: unknown): ImportResult => { const exists = creatorStore.history.some((h) => h.entryId === entry.entryId) if (!exists) { creatorStore.addHistoryEntry({ - invoiceId: entry.invoiceId, + invoice: entry.invoice, invoiceUrl: entry.invoiceUrl, - recipientName: entry.recipientName, - totalAmount: entry.totalAmount, }) historyAdded++ } diff --git a/src/features/generate-link/lib/generate-invoice.ts b/src/features/generate-link/lib/generate-invoice.ts index 1b12d377..26aff667 100644 --- a/src/features/generate-link/lib/generate-invoice.ts +++ b/src/features/generate-link/lib/generate-invoice.ts @@ -5,41 +5,49 @@ * This feature combines entities/invoice (data) with entities/creator (storage). */ -import type { InvoiceDraft } from '@/entities/invoice' +import { + lineItemsToInvoiceItems, + type Invoice, + type DraftState, + type LineItem, +} from '@/entities/invoice' import { useCreatorStore } from '@/entities/creator' /** - * Calculate total amount from invoice draft + * Calculate total amount from invoice data * - * @param draft - Invoice draft + * @param invoice - Partial invoice data + * @param lineItems - Line items with UI ids * @returns Total amount as decimal string with currency symbol */ -export function calculateTotalAmount(draft: InvoiceDraft): string { +export function calculateTotalAmount(invoice: Partial, lineItems: LineItem[]): string { + const currency = invoice.currency ?? 'USDC' + // Calculate subtotal from line items - const subtotal = draft.lineItems.reduce((sum, item) => { + const subtotal = lineItems.reduce((sum, item) => { const itemTotal = parseFloat(item.rate) * item.quantity return sum + itemTotal }, 0) - // Apply tax rate + // Apply tax let total = subtotal - if (draft.taxRate) { - const taxValue = draft.taxRate.endsWith('%') - ? (subtotal * parseFloat(draft.taxRate)) / 100 - : parseFloat(draft.taxRate) + if (invoice.tax) { + const taxValue = invoice.tax.endsWith('%') + ? (subtotal * parseFloat(invoice.tax)) / 100 + : parseFloat(invoice.tax) total += taxValue } // Apply discount - if (draft.discountAmount) { - const discountValue = draft.discountAmount.endsWith('%') - ? (subtotal * parseFloat(draft.discountAmount)) / 100 - : parseFloat(draft.discountAmount) + if (invoice.discount) { + const discountValue = invoice.discount.endsWith('%') + ? (subtotal * parseFloat(invoice.discount)) / 100 + : parseFloat(invoice.discount) total -= discountValue } // Format with currency symbol - return `${total.toFixed(2)} ${draft.currencySymbol}` + return `${total.toFixed(2)} ${currency}` } /** @@ -47,47 +55,58 @@ export function calculateTotalAmount(draft: InvoiceDraft): string { * * This should be called after successfully generating an invoice URL. * - * @param draft - Invoice draft that was converted to URL + * @param invoice - Full invoice data * @param invoiceUrl - Generated invoice URL * * @example - * const url = await generateInvoiceUrl(draft) - * addToHistory(draft, url) + * const url = await generateInvoiceUrl(invoice) + * addToHistory(invoice, url) */ -export function addToHistory(draft: InvoiceDraft, invoiceUrl: string): void { +export function addToHistory(invoice: Invoice, invoiceUrl: string): void { const { addHistoryEntry } = useCreatorStore.getState() - const totalAmount = calculateTotalAmount(draft) - addHistoryEntry({ - invoiceId: draft.invoiceId, - recipientName: draft.recipient.name, - totalAmount, + invoice, invoiceUrl, }) } +/** + * Build full Invoice from draft and line items + */ +export function buildInvoice(draft: DraftState, lineItems: LineItem[]): Invoice { + return { + ...draft.data, + items: lineItemsToInvoiceItems(lineItems), + } as Invoice +} + /** * Generate invoice URL and add to history * * This is a convenience function that combines URL generation and history tracking. * Replace the URL generation logic with actual implementation. * - * @param draft - Invoice draft + * @param draft - Draft state with invoice data + * @param lineItems - Line items for the invoice * @returns Generated invoice URL * * @example - * const url = await generateAndTrackInvoice(draft) + * const url = await generateAndTrackInvoice(draft, lineItems) * router.push(url) */ -export async function generateAndTrackInvoice(draft: InvoiceDraft): Promise { +export async function generateAndTrackInvoice( + draft: DraftState, + lineItems: LineItem[] +): Promise { // TODO: Replace with actual URL generation logic from url-state-codec // For now, this is a placeholder const baseUrl = typeof window !== 'undefined' ? window.location.origin : '' - const invoiceUrl = `${baseUrl}/invoice?draft=${draft.draftId}` + const invoiceUrl = `${baseUrl}/invoice?draft=${draft.meta.draftId}` - // Add to history - addToHistory(draft, invoiceUrl) + // Build full invoice and add to history + const invoice = buildInvoice(draft, lineItems) + addToHistory(invoice, invoiceUrl) return invoiceUrl } diff --git a/src/features/invoice-codec/README.md b/src/features/invoice-codec/README.md index beefd355..77695590 100644 --- a/src/features/invoice-codec/README.md +++ b/src/features/invoice-codec/README.md @@ -1,210 +1,107 @@ -# URL State Codec System +# Invoice Codec -Stateless URL-based invoice encoding/decoding system for VoidPay. +Binary URL encoding for stateless invoices. -## Features - -- ✅ **Zero-Backend**: All invoice data encoded in URL -- ✅ **Compression**: LZ-based compression keeps URLs under 2000 bytes -- ✅ **Validation**: Zod schema validation for data integrity -- ✅ **Versioning**: Forward-compatible schema versioning (v1) -- ✅ **Type-Safe**: Full TypeScript support - -## Installation - -Dependencies are already installed in the project: - -- `lz-string` - URL-safe compression -- `zod` - Runtime validation - -## Configuration +## Overview -Set the application URL in your environment variables (`.env.local`): +All invoice data lives in the URL — no backend storage. Uses hash fragments for privacy (never sent to server). -```bash -NEXT_PUBLIC_APP_URL=https://voidpay.com ``` +https://voidpay.xyz/pay#N4IgbghgTg9g... + └─ Binary-compressed invoice (private) +``` + +## Features -If not set, the system will fall back to `https://voidpay.com` as the default. +- **Binary V3 codec** — pako compression, ~40% smaller than JSON+lz-string +- **Hash fragments** — data never leaves browser +- **OG previews** — optional `?og=` param for social cards (minimal metadata only) +- **Version-locked** — v1 URLs work forever (Constitution Principle IV) ## Usage -### Encoding an Invoice +### Encode Invoice → URL ```typescript -import { generateInvoiceUrl, InvoiceSchemaV1 } from '@/features/invoice-codec' - -const invoice: InvoiceSchemaV1 = { - v: 1, - id: 'inv_123', - iss: 1732070000, - due: 1732674800, - net: 1, - cur: 'USDC', - dec: 6, - f: { n: 'Alice', a: '0x1234567890123456789012345678901234567890' }, - c: { n: 'Bob' }, - it: [{ d: 'Service', q: 1, r: '100000000' }], +import { generateInvoiceUrl } from '@/features/invoice-codec' +import type { Invoice } from '@/entities/invoice' + +const invoice: Invoice = { + version: 2, + invoiceId: 'INV-001', + issuedAt: 1732070000, + dueAt: 1732674800, + networkId: 1, + currency: 'USDC', + decimals: 6, + from: { name: 'Alice', walletAddress: '0x123...' }, + client: { name: 'Bob' }, + items: [{ description: 'Service', quantity: 1, rate: '100.00' }], } -try { - const url = generateInvoiceUrl(invoice) - console.log('Shareable URL:', url) -} catch (error) { - console.error('Encoding failed:', error.message) -} +const url = generateInvoiceUrl(invoice) +// → https://voidpay.xyz/pay#N4IgbghgTg9g... ``` -### Decoding a URL +### Decode URL → Invoice ```typescript import { decodeInvoice } from '@/features/invoice-codec' -const searchParams = new URLSearchParams(window.location.search) -const compressedData = searchParams.get('d') - -if (compressedData) { - try { - const invoice = decodeInvoice(compressedData) - console.log('Invoice Data:', invoice) - } catch (error) { - console.error('Invalid invoice URL:', error.message) - } -} +const hash = window.location.hash.slice(1) // Remove # +const invoice = decodeInvoice(hash) ``` -## API Reference - -### `generateInvoiceUrl(invoice, baseUrl?)` - -Generates a shareable URL with compressed invoice data. - -- **Parameters**: - - `invoice: InvoiceSchemaV1` - Invoice data to encode - - `baseUrl?: string` - Optional base URL override (default: uses `NEXT_PUBLIC_APP_URL` env or `https://voidpay.com`) -- **Returns**: `string` - Full URL with compressed data (e.g., `https://voidpay.com/pay?d=...`) -- **Throws**: Error if URL exceeds 2000 bytes - -### `encodeInvoice(invoice)` - -Encodes invoice into compressed string (without URL wrapping). - -- **Parameters**: `invoice: InvoiceSchemaV1` -- **Returns**: `string` - Compressed data string - -### `decodeInvoice(compressed)` - -Decodes compressed string into validated invoice object. - -- **Parameters**: `compressed: string` - Compressed data from URL -- **Returns**: `InvoiceSchemaV1` - Validated invoice object -- **Throws**: Error if decompression, parsing, or validation fails +## OG Previews -## Schema Version 1 - -The `InvoiceSchemaV1` interface uses abbreviated keys to minimize payload size: +Optional social card metadata (doesn't expose full invoice): ```typescript -interface InvoiceSchemaV1 { - v: 1; // Version - id: string; // Invoice ID - iss: number; // Issue date (Unix timestamp) - due: number; // Due date (Unix timestamp) - nt?: string; // Notes (max 280 chars) - net: number; // Network chain ID - cur: string; // Currency symbol - t?: string; // Token address (optional) - dec: number; // Token decimals - f: { ... }; // Sender info - c: { ... }; // Client info - it: Array<{ ... }>; // Line items - tax?: string; // Tax rate - dsc?: string; // Discount - meta?: Record; // Reserved - _future?: unknown; // Reserved -} -``` - -## Validation Rules - -- URL must be ≤ 2000 bytes -- Notes must be ≤ 280 characters -- Dates must be positive integers -- Addresses must be valid Ethereum addresses (0x + 40 hex chars) -- Amounts must be valid numeric strings +import { encodeOGPreview } from '@/features/invoice-codec' -## Architecture +const ogParam = encodeOGPreview(invoice) +// → "a1b2c3d4_1250.00_USDC_arb_Acme_1231" +const fullUrl = `https://voidpay.xyz/pay?og=${ogParam}#${encoded}` ``` -src/ -├── entities/invoice/ -│ ├── model/schema.ts # InvoiceSchemaV1 interface -│ └── lib/validation.ts # Zod validation schemas -├── features/invoice-codec/ -│ ├── lib/ -│ │ ├── encode.ts # URL encoding logic -│ │ └── decode.ts # URL decoding + versioning -│ └── index.ts # Public API -└── shared/lib/compression/ # lz-string wrapper -``` - -## Backward Compatibility -The decoder supports version-specific parsing: +Format: `id_amount_currency_network[_from][_due]` -- **v1 parser is immutable** (Constitution Principle IV) -- Future versions (v2, v3) can be added without breaking v1 URLs -- Reserved fields (`meta`, `_future`) allow extensibility +## API -## Error Handling +| Function | Description | +| ----------------------------- | --------------------------- | +| `generateInvoiceUrl(invoice)` | Full URL with hash fragment | +| `encodeInvoice(invoice)` | Binary string only | +| `decodeInvoice(hash)` | Hash → validated Invoice | +| `encodeOGPreview(invoice)` | Minimal OG metadata | +| `decodeOGPreview(str)` | Parse OG metadata | -All functions throw descriptive errors: - -```typescript -// URL too large -Error: URL size (2150 bytes) exceeds 2000 byte limit +## Constraints -// Invalid data -Error: Invalid invoice data: f.a: Invalid sender address +| Limit | Value | Reason | +| --------- | ---------- | ------------------------ | +| URL max | 2000 bytes | QR codes, browser safety | +| Notes max | 280 chars | Twitter-like brevity | -// Unsupported version -Error: Unsupported schema version: 2 +## Architecture -// Decompression failure -Error: Failed to decompress invoice data +``` +features/invoice-codec/ +├── lib/ +│ ├── encode.ts # Invoice → Binary URL +│ ├── decode.ts # Binary URL → Invoice +│ └── og-preview.ts # Social card metadata +└── index.ts # Public API + +shared/lib/binary-codec/ +├── encoder-v3.ts # Pako + custom binary format +├── decoder-v3.ts # Version-aware decoding +└── dictionary.ts # Common value compression ``` -## Implementation Status - -✅ **Completed Tasks**: - -- T001: Dependencies installed (lz-string, zod) -- T002: Project structure created -- T003: InvoiceSchemaV1 interface -- T004: Zod validation schemas -- T005: Compression utility -- T006: URL encoding logic -- T007: URL decoding logic -- T008: Public API barrel file -- T010: Version-specific parsing -- T012: Zod validation integration -- T014: Export verification - -⏭️ **Skipped** (as per user request): - -- T009: Round-trip tests -- T011: Forward compatibility tests -- T013: Validation and limit tests - -## Next Steps - -To integrate into the application: - -1. Import codec functions in invoice creation UI -2. Use `generateInvoiceUrl()` when user clicks "Share" -3. Use `decodeInvoice()` on the payment page to load invoice data -4. Add error boundaries for invalid URLs - -## License +## Versioning -Part of the VoidPay stateless invoicing platform. +- `version: 2` — current schema (readable field names) +- Decoder auto-detects version from binary header +- Old URLs remain valid forever diff --git a/src/features/invoice-codec/__tests__/__snapshots__/schema.test.ts.snap b/src/features/invoice-codec/__tests__/__snapshots__/schema.test.ts.snap index 7b8802e1..fd00c5eb 100644 --- a/src/features/invoice-codec/__tests__/__snapshots__/schema.test.ts.snap +++ b/src/features/invoice-codec/__tests__/__snapshots__/schema.test.ts.snap @@ -1,54 +1,54 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Invoice Schema V1 Encoding > Snapshot Tests - Backward Compatibility Protection > should encode full invoice to stable compressed format > full-invoice-v1-encoded 1`] = `"N4IgbiBcCMA0IEsAmUQEkByA1AtAJgAY8AWHAg6EeBAZxqmgHYDiCA2Rwg+JAVwFMGzDsQCsbcvAB2AF1QAFAIYBPALb9ZAAgBmAewBOmgO78ARpqT8w-ADa6ADuq01++sAgDG-etP5yY8B68+qgAqgDKACIAwlQg-iAEAB4AggSmABxsAJzZ0B5seNAZpgDMbB7QSNDZkcSKePzZAKKmBB7N5ez8AELEGXGWHlBs8NpQoFKoKR7qmpFWtg5OMpoAMmux8IqoydB4pWIcGdkUB0eMJ2eH4pen+zfHp3GCkCCmCDY2CFIA5gACilm-AAdJYINskPQ3g9NAAVfgeAAWmnCMn0-D8AB0pOFFFJNAAxfT4jy0Dy6WCaaIpTTZYjQAiiOL2JGoADU0Bwoh5OAeOAuIAAvoEJiApm9ot8NKtogZ7AZFDIELopttdklFKYPJZtFqdfw9drdfqTcbDaaLcaXqggRTeLIaP8PNLZCCKao4oooagjpoerwaD9vDRNClrDiMPwjJoAJoGADWVIwsc0jPIlHgrI5XJ5ohw2QyjBwbFExFKwuo-gA2qAUG9iaqZBokPNFnZHDK4gBHKCseAhGGicgj8iVuuoHpAhMtsPyNBt6wdlY9kbcECDkBMYejgjjkD1kByyyaABKVgQ0c0ADJNABFFKrmDrzf7Hej4UAXXgMkUSVQGQgqIACkgw0MMbwgcKQA"`; +exports[`Invoice Schema V1 Encoding > Snapshot Tests - Backward Compatibility Protection > should encode full invoice to stable compressed format > full-invoice-v1-encoded 1`] = `"H6a04fLaE11pvj6yXIJiCQvF123L2rNpnqW17RgcGf3dRwaUegQQvdiF69FwpCrGqUZbGfuDG4Zi8kbvh3rXHeptRPms15GWgMLtOGWE44PJ9hRxZ7fdTzHzo6LFRYmXzh3OVCtIvUMI184FG6dm6F8jCOVH1NfDEaCIRG1xfjhnbEIzzhrGL91V8RsaOapER7iVkqtWXFwZFtsE8RMbmOZdwo3FDW9qfMLyk01GZ7BTr4ng1TvBBxjqkDycggyIiW3LTiYhEyiNefBcztwGFOl1Jwmm91GBVigIwa7rkPWdaRWw6r7zJBsnUTcphkWbU4YiG4LxDfLhGBqbCq06WsvjYOUZAQOo3rCyLyB2rHe6l6Zd2nb9Lt0tmTZybexaPwE3f6ljaaRF5uBYZBk9Z5lsRK6IJebSQ"`; -exports[`Invoice Schema V1 Encoding > Snapshot Tests - Backward Compatibility Protection > should encode minimal invoice to stable compressed format > minimal-invoice-v1-encoded 1`] = `"N4IgbiBcCMA0IEsAmUQEkByA1AtAWUxwAYjoR4EBnSqaAdiIBYiA2OgJhPiQFcBTWgzaMArCy4gAdnwAutAMx14AYx4AnVHgCCAFTQBhciCR9ltABzwAZlFCTUAMTV8+AGwCGk5Xw3x3qIgAPaBDQsPCIyKiwkABfFVspVH1XBD5JOXjEOUgAbVAUSBB9AHtJSh5XGQRJAHMjAEdaeA0i6BIOzq7OuIBdWKA"`; +exports[`Invoice Schema V1 Encoding > Snapshot Tests - Backward Compatibility Protection > should encode minimal invoice to stable compressed format > minimal-invoice-v1-encoded 1`] = `"HSvExNNUXEFuuATe8bgOc4u2mw1p67UDSJzTbVviHOikOvGgZr3HbhpZu5JhbgCqiR3VjTKHWkm9La1APvxHchOHipvPaynGqxJPtzEMSZm1iRTDgDopqOks91c4xXdHaFvPa4hLG"`; exports[`Invoice Schema V1 Encoding > Snapshot Tests - Backward Compatibility Protection > should preserve exact JSON structure in encoding > full-invoice-v1-structure 1`] = ` { - "c": { - "a": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", - "ads": "456 Business Ave + "client": { + "email": "accounts@client.com", + "name": "Client Corporation", + "phone": "+1-555-987-6543", + "physicalAddress": "456 Business Ave New York, NY 10001", - "e": "accounts@client.com", - "n": "Client Corporation", - "ph": "+1-555-987-6543", + "walletAddress": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", }, - "cur": "USDC", - "dec": 6, - "dsc": "5%", - "due": 1706745600, - "f": { - "a": "0x1234567890123456789012345678901234567890", - "ads": "123 Tech Street + "currency": "USDC", + "decimals": 6, + "discount": "5%", + "dueAt": 1706745600, + "from": { + "email": "billing@acme.dev", + "name": "Acme Development LLC", + "phone": "+1-555-123-4567", + "physicalAddress": "123 Tech Street San Francisco, CA 94105", - "e": "billing@acme.dev", - "n": "Acme Development LLC", - "ph": "+1-555-123-4567", + "walletAddress": "0x1234567890123456789012345678901234567890", }, - "id": "INV-2024-001", - "iss": 1704067200, - "it": [ + "invoiceId": "INV-2024-001", + "issuedAt": 1704067200, + "items": [ { - "d": "Frontend Development", - "q": 40, - "r": "150000000", + "description": "Frontend Development", + "quantity": 40, + "rate": "150000000", }, { - "d": "Backend API Development", - "q": 60, - "r": "175000000", + "description": "Backend API Development", + "quantity": 60, + "rate": "175000000", }, { - "d": "Code Review & QA", - "q": 10, - "r": "125000000", + "description": "Code Review & QA", + "quantity": 10, + "rate": "125000000", }, ], - "net": 1, - "nt": "Payment for web development services", - "t": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "networkId": 1, + "notes": "Payment for web development services", "tax": "8.5%", - "v": 1, + "tokenAddress": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "version": 2, } `; diff --git a/src/features/invoice-codec/__tests__/schema.test.ts b/src/features/invoice-codec/__tests__/schema.test.ts index efe0a43f..f7e14597 100644 --- a/src/features/invoice-codec/__tests__/schema.test.ts +++ b/src/features/invoice-codec/__tests__/schema.test.ts @@ -15,59 +15,59 @@ import { describe, it, expect } from 'vitest' import { encodeInvoice, generateInvoiceUrl } from '../lib/encode' import { decodeInvoice } from '../lib/decode' -import type { InvoiceSchemaV1 } from '@/entities/invoice' +import type { Invoice } from '@/entities/invoice' // Canonical test invoice - represents a real-world invoice with all fields populated -const createTestInvoiceV1 = (): InvoiceSchemaV1 => ({ - v: 1, - id: 'INV-2024-001', - iss: 1704067200, // 2024-01-01T00:00:00Z - due: 1706745600, // 2024-02-01T00:00:00Z - nt: 'Payment for web development services', - net: 1, // Ethereum mainnet - cur: 'USDC', - t: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC on Ethereum - dec: 6, - f: { - n: 'Acme Development LLC', - a: '0x1234567890123456789012345678901234567890', - e: 'billing@acme.dev', - ads: '123 Tech Street\nSan Francisco, CA 94105', - ph: '+1-555-123-4567', +const createTestInvoiceV1 = (): Invoice => ({ + version: 2, + invoiceId: 'INV-2024-001', + issuedAt: 1704067200, // 2024-01-01T00:00:00Z + dueAt: 1706745600, // 2024-02-01T00:00:00Z + notes: 'Payment for web development services', + networkId: 1, // Ethereum mainnet + currency: 'USDC', + tokenAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC on Ethereum + decimals: 6, + from: { + name: 'Acme Development LLC', + walletAddress: '0x1234567890123456789012345678901234567890', + email: 'billing@acme.dev', + physicalAddress: '123 Tech Street\nSan Francisco, CA 94105', + phone: '+1-555-123-4567', }, - c: { - n: 'Client Corporation', - a: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', - e: 'accounts@client.com', - ads: '456 Business Ave\nNew York, NY 10001', - ph: '+1-555-987-6543', + client: { + name: 'Client Corporation', + walletAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + email: 'accounts@client.com', + physicalAddress: '456 Business Ave\nNew York, NY 10001', + phone: '+1-555-987-6543', }, - it: [ - { d: 'Frontend Development', q: 40, r: '150000000' }, // $150/hr * 40 hrs - { d: 'Backend API Development', q: 60, r: '175000000' }, // $175/hr * 60 hrs - { d: 'Code Review & QA', q: 10, r: '125000000' }, // $125/hr * 10 hrs + items: [ + { description: 'Frontend Development', quantity: 40, rate: '150000000' }, // $150/hr * 40 hrs + { description: 'Backend API Development', quantity: 60, rate: '175000000' }, // $175/hr * 60 hrs + { description: 'Code Review & QA', quantity: 10, rate: '125000000' }, // $125/hr * 10 hrs ], tax: '8.5%', - dsc: '5%', + discount: '5%', }) // Minimal invoice - only required fields -const createMinimalInvoiceV1 = (): InvoiceSchemaV1 => ({ - v: 1, - id: 'INV-MIN-001', - iss: 1704067200, - due: 1706745600, - net: 137, // Polygon - cur: 'MATIC', - dec: 18, // Native token - f: { - n: 'Freelancer', - a: '0x1111111111111111111111111111111111111111', +const createMinimalInvoiceV1 = (): Invoice => ({ + version: 2, + invoiceId: 'INV-MIN-001', + issuedAt: 1704067200, + dueAt: 1706745600, + networkId: 137, // Polygon + currency: 'MATIC', + decimals: 18, // Native token + from: { + name: 'Freelancer', + walletAddress: '0x1111111111111111111111111111111111111111', }, - c: { - n: 'Client', + client: { + name: 'Client', }, - it: [{ d: 'Consulting', q: 1, r: '1000000000000000000' }], // 1 MATIC + items: [{ description: 'Consulting', quantity: 1, rate: '1000000000000000000' }], // 1 MATIC }) describe('Invoice Schema V1 Encoding', () => { @@ -99,12 +99,24 @@ describe('Invoice Schema V1 Encoding', () => { }) describe('Round-trip Tests - Encode/Decode Consistency', () => { + // Helper to normalize addresses for comparison (binary codec returns lowercase) + const normalizeAddresses = (inv: Invoice): Invoice => ({ + ...inv, + tokenAddress: inv.tokenAddress?.toLowerCase(), + from: { ...inv.from, walletAddress: inv.from.walletAddress.toLowerCase() }, + client: { + ...inv.client, + walletAddress: inv.client.walletAddress?.toLowerCase(), + }, + }) + it('should perfectly round-trip full invoice', () => { const original = createTestInvoiceV1() const encoded = encodeInvoice(original) const decoded = decodeInvoice(encoded) - expect(decoded).toEqual(original) + // Addresses are normalized to lowercase by binary codec + expect(normalizeAddresses(decoded)).toEqual(normalizeAddresses(original)) }) it('should perfectly round-trip minimal invoice', () => { @@ -112,7 +124,7 @@ describe('Invoice Schema V1 Encoding', () => { const encoded = encodeInvoice(original) const decoded = decodeInvoice(encoded) - expect(decoded).toEqual(original) + expect(normalizeAddresses(decoded)).toEqual(normalizeAddresses(original)) }) it('should preserve all optional fields when present', () => { @@ -120,65 +132,68 @@ describe('Invoice Schema V1 Encoding', () => { const encoded = encodeInvoice(invoice) const decoded = decodeInvoice(encoded) - // Verify optional fields are preserved - expect(decoded.nt).toBe(invoice.nt) - expect(decoded.t).toBe(invoice.t) + // Verify optional fields are preserved (addresses normalized to lowercase) + expect(decoded.notes).toBe(invoice.notes) + expect(decoded.tokenAddress?.toLowerCase()).toBe(invoice.tokenAddress?.toLowerCase()) expect(decoded.tax).toBe(invoice.tax) - expect(decoded.dsc).toBe(invoice.dsc) - expect(decoded.f.e).toBe(invoice.f.e) - expect(decoded.f.ads).toBe(invoice.f.ads) - expect(decoded.f.ph).toBe(invoice.f.ph) - expect(decoded.c.a).toBe(invoice.c.a) - expect(decoded.c.e).toBe(invoice.c.e) - expect(decoded.c.ads).toBe(invoice.c.ads) - expect(decoded.c.ph).toBe(invoice.c.ph) + expect(decoded.discount).toBe(invoice.discount) + expect(decoded.from.email).toBe(invoice.from.email) + expect(decoded.from.physicalAddress).toBe(invoice.from.physicalAddress) + expect(decoded.from.phone).toBe(invoice.from.phone) + expect(decoded.client.walletAddress?.toLowerCase()).toBe( + invoice.client.walletAddress?.toLowerCase() + ) + expect(decoded.client.email).toBe(invoice.client.email) + expect(decoded.client.physicalAddress).toBe(invoice.client.physicalAddress) + expect(decoded.client.phone).toBe(invoice.client.phone) }) it('should handle unicode characters in notes and names', () => { - const invoice: InvoiceSchemaV1 = { + const invoice: Invoice = { ...createMinimalInvoiceV1(), - nt: 'Payment for services - Paiement pour services 支付服务费 🚀', - f: { - n: 'Développeur Фрилансер 开发者', - a: '0x1111111111111111111111111111111111111111', + notes: 'Payment for services - Paiement pour services 支付服务费 🚀', + from: { + name: 'Développeur Фрилансер 开发者', + walletAddress: '0x1111111111111111111111111111111111111111', }, - c: { - n: 'Client 顧客 Клиент', + client: { + name: 'Client 顧客 Клиент', }, } const encoded = encodeInvoice(invoice) const decoded = decodeInvoice(encoded) - expect(decoded.nt).toBe(invoice.nt) - expect(decoded.f.n).toBe(invoice.f.n) - expect(decoded.c.n).toBe(invoice.c.n) + expect(decoded.notes).toBe(invoice.notes) + expect(decoded.from.name).toBe(invoice.from.name) + expect(decoded.client.name).toBe(invoice.client.name) }) it('should handle line items with various quantity formats', () => { - const invoice: InvoiceSchemaV1 = { + const invoice: Invoice = { ...createMinimalInvoiceV1(), - it: [ - { d: 'Integer quantity', q: 100, r: '1000000' }, - { d: 'String quantity', q: '50.5', r: '2000000' }, - { d: 'Large rate', q: 1, r: '999999999999999999' }, // Near BigInt max + items: [ + { description: 'Integer quantity', quantity: 100, rate: '1000000' }, + { description: 'Float quantity', quantity: 50.5, rate: '2000000' }, + { description: 'Large rate', quantity: 1, rate: '999999999999999999' }, // Near BigInt max ], } const encoded = encodeInvoice(invoice) const decoded = decodeInvoice(encoded) - expect(decoded.it).toEqual(invoice.it) + // Decoder normalizes numeric string quantities to numbers + expect(decoded.items).toEqual(invoice.items) }) }) describe('Version Detection', () => { - it('should correctly identify schema version 1', () => { + it('should correctly identify schema version 2', () => { const invoice = createTestInvoiceV1() const encoded = encodeInvoice(invoice) const decoded = decodeInvoice(encoded) - expect(decoded.v).toBe(1) + expect(decoded.version).toBe(2) }) }) @@ -187,79 +202,82 @@ describe('Invoice Schema V1 Encoding', () => { expect(() => decodeInvoice('invalid-data-not-compressed')).toThrow() }) - it('should throw on missing version field', () => { - // This tests the decodeInvoice error path for missing version - const invalidInvoice = { id: 'test', net: 1 } - const encoded = encodeInvoice(invalidInvoice as unknown as InvoiceSchemaV1) - - expect(() => decodeInvoice(encoded)).toThrow('Missing or invalid version field') + it('should throw on missing H prefix', () => { + // Binary V3 requires 'H' prefix + expect(() => decodeInvoice('ABCD1234')).toThrow(/expected Binary V3/) }) - it('should throw on unsupported schema version', () => { - const futureInvoice = { v: 999, id: 'test', net: 1 } - const encoded = encodeInvoice(futureInvoice as unknown as InvoiceSchemaV1) - - expect(() => decodeInvoice(encoded)).toThrow('Unsupported schema version: 999') + it('should throw on corrupted base62 data', () => { + // Valid H prefix but corrupted data + expect(() => decodeInvoice('H!!!invalid!!!')).toThrow() }) - it('should throw on invalid version type', () => { - const invalidInvoice = { v: 'not-a-number', id: 'test', net: 1 } - const encoded = encodeInvoice(invalidInvoice as unknown as InvoiceSchemaV1) - - expect(() => decodeInvoice(encoded)).toThrow('Missing or invalid version field') - }) - - it('should throw on invalid invoice data structure', () => { - // Create a v1 invoice missing required fields - const incompleteInvoice = { v: 1, id: 'test' } - const encoded = encodeInvoice(incompleteInvoice as unknown as InvoiceSchemaV1) - - expect(() => decodeInvoice(encoded)).toThrow(/Invalid invoice data/) + it('should throw on truncated binary data', () => { + // Valid H prefix but too short + expect(() => decodeInvoice('H1')).toThrow() }) }) describe('URL Generation', () => { - it('should generate valid URL with default base', () => { + it('should generate valid URL with hash fragment', () => { const invoice = createMinimalInvoiceV1() const url = generateInvoiceUrl(invoice) - expect(url).toContain('/pay?d=') + expect(url).toContain('/pay#H') // Binary V3 prefix expect(url).toMatch(/^https?:\/\//) }) it('should generate URL with custom base URL', () => { const invoice = createMinimalInvoiceV1() const customBase = 'https://custom.voidpay.xyz' - const url = generateInvoiceUrl(invoice, customBase) + const url = generateInvoiceUrl(invoice, { baseUrl: customBase }) expect(url.startsWith(customBase)).toBe(true) - expect(url).toContain('/pay?d=') + expect(url).toContain('/pay#H') }) it('should generate URL that can be decoded back', () => { const invoice = createTestInvoiceV1() - const url = generateInvoiceUrl(invoice, 'https://voidpay.xyz') + const url = generateInvoiceUrl(invoice, { baseUrl: 'https://voidpay.xyz' }) - // Extract the compressed data from the URL - const urlObj = new URL(url) - const compressed = urlObj.searchParams.get('d') + // Extract the compressed data from hash fragment + const hashIndex = url.indexOf('#') + const compressed = url.slice(hashIndex + 1) expect(compressed).toBeTruthy() - const decoded = decodeInvoice(compressed!) - expect(decoded).toEqual(invoice) + expect(compressed.startsWith('H')).toBe(true) + const decoded = decodeInvoice(compressed) + + // Normalize addresses for comparison (binary codec returns lowercase) + const normalizeAddresses = (inv: Invoice): Invoice => ({ + ...inv, + tokenAddress: inv.tokenAddress?.toLowerCase(), + from: { ...inv.from, walletAddress: inv.from.walletAddress.toLowerCase() }, + client: { + ...inv.client, + walletAddress: inv.client.walletAddress?.toLowerCase(), + }, + }) + expect(normalizeAddresses(decoded)).toEqual(normalizeAddresses(invoice)) }) it('should throw when URL exceeds 2000 bytes', () => { - // Create an invoice with very long notes to exceed URL limit - const largeInvoice: InvoiceSchemaV1 = { + // Binary V3 with Deflate is very efficient, need diverse random data + // to prevent good compression ratios + const randomString = (len: number) => + Array.from({ length: len }, () => + String.fromCharCode(65 + Math.floor(Math.random() * 26)) + ).join('') + + const largeInvoice: Invoice = { ...createTestInvoiceV1(), - nt: 'A'.repeat(2000), // Very long notes - it: Array(50) + notes: randomString(280), // Max notes length + items: Array(100) .fill(null) .map((_, i) => ({ - d: `Line item ${i} with a very long description that takes up a lot of space in the URL`, - q: i + 1, - r: '999999999999999999', + description: randomString(50) + i, // Random + unique to prevent compression + quantity: Math.random() * 1000, + rate: String(Math.floor(Math.random() * 1e18)), })), } @@ -268,10 +286,10 @@ describe('Invoice Schema V1 Encoding', () => { it('should calculate correct byte size for unicode characters', () => { // Unicode characters take more bytes than ASCII - const invoice: InvoiceSchemaV1 = { + const invoice: Invoice = { ...createMinimalInvoiceV1(), - nt: '日本語テキスト', - f: { ...createMinimalInvoiceV1().f, n: '山田太郎' }, + notes: '日本語テキスト', + from: { ...createMinimalInvoiceV1().from, name: '山田太郎' }, } // Should not throw for reasonable unicode content diff --git a/src/features/invoice-codec/index.ts b/src/features/invoice-codec/index.ts index 590bc7e1..3fe2ded3 100644 --- a/src/features/invoice-codec/index.ts +++ b/src/features/invoice-codec/index.ts @@ -1,6 +1,7 @@ // Public API for invoice codec feature export * from './lib/encode' export * from './lib/decode' +export * from './lib/og-preview' // Re-export schema type for convenience (via public API) -export type { InvoiceSchemaV1 } from '@/entities/invoice' +export type { Invoice } from '@/entities/invoice' diff --git a/src/features/invoice-codec/lib/decode.ts b/src/features/invoice-codec/lib/decode.ts index ca8eb72b..6b6cd6ff 100644 --- a/src/features/invoice-codec/lib/decode.ts +++ b/src/features/invoice-codec/lib/decode.ts @@ -1,54 +1,43 @@ -import type { InvoiceSchemaV1 } from '@/entities/invoice' +import type { Invoice } from '@/entities/invoice' import { invoiceSchema } from '@/entities/invoice' -import { decompress } from '@/shared/lib/compression' +import { decodeBinaryV3 } from '@/shared/lib/binary-codec' /** - * Decodes a compressed string into an invoice object. + * Decodes a Binary V3 compressed string into an invoice object. * Supports version-specific parsing for backward compatibility. * - * @param compressed The compressed string from the URL + * @param compressed The compressed string from the URL hash fragment * @returns The decoded invoice object - * @throws Error if decompression, parsing, or unsupported version + * @throws Error if decoding fails or version is unsupported */ -export const decodeInvoice = (compressed: string): InvoiceSchemaV1 => { - const json = decompress(compressed) - if (!json) { - throw new Error('Failed to decompress invoice data') +export const decodeInvoice = (compressed: string): Invoice => { + // Binary V3 format starts with 'H' prefix + if (!compressed.startsWith('H')) { + throw new Error('Invalid invoice format: expected Binary V3 (H prefix)') } try { - const data = JSON.parse(json) + const invoice = decodeBinaryV3(compressed) - // Version detection - const version = data.v - if (typeof version !== 'number') { - throw new Error('Missing or invalid version field') - } - - // Version-specific parsing (immutable parsers per Constitution Principle IV) - switch (version) { - case 1: - return parseV1(data) - default: - throw new Error(`Unsupported schema version: ${version}`) - } + // Validate against schema + return validateInvoice(invoice) } catch (error) { if (error instanceof Error) { throw error } - throw new Error('Failed to parse invoice JSON') + throw new Error('Failed to decode invoice data') } } /** - * Immutable parser for InvoiceSchemaV1. - * This function MUST NOT be modified once deployed (Constitution Principle IV). + * Validates decoded invoice against schema. + * Ensures data integrity after binary decoding. * - * @param data Raw parsed JSON data - * @returns Validated InvoiceSchemaV1 object + * @param data Decoded invoice data + * @returns Validated Invoice object * @throws Error if validation fails */ -function parseV1(data: unknown): InvoiceSchemaV1 { +function validateInvoice(data: unknown): Invoice { const result = invoiceSchema.safeParse(data) if (!result.success) { diff --git a/src/features/invoice-codec/lib/encode.ts b/src/features/invoice-codec/lib/encode.ts index 53e23a66..a1dfa5c3 100644 --- a/src/features/invoice-codec/lib/encode.ts +++ b/src/features/invoice-codec/lib/encode.ts @@ -1,36 +1,71 @@ -import type { InvoiceSchemaV1 } from '@/entities/invoice' +import type { Invoice } from '@/entities/invoice' import { getAppBaseUrl } from '@/shared/config' -import { compress } from '@/shared/lib/compression' +import { encodeBinaryV3 } from '@/shared/lib/binary-codec' +import { encodeOGPreview } from './og-preview' /** - * Encodes an invoice into a compressed string. + * URL generation options. + */ +export interface GenerateUrlOptions { + /** Base URL override (default: from NEXT_PUBLIC_APP_URL env) */ + baseUrl?: string + /** Include OG preview data for social sharing (default: false) */ + includeOG?: boolean +} + +/** + * Encodes an invoice into a Binary V3 compressed string. + * Uses hybrid compression: binary packing + selective Deflate for text. * * @param invoice The invoice data to encode - * @returns The compressed string + * @returns The Base62-encoded binary string (prefixed with 'H') */ -export const encodeInvoice = (invoice: InvoiceSchemaV1): string => { - const json = JSON.stringify(invoice) - return compress(json) +export const encodeInvoice = (invoice: Invoice): string => { + return encodeBinaryV3(invoice) } /** - * Generates a shareable URL for the invoice. + * Generates a shareable URL for the invoice using hash fragment. + * Hash fragments are never sent to the server (Privacy-First principle). * Validates that the final URL does not exceed 2000 bytes. * * @param invoice The invoice data to encode - * @param baseUrl The base URL of the application (default: from NEXT_PUBLIC_APP_URL env or https://voidpay.com) - * @returns The full URL with the compressed invoice data + * @param options URL generation options + * @returns The full URL with the compressed invoice data in hash fragment * @throws Error if the URL exceeds 2000 bytes + * + * @example + * ```ts + * // Simple URL (privacy-first) + * generateInvoiceUrl(invoice) + * // => "https://voidpay.xyz/pay#H4sI..." + * + * // With OG preview for social sharing + * generateInvoiceUrl(invoice, { includeOG: true }) + * // => "https://voidpay.xyz/pay?og=a1b2c3d4_1250.00_USDC_arb_Acme#H4sI..." + * ``` */ -export const generateInvoiceUrl = (invoice: InvoiceSchemaV1, baseUrl?: string): string => { +export const generateInvoiceUrl = ( + invoice: Invoice, + options: GenerateUrlOptions | string = {} +): string => { + // Support legacy signature: generateInvoiceUrl(invoice, baseUrl) + const opts: GenerateUrlOptions = typeof options === 'string' ? { baseUrl: options } : options + const compressed = encodeInvoice(invoice) + const appUrl = opts.baseUrl || getAppBaseUrl() - // Use provided baseUrl, or fallback to centralized config - const appUrl = baseUrl || getAppBaseUrl() - const url = new URL(`${appUrl}/pay`) - url.searchParams.set('d', compressed) + let finalUrl: string + + if (opts.includeOG) { + // Hybrid format: ?og=preview#compressed + const ogData = encodeOGPreview(invoice) + finalUrl = `${appUrl}/pay?og=${ogData}#${compressed}` + } else { + // Pure hash fragment (maximum privacy) + finalUrl = `${appUrl}/pay#${compressed}` + } - const finalUrl = url.toString() const byteSize = new TextEncoder().encode(finalUrl).length if (byteSize > 2000) { diff --git a/src/features/invoice-codec/lib/og-preview.ts b/src/features/invoice-codec/lib/og-preview.ts new file mode 100644 index 00000000..a40f5833 --- /dev/null +++ b/src/features/invoice-codec/lib/og-preview.ts @@ -0,0 +1,158 @@ +import type { Invoice } from '@/entities/invoice' +import { NETWORK_CODES, NETWORK_CODES_REVERSE, type NetworkId } from '@/entities/network' + +/** + * OG Preview data structure for social sharing. + * Contains minimal, non-sensitive invoice metadata. + */ +export interface OGPreviewData { + /** Shortened invoice ID (first 8 chars of UUID) */ + id: string + /** Total amount (formatted with 2 decimal places) */ + amount: string + /** Currency symbol */ + currency: string + /** Network short code (eth, arb, op, poly) */ + network: string + /** Sender name (optional, max 20 chars) */ + from?: string + /** Due date in MMDD format (optional) */ + due?: string +} + +/** + * Encodes minimal invoice metadata for OG preview. + * Format: id_amount_currency_network[_from][_due] + * + * @param invoice The full invoice data + * @returns URL-safe string for og query parameter + * + * @example + * ```ts + * encodeOGPreview(invoice) + * // => "a1b2c3d4_1250.00_USDC_arb_Acme_1231" + * ``` + */ +export function encodeOGPreview(invoice: Invoice): string { + const parts: string[] = [] + + // 1. Shortened invoice ID (first 8 chars, remove dashes) + const shortId = invoice.invoiceId.replace(/-/g, '').slice(0, 8) + parts.push(shortId) + + // 2. Calculate total amount from line items + const total = calculateTotal(invoice) + parts.push(total) + + // 3. Currency symbol + parts.push(invoice.currency) + + // 4. Network short code + const networkCode = NETWORK_CODES[invoice.networkId as NetworkId] ?? String(invoice.networkId) + parts.push(networkCode) + + // 5. Sender name (optional, truncate to 20 chars, URL-safe) + if (invoice.from.name) { + const safeName = invoice.from.name + .slice(0, 20) + .replace(/[_#?&=%]/g, '') // Remove URL-unsafe chars and delimiter + .trim() + if (safeName) { + parts.push(safeName) + } + } + + // 6. Due date in MMDD format (optional) + if (invoice.dueAt) { + const date = new Date(invoice.dueAt * 1000) + const mm = String(date.getMonth() + 1).padStart(2, '0') + const dd = String(date.getDate()).padStart(2, '0') + parts.push(`${mm}${dd}`) + } + + return parts.join('_') +} + +/** + * Decodes OG preview string back to preview data. + * + * @param ogString The og query parameter value + * @returns Parsed preview data + */ +export function decodeOGPreview(ogString: string): OGPreviewData { + const parts = ogString.split('_') + + if (parts.length < 4) { + throw new Error('Invalid OG preview format: minimum 4 parts required') + } + + const result: OGPreviewData = { + id: parts[0] ?? '', + amount: parts[1] ?? '0', + currency: parts[2] ?? '', + network: parts[3] ?? '', + } + + // Optional: sender name (5th part) + if (parts.length >= 5 && parts[4] && !/^\d{4}$/.test(parts[4])) { + result.from = parts[4] + } + + // Optional: due date (5th or 6th part, always 4 digits MMDD) + const lastPart = parts[parts.length - 1] + if (lastPart && /^\d{4}$/.test(lastPart)) { + result.due = lastPart + } + + return result +} + +/** + * Gets network chain ID from short code. + */ +export function getNetworkIdFromCode(code: string): number | undefined { + return NETWORK_CODES_REVERSE[code.toLowerCase()] +} + +/** + * Calculates total invoice amount from line items. + * Returns formatted string with 2 decimal places. + */ +function calculateTotal(invoice: Invoice): string { + let total = 0 + + for (const item of invoice.items) { + const qty = typeof item.quantity === 'number' ? item.quantity : parseFloat(item.quantity) + const rate = parseFloat(item.rate) + + if (!isNaN(qty) && !isNaN(rate)) { + total += qty * rate + } + } + + // Apply tax if present + if (invoice.tax) { + const taxValue = parseTaxOrDiscount(invoice.tax, total) + total += taxValue + } + + // Apply discount if present + if (invoice.discount) { + const discountValue = parseTaxOrDiscount(invoice.discount, total) + total -= discountValue + } + + return total.toFixed(2) +} + +/** + * Parses tax/discount string (percentage or fixed amount). + */ +function parseTaxOrDiscount(value: string, base: number): number { + if (value.endsWith('%')) { + const percent = parseFloat(value.slice(0, -1)) + return isNaN(percent) ? 0 : (base * percent) / 100 + } + const fixed = parseFloat(value) + return isNaN(fixed) ? 0 : fixed +} diff --git a/src/features/invoice-draft/lib/auto-save.ts b/src/features/invoice-draft/lib/auto-save.ts index fd2244f0..34e15a4d 100644 --- a/src/features/invoice-draft/lib/auto-save.ts +++ b/src/features/invoice-draft/lib/auto-save.ts @@ -8,7 +8,7 @@ import { useCallback } from 'react' import { useDebouncedCallback } from 'use-debounce' import { useCreatorStore } from '@/entities/creator' -import type { InvoiceDraft } from '@/entities/invoice' +import type { Invoice } from '@/entities/invoice' import { AUTO_SAVE_DEBOUNCE_MS } from '@/shared/lib/debounce' /** @@ -20,11 +20,11 @@ import { AUTO_SAVE_DEBOUNCE_MS } from '@/shared/lib/debounce' * function InvoiceEditor() { * const { autoSave, isPending } = useAutoSave(); * - * const handleFieldChange = (field: string, value: any) => { + * const handleFieldChange = (field: keyof Invoice, value: unknown) => { * // Update local state immediately (optimistic UI) * setLocalDraft({ ...localDraft, [field]: value }); * // Debounced save to store - * autoSave({ ...localDraft, [field]: value }); + * autoSave({ [field]: value }); * }; * * return ( @@ -39,8 +39,8 @@ export function useAutoSave() { const updateDraft = useCreatorStore((s) => s.updateDraft) const debouncedSave = useDebouncedCallback( - (draft: Partial) => { - updateDraft(draft) + (data: Partial) => { + updateDraft(data) }, AUTO_SAVE_DEBOUNCE_MS, { @@ -50,8 +50,8 @@ export function useAutoSave() { ) const autoSave = useCallback( - (draft: Partial) => { - debouncedSave(draft) + (data: Partial) => { + debouncedSave(data) }, [debouncedSave] ) @@ -88,7 +88,7 @@ export function useAutoSave() { * function InvoiceEditor() { * const { autoSave, saveNow } = useAutoSaveWithManual(); * - * const handleFieldChange = (field: string, value: any) => { + * const handleFieldChange = (field: keyof Invoice, value: unknown) => { * autoSave({ [field]: value }); * }; * @@ -103,13 +103,13 @@ export function useAutoSaveWithManual() { const updateDraft = useCreatorStore((s) => s.updateDraft) const saveNow = useCallback( - (draft?: Partial) => { + (data?: Partial) => { // Flush any pending debounced saves flush() - // If draft provided, save it immediately - if (draft) { - updateDraft(draft) + // If data provided, save it immediately + if (data) { + updateDraft(data) } }, [flush, updateDraft] diff --git a/src/features/invoice-draft/ui/TemplateList.tsx b/src/features/invoice-draft/ui/TemplateList.tsx index 20e2d5f5..c8ac68cf 100644 --- a/src/features/invoice-draft/ui/TemplateList.tsx +++ b/src/features/invoice-draft/ui/TemplateList.tsx @@ -100,6 +100,11 @@ function TemplateCard({ day: 'numeric', }) + // Extract display values from the new Invoice schema + const clientName = invoiceData.client?.name ?? 'N/A' + const currency = invoiceData.currency ?? 'USDC' + const itemsCount = invoiceData.items?.length ?? 0 + return (
@@ -109,13 +114,13 @@ function TemplateCard({

- Recipient: {invoiceData.recipient.name || 'N/A'} + Client: {clientName}

- Currency: {invoiceData.currencySymbol} + Currency: {currency}

- Items: {invoiceData.lineItems.length} + Items: {itemsCount}

diff --git a/src/features/invoice-history/lib/search.ts b/src/features/invoice-history/lib/search.ts index 6bfcab8a..d3a90384 100644 --- a/src/features/invoice-history/lib/search.ts +++ b/src/features/invoice-history/lib/search.ts @@ -4,12 +4,12 @@ * Client-side search functionality for history entries. */ -import type { CreationHistoryEntry } from '@/entities/invoice' +import { formatInvoiceTotal, type CreationHistoryEntry } from '@/entities/invoice' /** * Search history entries by query string * - * Searches across: invoiceId, recipientName, totalAmount + * Searches across: invoiceId, client name, total amount * * @param history - Array of history entries * @param query - Search query (case-insensitive) @@ -26,7 +26,11 @@ export function searchHistory( const normalizedQuery = query.toLowerCase().trim() return history.filter((entry) => { - const searchableText = [entry.invoiceId, entry.recipientName, entry.totalAmount] + const searchableText = [ + entry.invoice.invoiceId, + entry.invoice.client?.name ?? '', + formatInvoiceTotal(entry.invoice), + ] .join(' ') .toLowerCase() @@ -103,14 +107,15 @@ export function sortHistory( case 'date': comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() break - case 'amount': - // Extract numeric value from totalAmount string (e.g., "1250.50 USDC" -> 1250.50) - const amountA = parseFloat(a.totalAmount.split(' ')[0] || '0') - const amountB = parseFloat(b.totalAmount.split(' ')[0] || '0') + case 'amount': { + // Extract numeric value from formatted total + const amountA = parseFloat(formatInvoiceTotal(a.invoice).split(' ')[0] || '0') + const amountB = parseFloat(formatInvoiceTotal(b.invoice).split(' ')[0] || '0') comparison = amountA - amountB break + } case 'invoiceId': - comparison = a.invoiceId.localeCompare(b.invoiceId) + comparison = a.invoice.invoiceId.localeCompare(b.invoice.invoiceId) break } diff --git a/src/features/invoice-history/ui/HistoryList.tsx b/src/features/invoice-history/ui/HistoryList.tsx index ac643ac1..db10c33d 100644 --- a/src/features/invoice-history/ui/HistoryList.tsx +++ b/src/features/invoice-history/ui/HistoryList.tsx @@ -9,7 +9,7 @@ import { useState } from 'react' import { useCreatorStore } from '@/entities/creator' -import type { CreationHistoryEntry } from '@/entities/invoice' +import { formatInvoiceTotal, type CreationHistoryEntry } from '@/entities/invoice' interface HistoryListProps { /** Optional CSS class name */ @@ -125,18 +125,20 @@ function HistoryEntryCard({ {/* Left: Invoice Info */}
-

{entry.invoiceId}

+

+ {entry.invoice.invoiceId} +

{entry.txHash && ( Paid )}
-

{entry.recipientName}

+

{entry.invoice.client?.name ?? 'Unknown'}

{formattedDate} - {entry.totalAmount} + {formatInvoiceTotal(entry.invoice)}
diff --git a/src/shared/lib/binary-codec/base62.ts b/src/shared/lib/binary-codec/base62.ts new file mode 100644 index 00000000..414ad946 --- /dev/null +++ b/src/shared/lib/binary-codec/base62.ts @@ -0,0 +1,75 @@ +/** + * Base62 Encoding/Decoding + * Alphabet: 0-9a-zA-Z (62 chars, URL-safe without special chars) + * More compact than Base64 for URLs + */ + +const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + +/** + * Encodes a Uint8Array to Base62 string + */ +export function encodeBase62(bytes: Uint8Array): string { + if (bytes.length === 0) return '' + + // Convert byte array to BigInt + let num = BigInt(0) + for (let i = 0; i < bytes.length; i++) { + num = num * BigInt(256) + BigInt(bytes[i]!) + } + + // Convert to base62 + if (num === BigInt(0)) return ALPHABET[0]! + + let result = '' + while (num > BigInt(0)) { + const digit = ALPHABET[Number(num % BigInt(62))] + result = (digit ?? '') + result + num = num / BigInt(62) + } + + // Preserve leading zeros + for (let i = 0; i < bytes.length && bytes[i] === 0; i++) { + result = ALPHABET[0]! + result + } + + return result +} + +/** + * Decodes a Base62 string to Uint8Array + */ +export function decodeBase62(str: string): Uint8Array { + if (str.length === 0) return new Uint8Array(0) + + // Count leading zeros + let leadingZeros = 0 + for (let i = 0; i < str.length && str[i] === ALPHABET[0]; i++) { + leadingZeros++ + } + + // Convert from base62 to BigInt + let num = BigInt(0) + for (let i = 0; i < str.length; i++) { + const char = str[i] + const digit = ALPHABET.indexOf(char!) + if (digit === -1) { + throw new Error(`Invalid Base62 character: ${char}`) + } + num = num * BigInt(62) + BigInt(digit) + } + + // Convert to bytes + const bytes: number[] = [] + while (num > BigInt(0)) { + bytes.unshift(Number(num % BigInt(256))) + num = num / BigInt(256) + } + + // Add leading zeros + for (let i = 0; i < leadingZeros; i++) { + bytes.unshift(0) + } + + return new Uint8Array(bytes) +} diff --git a/src/shared/lib/binary-codec/decoder-v3.ts b/src/shared/lib/binary-codec/decoder-v3.ts new file mode 100644 index 00000000..d5aa740e --- /dev/null +++ b/src/shared/lib/binary-codec/decoder-v3.ts @@ -0,0 +1,232 @@ +/** + * Binary Codec V3 - Hybrid Strategy Decoder (Fixed) + * + * Decodes invoices encoded with V3 hybrid compression strategy. + * Uses pako.inflate to decompress raw bytes (NO double decoding). + */ + +import { Invoice } from '@/entities/invoice/model/schema' +import { bytesToAddress, readVarInt } from './utils' +import { decodeBase62 } from './base62' +import { CURRENCY_DICT_REVERSE, TOKEN_DICT_REVERSE } from './dictionary' +import pako from 'pako' + +/** + * Bit flags for optional fields (matches encoder) + */ +enum OptionalFields { + HAS_NOTES = 1 << 0, + HAS_TOKEN = 1 << 1, + HAS_SENDER_EMAIL = 1 << 2, + HAS_SENDER_ADDRESS = 1 << 3, + HAS_SENDER_PHONE = 1 << 4, + HAS_CLIENT_WALLET = 1 << 5, + HAS_CLIENT_EMAIL = 1 << 6, + HAS_CLIENT_ADDRESS = 1 << 7, + HAS_CLIENT_PHONE = 1 << 8, + HAS_TAX = 1 << 9, + HAS_DISCOUNT = 1 << 10, + TEXT_COMPRESSED = 1 << 11, +} + +/** + * Decodes invoice from hybrid compressed format + * Prefix: 'H' (Hybrid) + */ +export function decodeBinaryV3(encoded: string): Invoice { + // 1. Check prefix + if (!encoded.startsWith('H')) { + throw new Error('Invalid V3 encoding: must start with H') + } + + // 2. Remove prefix and decode from Base62 + const base62Data = encoded.slice(1) + const bytes = decodeBase62(base62Data) + + let offset = 0 + + // 3. Version + const version = bytes[offset++] + if (version !== 3) { + throw new Error(`Unsupported version: ${version}`) + } + + // 4. Read flags (2 bytes) + const flagsHigh = bytes[offset++] ?? 0 + const flagsLow = bytes[offset++] ?? 0 + const flags = (flagsHigh << 8) | flagsLow + + // 5. Invoice ID moved to text fields (user-defined string, not UUID) + + // 6. Issue timestamp (4 bytes) + const issuedAt = + ((bytes[offset++] ?? 0) << 24) | + ((bytes[offset++] ?? 0) << 16) | + ((bytes[offset++] ?? 0) << 8) | + (bytes[offset++] ?? 0) + + // 7. Delta for due date (varint) + const deltaResult = readVarInt(bytes, offset) + const delta = deltaResult.value + offset += deltaResult.bytesRead + const dueAt = issuedAt + delta + + // 8. Network ID (varint) + const netResult = readVarInt(bytes, offset) + const networkId = netResult.value + offset += netResult.bytesRead + + // 9. Decimals (varint) + const decResult = readVarInt(bytes, offset) + const decimals = decResult.value + offset += decResult.bytesRead + + // 10. Token address (optional, with dictionary support) + let tokenAddress: string | undefined + if (flags & OptionalFields.HAS_TOKEN) { + const tokenFlag = bytes[offset++] + if (tokenFlag === 0) { + // Dictionary token + const tokenCode = bytes[offset++] + tokenAddress = TOKEN_DICT_REVERSE[tokenCode ?? 0] + } else { + // Custom token + const tokenBytes = bytes.slice(offset, offset + 20) + tokenAddress = bytesToAddress(tokenBytes) + offset += 20 + } + } + + // 11. From wallet address (20 bytes) + const fromAddressBytes = bytes.slice(offset, offset + 20) + const fromWalletAddress = bytesToAddress(fromAddressBytes) + offset += 20 + + // 12. Client wallet address (optional, 20 bytes) + let clientWalletAddress: string | undefined + if (flags & OptionalFields.HAS_CLIENT_WALLET) { + const clientAddressBytes = bytes.slice(offset, offset + 20) + clientWalletAddress = bytesToAddress(clientAddressBytes) + offset += 20 + } + + // 13. Line items count (varint) + const itemCountResult = readVarInt(bytes, offset) + const itemCount = itemCountResult.value + offset += itemCountResult.bytesRead + + // 14. Read text data length (varint) + const textLengthResult = readVarInt(bytes, offset) + const textLength = textLengthResult.value + offset += textLengthResult.bytesRead + + // 15. Read text data bytes + const textDataBytes = bytes.slice(offset, offset + textLength) + offset += textLength + + // 16. Decompress text if needed + let rawTextBytes: Uint8Array + if (flags & OptionalFields.TEXT_COMPRESSED) { + try { + // Key improvement: pako.inflate expects raw Uint8Array and returns raw Uint8Array + rawTextBytes = pako.inflate(textDataBytes) + } catch (error) { + throw new Error('Failed to decompress text data: ' + (error as Error).message) + } + } else { + rawTextBytes = textDataBytes + } + + // 17. Decode text from bytes + const textDecoder = new TextDecoder() + const textData = textDecoder.decode(rawTextBytes) + + // 18. Parse text data (split by null separator) + const textParts = textData.split('\x00') + let textIdx = 0 + + // Invoice ID (user-defined string, first in text fields) + const invoiceId = textParts[textIdx++] ?? '' + + // Currency (with dictionary support) + const currencyMarker = textParts[textIdx++] + let currency: string + if (currencyMarker === '\x01') { + // Dictionary + const currCode = textParts[textIdx++]?.charCodeAt(0) + currency = CURRENCY_DICT_REVERSE[currCode ?? 0] ?? 'UNKNOWN' + } else { + // String + currency = textParts[textIdx++] ?? 'UNKNOWN' + } + + // Notes (optional) + let notes: string | undefined + if (flags & OptionalFields.HAS_NOTES) { + notes = textParts[textIdx++] + } + + // From fields + const fromName = textParts[textIdx++] ?? '' + const fromEmail = flags & OptionalFields.HAS_SENDER_EMAIL ? textParts[textIdx++] : undefined + const fromPhysicalAddress = + flags & OptionalFields.HAS_SENDER_ADDRESS ? textParts[textIdx++] : undefined + const fromPhone = flags & OptionalFields.HAS_SENDER_PHONE ? textParts[textIdx++] : undefined + + // Client fields + const clientName = textParts[textIdx++] ?? '' + const clientEmail = flags & OptionalFields.HAS_CLIENT_EMAIL ? textParts[textIdx++] : undefined + const clientPhysicalAddress = + flags & OptionalFields.HAS_CLIENT_ADDRESS ? textParts[textIdx++] : undefined + const clientPhone = flags & OptionalFields.HAS_CLIENT_PHONE ? textParts[textIdx++] : undefined + + // Tax and Discount (optional) + const tax = flags & OptionalFields.HAS_TAX ? textParts[textIdx++] : undefined + const discount = flags & OptionalFields.HAS_DISCOUNT ? textParts[textIdx++] : undefined + + // Line items (all fields from text) + const items = [] + for (let i = 0; i < itemCount; i++) { + const description = textParts[textIdx++] ?? '' + const qtyStr = textParts[textIdx++] ?? '0' + const rate = textParts[textIdx++] ?? '0' + + items.push({ + description, + quantity: isNaN(Number(qtyStr)) ? qtyStr : Number(qtyStr), + rate, + }) + } + + // 19. Construct invoice + const invoice: Invoice = { + version: 2, + invoiceId, + issuedAt, + dueAt, + notes, + networkId, + currency, + tokenAddress, + decimals, + from: { + name: fromName, + walletAddress: fromWalletAddress, + email: fromEmail, + physicalAddress: fromPhysicalAddress, + phone: fromPhone, + }, + client: { + name: clientName, + walletAddress: clientWalletAddress, + email: clientEmail, + physicalAddress: clientPhysicalAddress, + phone: clientPhone, + }, + items, + tax, + discount, + } + + return invoice +} diff --git a/src/shared/lib/binary-codec/dictionary.ts b/src/shared/lib/binary-codec/dictionary.ts new file mode 100644 index 00000000..e74bb7a2 --- /dev/null +++ b/src/shared/lib/binary-codec/dictionary.ts @@ -0,0 +1,75 @@ +/** + * Dictionary Compression + * + * Pre-defined dictionaries for common strings to save bytes. + * Each common value is assigned a 1-byte code (0-255). + */ + +/** + * Common currency symbols + */ +export const CURRENCY_DICT: Record = { + 'USDC': 1, + 'USDT': 2, + 'DAI': 3, + 'ETH': 4, + 'WETH': 5, + 'MATIC': 6, + 'ARB': 7, + 'OP': 8, + 'AVAX': 9, + 'BNB': 10, + 'BUSD': 11, + 'FRAX': 12, + 'LUSD': 13, + 'sUSD': 14, + 'TUSD': 15, +}; + +export const CURRENCY_DICT_REVERSE: Record = Object.fromEntries( + Object.entries(CURRENCY_DICT).map(([k, v]) => [v, k]) +); + +/** + * Common token addresses (ERC-20) + */ +export const TOKEN_DICT: Record = { + // Ethereum Mainnet + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': 1, // USDC + '0xdac17f958d2ee523a2206206994597c13d831ec7': 2, // USDT + '0x6b175474e89094c44da98b954eedeac495271d0f': 3, // DAI + '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2': 4, // WETH + // Polygon + '0x2791bca1f2de4661ed88a30c99a7a9449aa84174': 5, // USDC (Polygon) + '0xc2132d05d31c914a87c6611c10748aeb04b58e8f': 6, // USDT (Polygon) + '0x8f3cf7ad23cd3cadbd9735aff958023239c6a063': 7, // DAI (Polygon) + // Arbitrum + '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8': 8, // USDC (Arbitrum) + '0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9': 9, // USDT (Arbitrum) + '0xda10009cbd5d07dd0cecc66161fc93d7c9000da1': 10, // DAI (Arbitrum) +}; + +export const TOKEN_DICT_REVERSE: Record = Object.fromEntries( + Object.entries(TOKEN_DICT).map(([k, v]) => [v, k]) +); + +/** + * Encodes a string using dictionary if available, otherwise returns null + */ +export function encodeDictString(str: string, dict: Record): number | null { + // Case-insensitive lookup + const lower = str.toLowerCase(); + for (const [key, value] of Object.entries(dict)) { + if (key.toLowerCase() === lower) { + return value; + } + } + return null; +} + +/** + * Decodes a dictionary code back to string + */ +export function decodeDictString(code: number, dict: Record): string | null { + return dict[code] || null; +} diff --git a/src/shared/lib/binary-codec/encoder-v3.ts b/src/shared/lib/binary-codec/encoder-v3.ts new file mode 100644 index 00000000..aacf50d2 --- /dev/null +++ b/src/shared/lib/binary-codec/encoder-v3.ts @@ -0,0 +1,214 @@ +/** + * Binary Codec V3 - Hybrid Strategy (Fixed) + * + * Combines V2 optimizations (bit-packing, delta encoding, dictionary) + * with selective Deflate compression for text fields only when beneficial. + * + * V2 optimizations: + * - Bit-packing for optional fields (2 bytes for all flags) + * - Dictionary compression for currencies and token addresses + * - Delta encoding for due date + * + * V3 addition: + * - Selective Deflate compression: only compress text fields if beneficial + * - Uses pako.deflate() which returns raw bytes (Uint8Array) - NO double encoding + * - Threshold: 100 bytes (deflate adds ~10 byte header) + */ + +import { Invoice } from '@/entities/invoice/model/schema' +import { addressToBytes, writeVarInt } from './utils' +import { encodeBase62 } from './base62' +import { CURRENCY_DICT, TOKEN_DICT } from './dictionary' +import pako from 'pako' + +/** + * Bit flags for optional fields (2 bytes = 16 flags) + */ +enum OptionalFields { + HAS_NOTES = 1 << 0, + HAS_TOKEN = 1 << 1, + HAS_SENDER_EMAIL = 1 << 2, + HAS_SENDER_ADDRESS = 1 << 3, + HAS_SENDER_PHONE = 1 << 4, + HAS_CLIENT_WALLET = 1 << 5, + HAS_CLIENT_EMAIL = 1 << 6, + HAS_CLIENT_ADDRESS = 1 << 7, + HAS_CLIENT_PHONE = 1 << 8, + HAS_TAX = 1 << 9, + HAS_DISCOUNT = 1 << 10, + TEXT_COMPRESSED = 1 << 11, // If set, text fields are Deflate-compressed + // Bits 12-15 reserved for future use +} + +/** + * Encodes invoice using hybrid compression strategy + * Prefix: 'H' (Hybrid) + */ +export function encodeBinaryV3(invoice: Invoice): string { + const buffer: number[] = [] + + // 1. Version (1 byte) - Version 3 + buffer.push(3) + + // 2. Compute bit flags for optional fields + let flags = 0 + if (invoice.notes) flags |= OptionalFields.HAS_NOTES + if (invoice.tokenAddress) flags |= OptionalFields.HAS_TOKEN + if (invoice.from.email) flags |= OptionalFields.HAS_SENDER_EMAIL + if (invoice.from.physicalAddress) flags |= OptionalFields.HAS_SENDER_ADDRESS + if (invoice.from.phone) flags |= OptionalFields.HAS_SENDER_PHONE + if (invoice.client.walletAddress) flags |= OptionalFields.HAS_CLIENT_WALLET + if (invoice.client.email) flags |= OptionalFields.HAS_CLIENT_EMAIL + if (invoice.client.physicalAddress) flags |= OptionalFields.HAS_CLIENT_ADDRESS + if (invoice.client.phone) flags |= OptionalFields.HAS_CLIENT_PHONE + if (invoice.tax) flags |= OptionalFields.HAS_TAX + if (invoice.discount) flags |= OptionalFields.HAS_DISCOUNT + + // We'll set TEXT_COMPRESSED bit later after collecting text + const flagsOffset = buffer.length + buffer.push(0, 0) // Placeholder for flags (2 bytes) + + // 3. Invoice ID moved to text fields (user-defined string, not UUID) + + // 4. Issue timestamp -> 4 bytes (UInt32) + const iss = invoice.issuedAt + buffer.push((iss >>> 24) & 0xff) + buffer.push((iss >>> 16) & 0xff) + buffer.push((iss >>> 8) & 0xff) + buffer.push(iss & 0xff) + + // 5. Delta encoding for due date (saves 2-3 bytes typically) + const delta = invoice.dueAt - invoice.issuedAt + writeVarInt(buffer, delta) + + // 6. Network ID -> varint + writeVarInt(buffer, invoice.networkId) + + // 7. Decimals -> varint + writeVarInt(buffer, invoice.decimals) + + // 8. Token address (optional) with dictionary support + if (invoice.tokenAddress) { + const tokenCode = TOKEN_DICT[invoice.tokenAddress.toLowerCase()] + if (tokenCode !== undefined) { + buffer.push(0) // Dictionary token + buffer.push(tokenCode) + } else { + buffer.push(1) // Custom token + const tokenBytes = addressToBytes(invoice.tokenAddress) + buffer.push(...Array.from(tokenBytes)) + } + } + + // 9. From wallet address -> 20 bytes + const fromAddressBytes = addressToBytes(invoice.from.walletAddress) + buffer.push(...Array.from(fromAddressBytes)) + + // 10. Client wallet address (optional) -> 20 bytes + if (invoice.client.walletAddress) { + const clientAddressBytes = addressToBytes(invoice.client.walletAddress) + buffer.push(...Array.from(clientAddressBytes)) + } + + // 11. Line items count -> varint + writeVarInt(buffer, invoice.items.length) + + // Now collect all text fields to decide if compression is beneficial + const textParts: string[] = [] + + // Invoice ID (user-defined string, first in text fields) + textParts.push(invoice.invoiceId) + + // Currency (with dictionary support) + const currencyCode = CURRENCY_DICT[invoice.currency] + if (currencyCode !== undefined) { + textParts.push('\x01') // Dict marker + textParts.push(String.fromCharCode(currencyCode)) + } else { + textParts.push('\x02') // String marker + textParts.push(invoice.currency) + } + + // Notes (optional) + if (invoice.notes) { + textParts.push(invoice.notes) + } + + // From fields + textParts.push(invoice.from.name) + if (invoice.from.email) textParts.push(invoice.from.email) + if (invoice.from.physicalAddress) textParts.push(invoice.from.physicalAddress) + if (invoice.from.phone) textParts.push(invoice.from.phone) + + // Client fields + textParts.push(invoice.client.name) + if (invoice.client.email) textParts.push(invoice.client.email) + if (invoice.client.physicalAddress) textParts.push(invoice.client.physicalAddress) + if (invoice.client.phone) textParts.push(invoice.client.phone) + + // Tax and Discount (optional) + if (invoice.tax) textParts.push(invoice.tax) + if (invoice.discount) textParts.push(invoice.discount) + + // Line items (all fields) + for (const item of invoice.items) { + textParts.push(item.description) + const qtyStr = typeof item.quantity === 'number' ? item.quantity.toString() : item.quantity + textParts.push(qtyStr) + textParts.push(item.rate) + } + + // Join all text with null separator + const textData = textParts.join('\x00') + + // Convert to bytes + const textEncoder = new TextEncoder() + const rawTextBytes = textEncoder.encode(textData) + + // Decide if we should compress text + // Deflate adds ~10 byte header, so only compress if > 100 bytes + let finalTextBytes: Uint8Array + let isCompressed = false + + if (rawTextBytes.length > 100) { + try { + // Key improvement: pako.deflate returns raw Uint8Array, NO string encoding + const compressedBytes = pako.deflate(rawTextBytes) + + // Only use compression if it actually reduces size + if (compressedBytes.length < rawTextBytes.length) { + finalTextBytes = compressedBytes + isCompressed = true + } else { + // Compression didn't help + finalTextBytes = rawTextBytes + } + } catch { + // Fallback to raw if compression fails + finalTextBytes = rawTextBytes + } + } else { + // Text too short for compression overhead + finalTextBytes = rawTextBytes + } + + // Update flags with compression decision + if (isCompressed) { + flags |= OptionalFields.TEXT_COMPRESSED + } + + // Write flags back to buffer + buffer[flagsOffset] = (flags >> 8) & 0xff + buffer[flagsOffset + 1] = flags & 0xff + + // Write text data length + raw bytes (NO additional encoding) + writeVarInt(buffer, finalTextBytes.length) + buffer.push(...Array.from(finalTextBytes)) + + // Convert to Uint8Array and encode to Base62 + const bytes = new Uint8Array(buffer) + const encoded = encodeBase62(bytes) + + // Add prefix + return 'H' + encoded +} diff --git a/src/shared/lib/binary-codec/index.ts b/src/shared/lib/binary-codec/index.ts new file mode 100644 index 00000000..5ae57d85 --- /dev/null +++ b/src/shared/lib/binary-codec/index.ts @@ -0,0 +1,33 @@ +/** + * Binary Codec for Invoice Compression (V3) + * + * Custom binary packing algorithm that achieves superior compression + * compared to JSON + LZ-String by: + * - Converting UUIDs from 36 chars to 16 bytes + * - Converting addresses from 42 chars to 20 bytes + * - Using varint encoding for small numbers + * - Using Base62 encoding (URL-safe, more compact than Base64) + * + * V3 features: + * - Hybrid compression strategy + * - Binary format for structured data (UUID, addresses, numbers) + * - Selective LZ compression only for text fields when beneficial (> 50 chars) + * - Best of both worlds: compact binary + smart text compression + * + * Usage: + * ```ts + * import { encodeBinaryV3, decodeBinaryV3 } from '@/shared/lib/binary-codec'; + * + * const invoice: Invoice = {...}; + * const encoded = encodeBinaryV3(invoice); + * const decoded = decodeBinaryV3(encoded); + * ``` + */ + +// V3 exports (only supported version) +export { encodeBinaryV3 } from './encoder-v3' +export { decodeBinaryV3 } from './decoder-v3' + +// Utilities +export { encodeBase62, decodeBase62 } from './base62' +export * from './dictionary' diff --git a/src/shared/lib/binary-codec/utils.ts b/src/shared/lib/binary-codec/utils.ts new file mode 100644 index 00000000..af449472 --- /dev/null +++ b/src/shared/lib/binary-codec/utils.ts @@ -0,0 +1,199 @@ +/** + * Binary Codec Utilities + * + * Utilities for converting between different data types and binary representations. + */ + +/** + * Parses UUID string (with or without dashes) to 16 bytes + * Example: "550e8400-e29b-41d4-a716-446655440000" -> Uint8Array(16) + */ +export function uuidToBytes(uuid: string): Uint8Array { + const hex = uuid.replace(/-/g, ''); + if (hex.length !== 32) { + throw new Error(`Invalid UUID length: ${uuid}`); + } + + const bytes = new Uint8Array(16); + for (let i = 0; i < 16; i++) { + bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +/** + * Converts 16 bytes back to UUID string with dashes + */ +export function bytesToUuid(bytes: Uint8Array): string { + if (bytes.length !== 16) { + throw new Error(`Invalid UUID bytes length: ${bytes.length}`); + } + + const hex = Array.from(bytes) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + +/** + * Parses Ethereum address (0x...) to 20 bytes + */ +export function addressToBytes(address: string): Uint8Array { + const hex = address.startsWith('0x') ? address.slice(2) : address; + if (hex.length !== 40) { + throw new Error(`Invalid address length: ${address}`); + } + + const bytes = new Uint8Array(20); + for (let i = 0; i < 20; i++) { + bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +/** + * Converts 20 bytes back to Ethereum address (0x...) + */ +export function bytesToAddress(bytes: Uint8Array): string { + if (bytes.length !== 20) { + throw new Error(`Invalid address bytes length: ${bytes.length}`); + } + + return '0x' + Array.from(bytes) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +} + +/** + * Writes a 32-bit unsigned integer to buffer (Big Endian) + */ +export function writeUInt32(buffer: number[], value: number): void { + buffer.push((value >>> 24) & 0xFF); + buffer.push((value >>> 16) & 0xFF); + buffer.push((value >>> 8) & 0xFF); + buffer.push(value & 0xFF); +} + +/** + * Reads a 32-bit unsigned integer from buffer (Big Endian) + */ +export function readUInt32(bytes: Uint8Array, offset: number): { value: number; bytesRead: number } { + const value = ((bytes[offset] ?? 0) << 24) | + ((bytes[offset + 1] ?? 0) << 16) | + ((bytes[offset + 2] ?? 0) << 8) | + (bytes[offset + 3] ?? 0); + return { value: value >>> 0, bytesRead: 4 }; // >>> 0 converts to unsigned +} + +/** + * Writes a varint (variable-length integer) + * Smaller numbers take fewer bytes (1-5 bytes for 32-bit values) + */ +export function writeVarInt(buffer: number[], value: number): void { + while (value > 0x7F) { + buffer.push((value & 0x7F) | 0x80); + value >>>= 7; + } + buffer.push(value & 0x7F); +} + +/** + * Reads a varint from buffer + */ +export function readVarInt(bytes: Uint8Array, offset: number): { value: number; bytesRead: number } { + let value = 0; + let shift = 0; + let bytesRead = 0; + + while (offset + bytesRead < bytes.length) { + const byte = bytes[offset + bytesRead] ?? 0; + bytesRead++; + + value |= (byte & 0x7F) << shift; + + if ((byte & 0x80) === 0) { + break; + } + + shift += 7; + } + + return { value, bytesRead }; +} + +/** + * Writes a length-prefixed string (varint length + UTF-8 bytes) + */ +export function writeString(buffer: number[], str: string): void { + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + writeVarInt(buffer, bytes.length); + buffer.push(...Array.from(bytes)); +} + +/** + * Reads a length-prefixed string + */ +export function readString(bytes: Uint8Array, offset: number): { value: string; bytesRead: number } { + const { value: length, bytesRead: lengthBytes } = readVarInt(bytes, offset); + offset += lengthBytes; + + const decoder = new TextDecoder(); + const stringBytes = bytes.slice(offset, offset + length); + const value = decoder.decode(stringBytes); + + return { value, bytesRead: lengthBytes + length }; +} + +/** + * Writes an optional string (1 byte flag + string if present) + */ +export function writeOptionalString(buffer: number[], str: string | undefined): void { + if (str === undefined) { + buffer.push(0); + } else { + buffer.push(1); + writeString(buffer, str); + } +} + +/** + * Reads an optional string + */ +export function readOptionalString(bytes: Uint8Array, offset: number): { value: string | undefined; bytesRead: number } { + const flag = bytes[offset] ?? 0; + if (flag === 0) { + return { value: undefined, bytesRead: 1 }; + } + + const { value, bytesRead } = readString(bytes, offset + 1); + return { value, bytesRead: bytesRead + 1 }; +} + +/** + * Writes an optional address (1 byte flag + 20 bytes if present) + */ +export function writeOptionalAddress(buffer: number[], address: string | undefined): void { + if (address === undefined) { + buffer.push(0); + } else { + buffer.push(1); + const bytes = addressToBytes(address); + buffer.push(...Array.from(bytes)); + } +} + +/** + * Reads an optional address + */ +export function readOptionalAddress(bytes: Uint8Array, offset: number): { value: string | undefined; bytesRead: number } { + const flag = bytes[offset] ?? 0; + if (flag === 0) { + return { value: undefined, bytesRead: 1 }; + } + + const addressBytes = bytes.slice(offset + 1, offset + 21); + const value = bytesToAddress(addressBytes); + return { value, bytesRead: 21 }; +} diff --git a/src/shared/lib/compression/index.ts b/src/shared/lib/compression/index.ts deleted file mode 100644 index 8ae4181a..00000000 --- a/src/shared/lib/compression/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as lzString from 'lz-string' - -/** - * Compresses a string using LZ-based compression optimized for URLs. - * Uses lz-string's compressToEncodedURIComponent. - */ -export const compress = (data: string): string => { - return lzString.compressToEncodedURIComponent(data) -} - -/** - * Decompresses a string compressed with compress(). - * Returns null if decompression fails or input is invalid. - */ -export const decompress = (compressed: string): string | null => { - return lzString.decompressFromEncodedURIComponent(compressed) -} diff --git a/src/shared/lib/hooks/index.ts b/src/shared/lib/hooks/index.ts index d22fe4b0..6dde8394 100644 --- a/src/shared/lib/hooks/index.ts +++ b/src/shared/lib/hooks/index.ts @@ -1 +1,2 @@ export { useHydrated } from './use-hydrated' +export { useHashFragment } from './use-hash-fragment' diff --git a/src/shared/lib/hooks/use-hash-fragment.ts b/src/shared/lib/hooks/use-hash-fragment.ts new file mode 100644 index 00000000..eafa1ea1 --- /dev/null +++ b/src/shared/lib/hooks/use-hash-fragment.ts @@ -0,0 +1,52 @@ +import { useSyncExternalStore } from 'react' + +/** + * Subscribe to hash change events + */ +function subscribeToHash(callback: () => void): () => void { + window.addEventListener('hashchange', callback) + return () => window.removeEventListener('hashchange', callback) +} + +/** + * Get current hash fragment without the leading '#' + */ +function getHashSnapshot(): string { + return window.location.hash.slice(1) +} + +/** + * Server snapshot — hash is unavailable during SSR + */ +function getServerSnapshot(): string { + return '' +} + +/** + * Hook to read URL hash fragment reactively. + * Hash fragments are never sent to the server (Privacy-First). + * + * Uses useSyncExternalStore for: + * - Optimal performance (no extra re-renders) + * - SSR compatibility (returns '' on server) + * - Automatic updates on hash change + * + * @returns Hash fragment string (without '#'), empty on server + * + * @example + * ```tsx + * const hash = useHashFragment() + * + * // Decode invoice from hash + * if (hash) { + * const invoice = decodeInvoice(hash) + * } + * ``` + */ +export function useHashFragment(): string { + return useSyncExternalStore( + subscribeToHash, + getHashSnapshot, + getServerSnapshot + ) +} diff --git a/src/shared/lib/test-utils/invoice-generator.ts b/src/shared/lib/test-utils/invoice-generator.ts new file mode 100644 index 00000000..10b137d7 --- /dev/null +++ b/src/shared/lib/test-utils/invoice-generator.ts @@ -0,0 +1,188 @@ +/** + * Random Invoice Generator + * + * Generates random invoices for testing and demonstration purposes. + */ + +import { Invoice } from '@/entities/invoice/model/schema' + +/** + * Generates a random UUID v4 + */ +function generateUUID(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0 + const v = c === 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) +} + +/** + * Generates a random Ethereum address + */ +function generateAddress(): string { + const chars = '0123456789abcdef' + let address = '0x' + for (let i = 0; i < 40; i++) { + address += chars[Math.floor(Math.random() * chars.length)] + } + return address +} + +/** + * Random item from array + */ +function randomItem(arr: T[]): T { + const item = arr[Math.floor(Math.random() * arr.length)] + if (item === undefined) { + throw new Error('Array is empty or invalid index') + } + return item +} + +/** + * Random number between min and max + */ +function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min +} + +/** + * Random boolean with given probability (default 50%) + */ +function randomBool(probability = 0.5): boolean { + return Math.random() < probability +} + +/** + * Sample data for realistic invoices + */ +const SAMPLE_DATA = { + senderNames: [ + 'Acme Corp', + 'TechStart Inc', + 'Digital Solutions LLC', + 'BlockChain Ventures', + 'Web3 Consulting', + 'Crypto Advisory Group', + ], + clientNames: [ + 'GlobalTech Industries', + 'Innovation Labs', + 'Smart Contracts Co', + 'Decentralized Systems', + 'Future Finance', + 'Digital Assets Fund', + ], + emails: ['info@example.com', 'contact@company.io', 'hello@startup.xyz', 'admin@business.com'], + addresses: [ + '123 Main Street\nSan Francisco, CA 94105\nUSA', + '456 Tech Avenue\nNew York, NY 10001\nUSA', + '789 Innovation Blvd\nAustin, TX 78701\nUSA', + ], + phones: ['+1-555-0100', '+1-555-0200', '+1-555-0300'], + itemDescriptions: [ + 'Web Development Services', + 'Smart Contract Audit', + 'Blockchain Consulting', + 'UI/UX Design', + 'Technical Documentation', + 'System Integration', + 'DevOps Services', + 'Security Assessment', + ], + currencies: [ + { symbol: 'USDC', decimals: 6, address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' }, + { symbol: 'USDT', decimals: 6, address: '0xdac17f958d2ee523a2206206994597c13d831ec7' }, + { symbol: 'DAI', decimals: 18, address: '0x6b175474e89094c44da98b954eedeac495271d0f' }, + { symbol: 'ETH', decimals: 18, address: undefined }, + ], + chainIds: [ + { id: 1, name: 'Ethereum' }, + { id: 42161, name: 'Arbitrum' }, + { id: 10, name: 'Optimism' }, + { id: 137, name: 'Polygon' }, + ], +} + +/** + * Generates a random invoice with realistic data + */ +export function generateRandomInvoice(): Invoice { + const currency = randomItem(SAMPLE_DATA.currencies) + const chainId = randomItem(SAMPLE_DATA.chainIds) + + // Generate timestamps + const now = Math.floor(Date.now() / 1000) + const iss = now - randomInt(0, 7 * 24 * 60 * 60) // Up to 7 days ago + const due = iss + randomInt(7, 60) * 24 * 60 * 60 // 7-60 days from issue + + // Generate line items (1-5 items) + const itemCount = randomInt(1, 5) + const it = Array.from({ length: itemCount }, () => { + const qty = randomInt(1, 100) + const rate = (randomInt(10, 5000) * Math.pow(10, currency.decimals)).toString() + + return { + description: randomItem(SAMPLE_DATA.itemDescriptions), + quantity: qty, + rate: rate, + } + }) + + // Optional fields (70% chance each) + const includeNotes = randomBool(0.7) + const includeTax = randomBool(0.7) + const includeDiscount = randomBool(0.3) + + const includeSenderEmail = randomBool(0.8) + const includeSenderAddress = randomBool(0.6) + const includeSenderPhone = randomBool(0.6) + + const includeClientWallet = randomBool(0.5) + const includeClientEmail = randomBool(0.7) + const includeClientAddress = randomBool(0.5) + const includeClientPhone = randomBool(0.4) + + const invoice: Invoice = { + version: 2, + invoiceId: generateUUID(), + issuedAt: iss, + dueAt: due, + notes: includeNotes + ? 'Payment due within ' + + randomInt(7, 30) + + ' days. Please include invoice number in transaction memo.' + : undefined, + networkId: chainId.id, + currency: currency.symbol, + tokenAddress: currency.address, + decimals: currency.decimals, + from: { + name: randomItem(SAMPLE_DATA.senderNames), + walletAddress: generateAddress(), + email: includeSenderEmail ? randomItem(SAMPLE_DATA.emails) : undefined, + physicalAddress: includeSenderAddress ? randomItem(SAMPLE_DATA.addresses) : undefined, + phone: includeSenderPhone ? randomItem(SAMPLE_DATA.phones) : undefined, + }, + client: { + name: randomItem(SAMPLE_DATA.clientNames), + walletAddress: includeClientWallet ? generateAddress() : undefined, + email: includeClientEmail ? randomItem(SAMPLE_DATA.emails) : undefined, + physicalAddress: includeClientAddress ? randomItem(SAMPLE_DATA.addresses) : undefined, + phone: includeClientPhone ? randomItem(SAMPLE_DATA.phones) : undefined, + }, + items: it, + tax: includeTax ? randomInt(5, 25) + '%' : undefined, + discount: includeDiscount ? randomInt(5, 20) + '%' : undefined, + } + + return invoice +} + +/** + * Generates multiple random invoices + */ +export function generateRandomInvoices(count: number): Invoice[] { + return Array.from({ length: count }, () => generateRandomInvoice()) +} diff --git a/src/shared/ui/icons/critical-icons.tsx b/src/shared/ui/icons/critical-icons.tsx index 43778682..a478e8f4 100644 --- a/src/shared/ui/icons/critical-icons.tsx +++ b/src/shared/ui/icons/critical-icons.tsx @@ -92,3 +92,64 @@ export function GithubIcon({ size = 24, ...props }: IconProps) { ) } + +/** + * Mail icon - Used in PartyInfo contact details + */ +export function MailIcon({ size = 24, ...props }: IconProps) { + return ( + + + + + ) +} + +/** + * Phone icon - Used in PartyInfo contact details + */ +export function PhoneIcon({ size = 24, ...props }: IconProps) { + return ( + + + + ) +} + +/** + * MapPin icon - Used in PartyInfo physical address + */ +export function MapPinIcon({ size = 24, ...props }: IconProps) { + return ( + + + + + ) +} + +/** + * Wallet icon - Used in PartyInfo wallet address + */ +export function WalletIcon({ size = 24, ...props }: IconProps) { + return ( + + + + + ) +} + +/** + * Hash icon - Used in PartyInfo tax ID + */ +export function HashIcon({ size = 24, ...props }: IconProps) { + return ( + + + + + + + ) +} diff --git a/src/shared/ui/icons/index.ts b/src/shared/ui/icons/index.ts index af327c8f..eb09492c 100644 --- a/src/shared/ui/icons/index.ts +++ b/src/shared/ui/icons/index.ts @@ -11,4 +11,9 @@ export { ServerOffIcon, GlobeIcon, GithubIcon, + MailIcon, + PhoneIcon, + MapPinIcon, + WalletIcon, + HashIcon, } from './critical-icons' diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 556fbf4c..0ad63298 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -50,7 +50,18 @@ export { useReducedMotion } from './hooks/use-reduced-motion' export type { NetworkTheme } from './constants/brand-tokens' // Critical path inline SVG icons (above-fold optimization) -export { ArrowRightIcon, LockIcon, ServerOffIcon, GlobeIcon, GithubIcon } from './icons' +export { + ArrowRightIcon, + LockIcon, + ServerOffIcon, + GlobeIcon, + GithubIcon, + MailIcon, + PhoneIcon, + MapPinIcon, + WalletIcon, + HashIcon, +} from './icons' // Popover (existing) export { Popover, PopoverTrigger, PopoverContent } from './popover' diff --git a/src/widgets/invoice-paper/__tests__/InvoicePaper.preview.test.tsx b/src/widgets/invoice-paper/__tests__/InvoicePaper.preview.test.tsx index 3c1d43fb..7346e968 100644 --- a/src/widgets/invoice-paper/__tests__/InvoicePaper.preview.test.tsx +++ b/src/widgets/invoice-paper/__tests__/InvoicePaper.preview.test.tsx @@ -1,25 +1,25 @@ import { render, screen } from '@testing-library/react' import { describe, it, expect } from 'vitest' import { InvoicePaper } from '../ui/InvoicePaper' -import type { InvoiceSchemaV1 } from '@/entities/invoice' +import type { Invoice } from '@/entities/invoice' describe('InvoicePaper Preview Mode', () => { it('renders placeholder for empty items', () => { const emptyData = { - f: { n: 'Alice', a: '0x1' }, - c: { n: 'Bob', a: '0x2' }, - it: [], - cur: 'USDC', - } as unknown as InvoiceSchemaV1 + from: { name: 'Alice', walletAddress: '0x1' }, + client: { name: 'Bob', walletAddress: '0x2' }, + items: [], + currency: 'USDC', + } as unknown as Invoice render() expect(screen.getByText(/No line items/i)).toBeDefined() }) it('renders default values for missing data', () => { const minimalData = { - f: { n: 'Alice' }, - c: { n: 'Bob' }, - } as unknown as InvoiceSchemaV1 + from: { name: 'Alice' }, + client: { name: 'Bob' }, + } as unknown as Invoice render() expect(screen.getByText(/Alice/i)).toBeDefined() expect(screen.getByText(/Bob/i)).toBeDefined() @@ -29,9 +29,9 @@ describe('InvoicePaper Preview Mode', () => { it('handles optional notes', () => { const minimalData = { - f: { n: 'Alice' }, - c: { n: 'Bob' }, - } as unknown as InvoiceSchemaV1 + from: { name: 'Alice' }, + client: { name: 'Bob' }, + } as unknown as Invoice render() expect(screen.getByText(/Thank you for your business/i)).toBeDefined() }) diff --git a/src/widgets/invoice-paper/__tests__/InvoicePaper.print.test.tsx b/src/widgets/invoice-paper/__tests__/InvoicePaper.print.test.tsx index 747c8903..e3f32f47 100644 --- a/src/widgets/invoice-paper/__tests__/InvoicePaper.print.test.tsx +++ b/src/widgets/invoice-paper/__tests__/InvoicePaper.print.test.tsx @@ -2,7 +2,7 @@ import { render } from '@testing-library/react' import { describe, it, expect } from 'vitest' import React from 'react' import { InvoicePaper } from '../ui/InvoicePaper' -import type { InvoiceSchemaV1 } from '@/entities/invoice' +import type { Invoice } from '@/entities/invoice' describe('InvoicePaper Print Support', () => { it('supports forwardRef for printing', () => { @@ -16,7 +16,7 @@ describe('InvoicePaper Print Support', () => { f: { n: 'S', a: '0x1' }, c: { n: 'C', a: '0x2' }, it: [], - } as unknown as InvoiceSchemaV1 + } as unknown as Invoice render() expect(ref.current).not.toBeNull() @@ -32,7 +32,7 @@ describe('InvoicePaper Print Support', () => { f: { n: 'S', a: '0x1' }, c: { n: 'C', a: '0x2' }, it: [], - } as unknown as InvoiceSchemaV1 + } as unknown as Invoice const { container } = render() const paper = container.firstChild as HTMLElement diff --git a/src/widgets/invoice-paper/__tests__/InvoicePaper.test.tsx b/src/widgets/invoice-paper/__tests__/InvoicePaper.test.tsx index e4570452..49b83ba8 100644 --- a/src/widgets/invoice-paper/__tests__/InvoicePaper.test.tsx +++ b/src/widgets/invoice-paper/__tests__/InvoicePaper.test.tsx @@ -1,18 +1,18 @@ import { render, screen } from '@testing-library/react' import { describe, it, expect } from 'vitest' import { InvoicePaper } from '../ui/InvoicePaper' -import { InvoiceSchemaV1 } from '@/entities/invoice' +import { Invoice } from '@/entities/invoice' describe('InvoicePaper Integration', () => { - const mockData: Partial = { - id: 'INV-2024-001', - iss: 1735296000, // Dec 27 - due: 1735382400, // Dec 28 - f: { n: 'Alice', a: '0xSender' }, - c: { n: 'Bob', a: '0xRecipient' }, - it: [{ d: 'Web Design', q: 1, r: '1500' }], - cur: 'USDC', - net: 1, + const mockData: Partial = { + invoiceId: 'INV-2024-001', + issuedAt: 1735296000, // Dec 27 + dueAt: 1735382400, // Dec 28 + from: { name: 'Alice', walletAddress: '0xSender' }, + client: { name: 'Bob', walletAddress: '0xRecipient' }, + items: [{ description: 'Web Design', quantity: 1, rate: '1500' }], + currency: 'USDC', + networkId: 1, } it('renders all core sections', () => { diff --git a/src/widgets/invoice-paper/lib/__tests__/calculate-totals.test.ts b/src/widgets/invoice-paper/lib/__tests__/calculate-totals.test.ts index 6feb2199..98c87973 100644 --- a/src/widgets/invoice-paper/lib/__tests__/calculate-totals.test.ts +++ b/src/widgets/invoice-paper/lib/__tests__/calculate-totals.test.ts @@ -4,8 +4,8 @@ import { calculateTotals } from '../calculate-totals' describe('calculateTotals', () => { it('calculates basic subtotal correctly', () => { const items = [ - { d: 'Item 1', q: 2, r: '100' }, - { d: 'Item 2', q: 1, r: '50' }, + { description: 'Item 1', quantity: 2, rate: '100' }, + { description: 'Item 2', quantity: 1, rate: '50' }, ] const result = calculateTotals(items) expect(result.subtotal).toBe(250) @@ -13,7 +13,7 @@ describe('calculateTotals', () => { }) it('handles percentage tax correctly', () => { - const items = [{ d: 'Item 1', q: 1, r: '100' }] + const items = [{ description: 'Item 1', quantity: 1, rate: '100' }] const result = calculateTotals(items, { tax: '10%' }) expect(result.subtotal).toBe(100) expect(result.taxAmount).toBe(10) @@ -21,7 +21,7 @@ describe('calculateTotals', () => { }) it('handles fixed tax correctly', () => { - const items = [{ d: 'Item 1', q: 1, r: '100' }] + const items = [{ description: 'Item 1', quantity: 1, rate: '100' }] const result = calculateTotals(items, { tax: '5' }) expect(result.subtotal).toBe(100) expect(result.taxAmount).toBe(5) @@ -29,7 +29,7 @@ describe('calculateTotals', () => { }) it('handles percentage discount correctly', () => { - const items = [{ d: 'Item 1', q: 1, r: '100' }] + const items = [{ description: 'Item 1', quantity: 1, rate: '100' }] const result = calculateTotals(items, { discount: '20%' }) expect(result.subtotal).toBe(100) expect(result.discountAmount).toBe(20) @@ -37,7 +37,7 @@ describe('calculateTotals', () => { }) it('handles both tax and discount correctly', () => { - const items = [{ d: 'Item 1', q: 1, r: '100' }] + const items = [{ description: 'Item 1', quantity: 1, rate: '100' }] const result = calculateTotals(items, { tax: '10%', discount: '10' }) // 100 - 10 + 10 = 100 expect(result.subtotal).toBe(100) expect(result.taxAmount).toBe(10) diff --git a/src/widgets/invoice-paper/lib/calculate-totals.ts b/src/widgets/invoice-paper/lib/calculate-totals.ts index 336e038a..2914c5e7 100644 --- a/src/widgets/invoice-paper/lib/calculate-totals.ts +++ b/src/widgets/invoice-paper/lib/calculate-totals.ts @@ -10,8 +10,8 @@ export interface Totals { } interface Item { - q: string | number - r: string + quantity: string | number + rate: string } interface Options { @@ -23,8 +23,8 @@ export const MAGIC_DUST = 0.000001 export function calculateTotals(items: Item[], options: Options = {}): Totals { const subtotal = items.reduce((acc, item) => { - const qty = typeof item.q === 'string' ? parseFloat(item.q) : item.q - const rate = parseFloat(item.r) + const qty = typeof item.quantity === 'string' ? parseFloat(item.quantity) : item.quantity + const rate = parseFloat(item.rate) return acc + (isNaN(qty) || isNaN(rate) ? 0 : qty * rate) }, 0) diff --git a/src/widgets/invoice-paper/types.ts b/src/widgets/invoice-paper/types.ts index 37985bb9..14b6a11b 100644 --- a/src/widgets/invoice-paper/types.ts +++ b/src/widgets/invoice-paper/types.ts @@ -1,4 +1,4 @@ -import { InvoiceSchemaV1, ViewedInvoiceStatus as InvoiceStatus } from '@/entities/invoice' +import { Invoice, ViewedInvoiceStatus as InvoiceStatus } from '@/entities/invoice' /** * Visual status of the invoice document @@ -27,7 +27,7 @@ export interface InvoicePaperProps { * Invoice data following Schema V1. * Can be partial for 'draft' status preview. */ - data: Partial + data: Partial /** * Current status of the invoice. diff --git a/src/widgets/invoice-paper/ui/InvoicePaper.tsx b/src/widgets/invoice-paper/ui/InvoicePaper.tsx index 10e2c1e7..3dcf9320 100644 --- a/src/widgets/invoice-paper/ui/InvoicePaper.tsx +++ b/src/widgets/invoice-paper/ui/InvoicePaper.tsx @@ -11,8 +11,8 @@ import { NETWORK_SHADOWS } from '@/entities/network/config/ui-config' import { cn } from '@/shared/lib/utils' // Stable fallback objects (prevent new object creation on each render) -const EMPTY_PARTY = { n: '', a: '' } as const -const EMPTY_CLIENT = { n: '' } as const +const EMPTY_PARTY = { name: '', walletAddress: '' } as const +const EMPTY_CLIENT = { name: '' } as const const EMPTY_ITEMS: never[] = [] // Date formatter singleton @@ -48,27 +48,27 @@ export const InvoicePaper = React.memo( ) => { const totals = useMemo( () => - calculateTotals(data.it ?? EMPTY_ITEMS, { + calculateTotals(data.items ?? EMPTY_ITEMS, { tax: data.tax, - discount: data.dsc, + discount: data.discount, }), - [data.it, data.tax, data.dsc] + [data.items, data.tax, data.discount] ) - const shadowClass = data.net ? NETWORK_SHADOWS[data.net] : 'shadow-black/20' + const shadowClass = data.networkId ? NETWORK_SHADOWS[data.networkId] : 'shadow-black/20' // Memoize stable props to prevent child re-renders - const from = data.f ?? EMPTY_PARTY - const client = data.c ?? EMPTY_CLIENT - const items = data.it ?? EMPTY_ITEMS + const from = data.from ?? EMPTY_PARTY + const client = data.client ?? EMPTY_CLIENT + const items = data.items ?? EMPTY_ITEMS // Format date for watermark if paid const paidDate = useMemo( () => - status === 'paid' && data.iss - ? dateFormatter.format(new Date(data.iss * 1000)).toUpperCase() + status === 'paid' && data.issuedAt + ? dateFormatter.format(new Date(data.issuedAt * 1000)).toUpperCase() : undefined, - [status, data.iss] + [status, data.issuedAt] ) // Determine if QR should be shown based on variant @@ -92,7 +92,7 @@ export const InvoicePaper = React.memo( className )} role="document" - aria-label={`Invoice ${data.id ?? 'draft'}`} + aria-label={`Invoice ${data.invoiceId ?? 'draft'}`} > {/* Texture Layer - self-hosted for stateless operation */} {showTexture && ( @@ -105,9 +105,9 @@ export const InvoicePaper = React.memo( {/* Content Container */}
@@ -121,20 +121,20 @@ export const InvoicePaper = React.memo( - +
diff --git a/src/widgets/invoice-paper/ui/InvoicePreviewModal.tsx b/src/widgets/invoice-paper/ui/InvoicePreviewModal.tsx index 6b14fb56..956a8d6c 100644 --- a/src/widgets/invoice-paper/ui/InvoicePreviewModal.tsx +++ b/src/widgets/invoice-paper/ui/InvoicePreviewModal.tsx @@ -5,13 +5,13 @@ import { X } from 'lucide-react' import { Dialog, DialogContent, DialogTitle, DialogClose } from '@/shared/ui' import { InvoicePaper } from './InvoicePaper' import { InvoiceStatus } from '../types' -import { InvoiceSchemaV1 } from '@/entities/invoice' +import { Invoice } from '@/entities/invoice' export interface InvoicePreviewModalProps { /** * Invoice data to display */ - data: Partial + data: Partial /** * Invoice status for watermark/badge @@ -51,7 +51,7 @@ export const InvoicePreviewModal = React.memo( > {/* Visually hidden title for accessibility */} - Invoice Preview {data.id ? `#${data.id}` : ''} + Invoice Preview {data.invoiceId ? `#${data.invoiceId}` : ''} {/* Close button positioned outside the invoice */} diff --git a/src/widgets/invoice-paper/ui/LineItemsTable.tsx b/src/widgets/invoice-paper/ui/LineItemsTable.tsx index 52a015f5..4aa68634 100644 --- a/src/widgets/invoice-paper/ui/LineItemsTable.tsx +++ b/src/widgets/invoice-paper/ui/LineItemsTable.tsx @@ -1,9 +1,9 @@ import React from 'react' -import { InvoiceSchemaV1 } from '@/entities/invoice' +import { Invoice } from '@/entities/invoice' import { formatAmount, formatRate } from '../lib/format' interface LineItemsTableProps { - items: InvoiceSchemaV1['it'] + items: Invoice['items'] } export const LineItemsTable = React.memo(({ items }) => { @@ -30,8 +30,9 @@ export const LineItemsTable = React.memo(({ items }) => { {items.map((item, idx) => { - const qty = typeof item.q === 'string' ? parseFloat(item.q) : item.q - const rate = parseFloat(item.r) + const qty = + typeof item.quantity === 'string' ? parseFloat(item.quantity) : item.quantity + const rate = parseFloat(item.rate) const amount = isNaN(qty) || isNaN(rate) ? 0 : qty * rate return ( @@ -40,9 +41,11 @@ export const LineItemsTable = React.memo(({ items }) => { className="group border-b border-zinc-200 transition-colors last:border-0 even:bg-zinc-50/50 hover:bg-zinc-100/50" > {idx + 1} - {item.d} - {item.q} - {formatRate(item.r)} + {item.description} + {item.quantity} + + {formatRate(item.rate)} + {formatAmount(amount)} diff --git a/src/widgets/invoice-paper/ui/PartyInfo.tsx b/src/widgets/invoice-paper/ui/PartyInfo.tsx index 37396840..55090a10 100644 --- a/src/widgets/invoice-paper/ui/PartyInfo.tsx +++ b/src/widgets/invoice-paper/ui/PartyInfo.tsx @@ -1,12 +1,11 @@ import React from 'react' -import { Mail, Phone, MapPin, Wallet } from 'lucide-react' -import { InvoiceSchemaV1 } from '@/entities/invoice' -import { CopyButton } from '@/shared/ui' +import { Invoice } from '@/entities/invoice' +import { CopyButton, MailIcon, PhoneIcon, MapPinIcon, WalletIcon, HashIcon } from '@/shared/ui' import { InvoicePaperVariant } from '../types' interface PartyInfoProps { - from: InvoiceSchemaV1['f'] - client: InvoiceSchemaV1['c'] + from: Invoice['from'] + client: Invoice['client'] variant?: InvoicePaperVariant } @@ -22,37 +21,45 @@ export const PartyInfo = React.memo(({ from, client, variant = ' {/* FROM Section - Order: Name → Email → Phone → Address → Wallet */}

From

- {from.n &&

{from.n}

} + {from.name &&

{from.name}

}
- {from.e && ( + {from.email && ( )} - {from.ph && ( + {from.phone && ( )} - {from.ads && ( -
- +
+ )} + {from.physicalAddress && ( +
+
)}
@@ -63,47 +70,64 @@ export const PartyInfo = React.memo(({ from, client, variant = '

Bill To

- {client.n &&

{client.n}

} + {client.name && ( +

{client.name}

+ )}
- {client.e && ( + {client.email && ( )} - {client.ph && ( + {client.phone && ( )} - {client.ads && ( -
- +
+ )} + {client.physicalAddress && ( +
+
)} - {client.a && ( -
-