diff --git a/dummy/helpers.ts b/dummy/helpers.ts index b9f7a0847..cfea21c08 100644 --- a/dummy/helpers.ts +++ b/dummy/helpers.ts @@ -1,5 +1,9 @@ import { DateTime } from 'luxon'; +export const DEFAULT_FLOW_VALUES = [ + 0.35, 0.25, 0.15, 0.15, 0.25, 0.05, 0.05, 0.15, 0.25, 0.35, 0.45, 0.55, +]; + // prettier-ignore export const partyPurchaseItemMap: Record = { 'Janky Office Spaces': ['Office Rent', 'Office Cleaning'], @@ -22,26 +26,100 @@ export const purchaseItemPartyMap: Record = Object.keys( return acc; }, {} as Record); -export const flow = [ - 0.35, // Jan - 0.25, // Feb - 0.15, // Mar - 0.15, // Apr - 0.25, // May - 0.05, // Jun - 0.05, // Jul - 0.15, // Aug - 0.25, // Sep - 0.35, // Oct - 0.45, // Nov - 0.55, // Dec -]; -export function getFlowConstant(months: number) { - // Jan to December +/** @deprecated use getFlowArray() for dynamic demo payloads */ +export const flow = DEFAULT_FLOW_VALUES; + +let activeFlow: number[] = [...DEFAULT_FLOW_VALUES]; + +let activePurchaseItemPartyMap: Record = { + ...purchaseItemPartyMap, +}; + +export function buildReversePurchaseMap( + map: Record +): Record { + const acc: Record = {}; + for (const party of Object.keys(map)) { + for (const item of map[party]) { + acc[item] = party; + } + } + return acc; +} + +/** Resolved flow for one run (no module globals). */ +export function resolveDummyFlow(flowOverride?: number[] | null): number[] { + if (flowOverride && flowOverride.length === 12) { + return [...flowOverride]; + } + return [...DEFAULT_FLOW_VALUES]; +} + +/** Item name → supplier party for one run (no module globals). */ +export function resolvePurchaseItemPartyLookup( + partyPurchaseItemMapOverride?: Record | null +): Record { + if ( + partyPurchaseItemMapOverride && + Object.keys(partyPurchaseItemMapOverride).length + ) { + return buildReversePurchaseMap(partyPurchaseItemMapOverride); + } + return { ...purchaseItemPartyMap }; +} + +/** Seasonality factor for `months` months back, using an explicit flow array. */ +export function getFlowConstantWithFlow( + months: number, + flow: number[] +): number { const d = DateTime.now().minus({ months }); return flow[d.month - 1]; } +export function resetDummyHelpers(): void { + activeFlow = [...DEFAULT_FLOW_VALUES]; + activePurchaseItemPartyMap = { ...purchaseItemPartyMap }; +} + +export function applyDummyHelpersOverrides( + flowOverride?: number[] | null, + partyPurchaseItemMapOverride?: Record | null +): void { + resetDummyHelpers(); + if (flowOverride != null) { + if (flowOverride.length === 12) { + activeFlow = [...flowOverride]; + } else { + // eslint-disable-next-line no-console -- invalid flowOverride; warn so callers see why activeFlow was not updated + console.warn( + `[applyDummyHelpersOverrides] Ignoring flowOverride (length ${flowOverride.length}, expected 12); activeFlow stays default from resetDummyHelpers().` + ); + } + } + if ( + partyPurchaseItemMapOverride && + Object.keys(partyPurchaseItemMapOverride).length + ) { + activePurchaseItemPartyMap = buildReversePurchaseMap( + partyPurchaseItemMapOverride + ); + } +} + +export function getFlowArray(): number[] { + return activeFlow; +} + +export function getPurchaseItemPartyMap(): Record { + return activePurchaseItemPartyMap; +} + +export function getFlowConstant(months: number) { + const d = DateTime.now().minus({ months }); + return activeFlow[d.month - 1]; +} + export function getRandomDates(count: number, months: number): Date[] { /** * Returns `count` number of dates for a month, `months` back from the diff --git a/dummy/index.ts b/dummy/index.ts index 4ec21694a..a91e56a64 100644 --- a/dummy/index.ts +++ b/dummy/index.ts @@ -11,60 +11,245 @@ import setupInstance from 'src/setup/setupInstance'; import { getMapFromList, safeParseInt } from 'utils'; import { getFiscalYear } from 'utils/misc'; import { - flow, - getFlowConstant, + getFlowConstantWithFlow, getRandomDates, - purchaseItemPartyMap, + resetDummyHelpers, + resolveDummyFlow, + resolvePurchaseItemPartyLookup, } from './helpers'; -import items from './items.json'; +import itemsCatalogDefault from './items.json'; import logo from './logo'; -import parties from './parties.json'; +import partiesCatalogDefault from './parties.json'; +import type { DemoDatasetPayload, DemoItemSeed } from './types'; + +export type { DemoDatasetPayload } from './types'; type Notifier = (stage: string, percent: number) => void; +type CatalogItem = DemoItemSeed; +type CatalogParty = typeof partiesCatalogDefault[number]; + +const DEFAULT_PERIODIC_PURCHASES: Record = { + 'Marketing - Video': 2, + 'Social Ads': 1, + Electricity: 1, + 'Office Cleaning': 1, + 'Office Rent': 1, +}; + +/** Per-run dummy generator state (safe for concurrent setupDummyInstance calls). */ +type DummyRunContext = { + payload: DemoDatasetPayload | null; + catalogItems: CatalogItem[]; + catalogParties: CatalogParty[]; + flow: number[]; + purchaseItemPartyMap: Record; +}; + +function createDummyRunContext( + payload?: DemoDatasetPayload | null +): DummyRunContext { + const catalogItems = + (payload?.items as CatalogItem[]) ?? (itemsCatalogDefault as CatalogItem[]); + const catalogParties = + (payload?.parties as CatalogParty[]) ?? partiesCatalogDefault; + return { + payload: payload ?? null, + catalogItems, + catalogParties, + flow: resolveDummyFlow(payload?.flow ?? null), + purchaseItemPartyMap: resolvePurchaseItemPartyLookup( + payload?.partyPurchaseItemMap ?? null + ), + }; +} + +async function defaultReceivableAccount(fyo: Fyo): Promise { + if (await fyo.db.exists(ModelNameEnum.Account, 'Debtors')) { + return 'Debtors'; + } + if (await fyo.db.exists(ModelNameEnum.Account, 'Trade Receivable')) { + return 'Trade Receivable'; + } + const rows = (await fyo.db.getAll(ModelNameEnum.Account, { + fields: ['name'], + filters: { accountType: 'Receivable', isGroup: false }, + limit: 1, + })) as { name: string }[]; + return rows[0]?.name ?? 'Debtors'; +} + +async function defaultPayableAccount(fyo: Fyo): Promise { + if (await fyo.db.exists(ModelNameEnum.Account, 'Creditors')) { + return 'Creditors'; + } + if (await fyo.db.exists(ModelNameEnum.Account, 'Trade Payable')) { + return 'Trade Payable'; + } + const rows = (await fyo.db.getAll(ModelNameEnum.Account, { + fields: ['name'], + filters: { accountType: 'Payable', isGroup: false }, + limit: 1, + })) as { name: string }[]; + return rows[0]?.name ?? 'Creditors'; +} + +async function defaultCashAccount(fyo: Fyo): Promise { + if (await fyo.db.exists(ModelNameEnum.Account, 'Cash')) { + return 'Cash'; + } + const rows = (await fyo.db.getAll(ModelNameEnum.Account, { + fields: ['name'], + filters: { accountType: 'Cash', isGroup: false }, + limit: 1, + })) as { name: string }[]; + return rows[0]?.name ?? 'Cash'; +} + +async function ensureUnitedArabEmiratesDemoTaxes(fyo: Fyo) { + const specs = [ + { name: 'VAT-5', rate: 5 }, + { name: 'VAT-0', rate: 0 }, + ]; + for (const spec of specs) { + if (await fyo.db.exists(ModelNameEnum.Tax, spec.name)) { + continue; + } + const doc = fyo.doc.getNewDoc( + ModelNameEnum.Tax, + { + name: spec.name, + details: [{ account: 'Sales Tax Payable', rate: spec.rate }], + }, + false + ); + await doc.sync(); + } +} + export async function setupDummyInstance( dbPath: string, fyo: Fyo, years = 1, baseCount = 1000, - notifier?: Notifier + notifier?: Notifier, + payload?: DemoDatasetPayload | null ) { + const ctx = createDummyRunContext(payload ?? null); + await fyo.purgeCache(); notifier?.(fyo.t`Setting Up Instance`, -1); - const options = { - logo: null, - companyName: "Flo's Clothes", - country: 'India', - fullname: 'Lin Florentine', - email: 'lin@flosclothes.com', - bankName: 'Supreme Bank', - currency: 'INR', - fiscalYearStart: getFiscalYear('04-01', true)!.toISOString(), - fiscalYearEnd: getFiscalYear('04-01', false)!.toISOString(), - chartOfAccounts: 'India - Chart of Accounts', - }; - await setupInstance(dbPath, options, fyo); - fyo.store.skipTelemetryLogging = true; - - years = Math.floor(years); - notifier?.(fyo.t`Creating Items and Parties`, -1); - await generateStaticEntries(fyo); - await generateDynamicEntries(fyo, years, baseCount, notifier); - await setOtherSettings(fyo); - - const instanceId = (await fyo.getValue( - ModelNameEnum.SystemSettings, - 'instanceId' - )) as string; - await fyo.singles.SystemSettings?.setAndSync('hideGetStarted', true); - - fyo.store.skipTelemetryLogging = false; - return { companyName: options.companyName, instanceId }; + + const fyStart = payload?.options.fiscalYearStartMD ?? '04-01'; + const fyEnd = payload?.options.fiscalYearEndMD ?? '04-01'; + + const options = payload + ? (() => { + const fyStartDate = getFiscalYear(fyStart, true); + const fyEndDate = getFiscalYear(fyEnd, false); + if (!fyStartDate || !fyEndDate) { + throw new Error( + `Invalid fiscal year format: start=${fyStart}, end=${fyEnd}` + ); + } + return { + logo: null as string | null, + companyName: payload.options.companyName, + country: payload.options.country, + fullname: payload.options.fullname ?? '', + email: payload.options.email ?? '', + bankName: payload.options.bankName ?? '', + currency: payload.options.currency, + fiscalYearStart: fyStartDate.toISOString(), + fiscalYearEnd: fyEndDate.toISOString(), + chartOfAccounts: payload.options.chartOfAccounts, + }; + })() + : { + logo: null as string | null, + companyName: "Flo's Clothes", + country: 'India', + fullname: 'Lin Florentine', + email: 'lin@flosclothes.com', + bankName: 'Supreme Bank', + currency: 'INR', + fiscalYearStart: getFiscalYear('04-01', true)!.toISOString(), + fiscalYearEnd: getFiscalYear('04-01', false)!.toISOString(), + chartOfAccounts: 'India - Chart of Accounts', + }; + + let prevSkipTelemetryLogging = false; + let skipTelemetryLoggingWasOverridden = false; + try { + await setupInstance(dbPath, options, fyo); + if (payload?.options.country === 'United Arab Emirates') { + await ensureUnitedArabEmiratesDemoTaxes(fyo); + } + prevSkipTelemetryLogging = fyo.store?.skipTelemetryLogging ?? false; + fyo.store.skipTelemetryLogging = true; + skipTelemetryLoggingWasOverridden = true; + + years = Math.floor(years); + notifier?.(fyo.t`Creating Items and Parties`, -1); + await generateStaticEntries(fyo, ctx); + await generateDynamicEntries(fyo, years, baseCount, notifier, ctx); + await setOtherSettings(fyo, payload ?? undefined); + + const instanceId = (await fyo.getValue( + ModelNameEnum.SystemSettings, + 'instanceId' + )) as string; + await fyo.singles.SystemSettings?.setAndSync('hideGetStarted', true); + + return { companyName: options.companyName, instanceId }; + } finally { + if (skipTelemetryLoggingWasOverridden) { + fyo.store.skipTelemetryLogging = prevSkipTelemetryLogging; + } + resetDummyHelpers(); + } } -async function setOtherSettings(fyo: Fyo) { +async function setOtherSettings(fyo: Fyo, payload?: DemoDatasetPayload) { const doc = await fyo.doc.getDoc(ModelNameEnum.PrintSettings); const address = fyo.doc.getNewDoc(ModelNameEnum.Address); + + if (payload?.address) { + const emirateOrState = payload.address.state || payload.address.city || ''; + await address.setAndSync({ + addressLine1: payload.address.addressLine1 ?? '', + city: payload.address.city ?? '', + state: payload.address.state ?? '', + pos: emirateOrState, + postalCode: payload.address.postalCode ?? '', + country: payload.address.country ?? payload.options.country, + }); + + const ps = payload.printSettings; + const displayLogo = + typeof ps.displayLogo === 'boolean' + ? ps.displayLogo + : Boolean(ps.displayLogo); + await doc.setAndSync({ + color: ps.color ?? '#F687B3', + template: 'Business', + displayLogo, + phone: payload.accounting.phone ?? '', + logo, + address: address.name, + }); + + const acc = await fyo.doc.getDoc(ModelNameEnum.AccountingSettings); + const patch: Record = {}; + if (payload.options.country === 'India' && payload.accounting.taxId) { + patch.gstin = payload.accounting.taxId; + } + if (Object.keys(patch).length) { + await acc.setAndSync(patch); + } + return; + } + await address.setAndSync({ addressLine1: '1st Column, Fitzgerald Bridge', city: 'Pune', @@ -97,15 +282,27 @@ async function generateDynamicEntries( fyo: Fyo, years: number, baseCount: number, - notifier?: Notifier + notifier: Notifier | undefined, + ctx: DummyRunContext ) { - const salesInvoices = await getSalesInvoices(fyo, years, baseCount, notifier); + const salesInvoices = await getSalesInvoices( + fyo, + years, + baseCount, + notifier, + ctx + ); notifier?.(fyo.t`Creating Purchase Invoices`, -1); - const purchaseInvoices = await getPurchaseInvoices(fyo, years, salesInvoices); + const purchaseInvoices = await getPurchaseInvoices( + fyo, + years, + salesInvoices, + ctx + ); notifier?.(fyo.t`Creating Journal Entries`, -1); - const journalEntries = await getJournalEntries(fyo, salesInvoices); + const journalEntries = await getJournalEntries(fyo, salesInvoices, ctx); await syncAndSubmit(journalEntries, notifier); const invoices = ([salesInvoices, purchaseInvoices].flat() as Invoice[]).sort( @@ -117,7 +314,14 @@ async function generateDynamicEntries( await syncAndSubmit(payments, notifier); } -async function getJournalEntries(fyo: Fyo, salesInvoices: SalesInvoice[]) { +async function getJournalEntries( + fyo: Fyo, + salesInvoices: SalesInvoice[], + ctx: DummyRunContext +) { + if (ctx.payload?.options.country === 'United Arab Emirates') { + return []; + } const entries = []; const amount = salesInvoices .map((i) => i.items!) @@ -177,6 +381,9 @@ async function getJournalEntries(fyo: Fyo, salesInvoices: SalesInvoice[]) { } async function getPayments(fyo: Fyo, invoices: Invoice[]) { + const recvAccount = await defaultReceivableAccount(fyo); + const payAccount = await defaultPayableAccount(fyo); + const cashAccount = await defaultCashAccount(fyo); const payments = []; for (const invoice of invoices) { // Defaulters @@ -192,11 +399,11 @@ async function getPayments(fyo: Fyo, invoices: Invoice[]) { .plus({ hours: 1 }) .toJSDate(); if (doc.paymentType === 'Receive') { - doc.account = 'Debtors'; - doc.paymentAccount = 'Cash'; + doc.account = recvAccount; + doc.paymentAccount = cashAccount; } else { - doc.account = 'Cash'; - doc.paymentAccount = 'Creditors'; + doc.account = cashAccount; + doc.paymentAccount = payAccount; } doc.amount = invoice.outstandingAmount; @@ -221,10 +428,14 @@ async function getPayments(fyo: Fyo, invoices: Invoice[]) { return payments; } -function getSalesInvoiceDates(years: number, baseCount: number): Date[] { +function getSalesInvoiceDates( + years: number, + baseCount: number, + ctx: DummyRunContext +): Date[] { const dates: Date[] = []; for (const months of range(0, years * 12)) { - const flow = getFlowConstant(months); + const flow = getFlowConstantWithFlow(months, ctx.flow); const count = Math.ceil(flow * baseCount * (Math.random() * 0.25 + 0.75)); dates.push(...getRandomDates(count, months)); } @@ -236,17 +447,21 @@ async function getSalesInvoices( fyo: Fyo, years: number, baseCount: number, - notifier?: Notifier + notifier: Notifier | undefined, + ctx: DummyRunContext ) { const invoices: SalesInvoice[] = []; - const salesItems = items.filter((i) => i.for !== 'Purchases'); - const customers = parties.filter((i) => i.role !== 'Supplier'); + const recvAccount = await defaultReceivableAccount(fyo); + const salesItems = ctx.catalogItems.filter( + (i) => i.forSalesOrPurchases !== 'Purchases' + ); + const customers = ctx.catalogParties.filter((i) => i.role !== 'Supplier'); /** * Get certain number of entries for each month of the count * of years. */ - const dates = getSalesInvoiceDates(years, baseCount); + const dates = getSalesInvoiceDates(years, baseCount, ctx); /** * For each date create a Sales Invoice. @@ -271,7 +486,7 @@ async function getSalesInvoices( await doc.set('party', customer!.name); if (!doc.account) { - doc.account = 'Debtors'; + doc.account = recvAccount; } /** * Add `numItems` number of items to the invoice. @@ -296,7 +511,7 @@ async function getSalesInvoices( quantity = Math.ceil(Math.random() * 3); } - let fc = flow[date.getMonth()]; + let fc = ctx.flow[date.getMonth()]; if (baseCount < 500) { fc += 1; } @@ -323,19 +538,22 @@ async function getSalesInvoices( async function getPurchaseInvoices( fyo: Fyo, years: number, - salesInvoices: SalesInvoice[] + salesInvoices: SalesInvoice[], + ctx: DummyRunContext ): Promise { return [ - await getSalesPurchaseInvoices(fyo, salesInvoices), - await getNonSalesPurchaseInvoices(fyo, years), + await getSalesPurchaseInvoices(fyo, salesInvoices, ctx), + await getNonSalesPurchaseInvoices(fyo, years, ctx), ].flat(); } async function getSalesPurchaseInvoices( fyo: Fyo, - salesInvoices: SalesInvoice[] + salesInvoices: SalesInvoice[], + ctx: DummyRunContext ): Promise { const invoices = [] as PurchaseInvoice[]; + const payAccount = await defaultPayableAccount(fyo); /** * Group all sales invoices by their YYYY-MM. */ @@ -395,7 +613,10 @@ async function getSalesPurchaseInvoices( }); const supplierGrouped = Object.keys(itemGrouped).reduce((acc, item) => { - const supplier = purchaseItemPartyMap[item]; + const supplier = ctx.purchaseItemPartyMap[item]; + if (!supplier) { + return acc; + } acc[supplier] ??= []; acc[supplier].push(item); @@ -416,7 +637,7 @@ async function getSalesPurchaseInvoices( await doc.set('party', supplier); if (!doc.account) { - doc.account = 'Creditors'; + doc.account = payAccount; } /** @@ -437,17 +658,15 @@ async function getSalesPurchaseInvoices( async function getNonSalesPurchaseInvoices( fyo: Fyo, - years: number + years: number, + ctx: DummyRunContext ): Promise { - const purchaseItems = items.filter((i) => i.for !== 'Sales'); + const payAccount = await defaultPayableAccount(fyo); + const purchaseItems = ctx.catalogItems.filter( + (i) => i.forSalesOrPurchases !== 'Sales' + ); const itemMap = getMapFromList(purchaseItems, 'name'); - const periodic: Record = { - 'Marketing - Video': 2, - 'Social Ads': 1, - Electricity: 1, - 'Office Cleaning': 1, - 'Office Rent': 1, - }; + const periodic = ctx.payload?.periodicPurchases ?? DEFAULT_PERIODIC_PURCHASES; const invoices: SalesInvoice[] = []; for (const months of range(0, years * 12)) { @@ -462,6 +681,16 @@ async function getNonSalesPurchaseInvoices( continue; } + const party = ctx.purchaseItemPartyMap[name]; + if (!party) { + continue; + } + + const item = itemMap[name]; + if (!item) { + continue; + } + const doc = fyo.doc.getNewDoc( ModelNameEnum.PurchaseInvoice, { @@ -470,20 +699,22 @@ async function getNonSalesPurchaseInvoices( false ) as PurchaseInvoice; - const party = purchaseItemPartyMap[name]; await doc.set('party', party); if (!doc.account) { - doc.account = 'Creditors'; + doc.account = payAccount; } await doc.append('items', {}); const row = doc.items!.at(-1)!; - const item = itemMap[name]; let quantity = 1; let rate = item.rate; - if (name === 'Social Ads') { + const rentLike = + name === 'Office Rent' || + name.includes('إيجار') || + (typeof name === 'string' && name.toLowerCase().includes('rent')); + if (item.rate < 120 && !rentLike) { quantity = Math.ceil(Math.random() * 200); - } else if (name !== 'Office Rent') { + } else if (!rentLike) { rate = rate * (Math.random() * 0.4 + 0.8); } @@ -500,21 +731,26 @@ async function getNonSalesPurchaseInvoices( return invoices; } -async function generateStaticEntries(fyo: Fyo) { - await generateItems(fyo); - await generateParties(fyo); +async function generateStaticEntries(fyo: Fyo, ctx: DummyRunContext) { + await generateItems(fyo, ctx); + await generateParties(fyo, ctx); } -async function generateItems(fyo: Fyo) { - for (const item of items) { - const doc = fyo.doc.getNewDoc('Item', item, false); +async function generateItems(fyo: Fyo, ctx: DummyRunContext) { + for (const raw of ctx.catalogItems) { + const { forSalesOrPurchases, ...rest } = raw; + const doc = fyo.doc.getNewDoc( + 'Item', + { ...rest, for: forSalesOrPurchases }, + false + ); await doc.sync(); } } -async function generateParties(fyo: Fyo) { - for (const party of parties) { - const doc = fyo.doc.getNewDoc('Party', party, false); +async function generateParties(fyo: Fyo, ctx: DummyRunContext) { + for (const raw of ctx.catalogParties) { + const doc = fyo.doc.getNewDoc('Party', raw, false); await doc.sync(); } } diff --git a/dummy/items.json b/dummy/items.json index 4bfdc09dd..9d3e83126 100644 --- a/dummy/items.json +++ b/dummy/items.json @@ -1 +1 @@ -[{"name": "Dry-Cleaning", "description": null, "unit": "Unit", "itemType": "Service", "incomeAccount": "Service", "expenseAccount": "Cost of Goods Sold", "tax": "GST-18", "rate": 69, "hsnCode": "999712", "for": "Sales"}, {"name": "Electricity", "description": "Bzz Bzz", "unit": "Day", "itemType": "Service", "incomeAccount": "Service", "expenseAccount": "Utility Expenses", "tax": "GST-0", "rate": 6000, "hsnCode": "271600", "for": "Purchases"}, {"name": "Marketing - Video", "description": "One single video", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Marketing Expenses", "tax": "GST-18", "rate": 15000, "hsnCode": "998371", "for": "Purchases"}, {"name": "Office Rent", "description": "Rent per day", "unit": "Day", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Office Rent", "tax": "GST-18", "rate": 50000, "hsnCode": "997212", "for": "Purchases"}, {"name": "Office Cleaning", "description": "Cleaning cost per day", "unit": "Day", "itemType": "Service", "incomeAccount": "Service", "expenseAccount": "Office Maintenance Expenses", "tax": "GST-18", "rate": 7500, "hsnCode": "998533", "for": "Purchases"}, {"name": "Social Ads", "description": "Cost per click", "unit": "Unit", "itemType": "Service", "incomeAccount": "Service", "expenseAccount": "Marketing Expenses", "tax": "GST-18", "rate": 50, "hsnCode": "99836", "for": "Purchases"}, {"name": "Cool Cloth", "description": "Some real \ud83c\udd92 cloth", "unit": "Meter", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-18", "rate": 4000, "hsnCode": "59111000", "for": "Both"}, {"name": "611 Jeans - PCH", "description": "Peach coloured 611s", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-12", "rate": 4499, "hsnCode": "62034990", "for": "Both"}, {"name": "611 Jeans - SHR", "description": "Shark skin 611s", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-12", "rate": 6499, "hsnCode": "62034990", "for": "Both"}, {"name": "Bominga Shoes", "description": null, "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-18", "rate": 4999, "hsnCode": "640291", "for": "Both"}, {"name": "Cryo Gloves", "description": "Keeps hands cool", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-18", "rate": 3499, "hsnCode": "611693", "for": "Both"}, {"name": "Epaulettes - 4POR", "description": "Porcelain epaulettes", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-18", "rate": 2499, "hsnCode": "62179090", "for": "Both"}, {"name": "Full Sleeve - BLK", "description": "Black sleeved", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-12", "rate": 599, "hsnCode": "100820", "for": "Both"}, {"name": "Full Sleeve - COL", "description": "All color sleeved", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-12", "rate": 499, "hsnCode": "100820", "for": "Both"}, {"name": "Jacket - RAW", "description": "Raw baby skinned jackets", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-12", "rate": 8999, "hsnCode": "100820", "for": "Both"}, {"name": "Jade Slippers", "description": null, "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-18", "rate": 2999, "hsnCode": "640520", "for": "Both"}] \ No newline at end of file +[{"name": "Dry-Cleaning", "description": null, "unit": "Unit", "itemType": "Service", "incomeAccount": "Service", "expenseAccount": "Cost of Goods Sold", "tax": "GST-18", "rate": 69, "hsnCode": "999712", "forSalesOrPurchases": "Sales"}, {"name": "Electricity", "description": "Bzz Bzz", "unit": "Day", "itemType": "Service", "incomeAccount": "Service", "expenseAccount": "Utility Expenses", "tax": "GST-0", "rate": 6000, "hsnCode": "271600", "forSalesOrPurchases": "Purchases"}, {"name": "Marketing - Video", "description": "One single video", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Marketing Expenses", "tax": "GST-18", "rate": 15000, "hsnCode": "998371", "forSalesOrPurchases": "Purchases"}, {"name": "Office Rent", "description": "Rent per day", "unit": "Day", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Office Rent", "tax": "GST-18", "rate": 50000, "hsnCode": "997212", "forSalesOrPurchases": "Purchases"}, {"name": "Office Cleaning", "description": "Cleaning cost per day", "unit": "Day", "itemType": "Service", "incomeAccount": "Service", "expenseAccount": "Office Maintenance Expenses", "tax": "GST-18", "rate": 7500, "hsnCode": "998533", "forSalesOrPurchases": "Purchases"}, {"name": "Social Ads", "description": "Cost per click", "unit": "Unit", "itemType": "Service", "incomeAccount": "Service", "expenseAccount": "Marketing Expenses", "tax": "GST-18", "rate": 50, "hsnCode": "99836", "forSalesOrPurchases": "Purchases"}, {"name": "Cool Cloth", "description": "Some real \ud83c\udd92 cloth", "unit": "Meter", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-18", "rate": 4000, "hsnCode": "59111000", "forSalesOrPurchases": "Both"}, {"name": "611 Jeans - PCH", "description": "Peach coloured 611s", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-12", "rate": 4499, "hsnCode": "62034990", "forSalesOrPurchases": "Both"}, {"name": "611 Jeans - SHR", "description": "Shark skin 611s", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-12", "rate": 6499, "hsnCode": "62034990", "forSalesOrPurchases": "Both"}, {"name": "Bominga Shoes", "description": null, "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-18", "rate": 4999, "hsnCode": "640291", "forSalesOrPurchases": "Both"}, {"name": "Cryo Gloves", "description": "Keeps hands cool", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-18", "rate": 3499, "hsnCode": "611693", "forSalesOrPurchases": "Both"}, {"name": "Epaulettes - 4POR", "description": "Porcelain epaulettes", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-18", "rate": 2499, "hsnCode": "62179090", "forSalesOrPurchases": "Both"}, {"name": "Full Sleeve - BLK", "description": "Black sleeved", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-12", "rate": 599, "hsnCode": "100820", "forSalesOrPurchases": "Both"}, {"name": "Full Sleeve - COL", "description": "All color sleeved", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-12", "rate": 499, "hsnCode": "100820", "forSalesOrPurchases": "Both"}, {"name": "Jacket - RAW", "description": "Raw baby skinned jackets", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-12", "rate": 8999, "hsnCode": "100820", "forSalesOrPurchases": "Both"}, {"name": "Jade Slippers", "description": null, "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-18", "rate": 2999, "hsnCode": "640520", "forSalesOrPurchases": "Both"}] \ No newline at end of file diff --git a/dummy/types.ts b/dummy/types.ts new file mode 100644 index 000000000..f62091531 --- /dev/null +++ b/dummy/types.ts @@ -0,0 +1,64 @@ +/** + * Payload returned by rukn_books_subscription.api.get_demo_dataset + * and consumed by setupDummyInstance. + */ +export type DemoDatasetPayload = { + key: string; + options: { + companyName: string; + fullname?: string; + email?: string; + bankName?: string; + country: string; + currency: string; + chartOfAccounts: string; + fiscalYearStartMD: string; + fiscalYearEndMD: string; + }; + address: { + addressLine1?: string; + city?: string; + state?: string; + postalCode?: string; + country?: string; + }; + accounting: { + taxId?: string; + phone?: string; + }; + printSettings: { + color?: string; + displayLogo?: boolean; + }; + items: DemoItemSeed[]; + parties: DemoPartySeed[]; + partyPurchaseItemMap: Record; + flow: number[]; + periodicPurchases?: Record; +}; + +export type DemoItemSeed = { + name: string; + description?: string | null; + unit: string; + itemType: string; + incomeAccount: string; + expenseAccount: string; + tax?: string; + rate: number; + hsnCode?: string | null; + /** Sales / Purchases / Both (maps to Item.for when syncing). */ + forSalesOrPurchases: string; + image?: string; +}; + +export type DemoPartySeed = { + name: string; + role: string; + defaultAccount: string; + currency: string; + email?: string | null; + phone?: string | null; + gstType?: string | null; + gstin?: string | null; +}; diff --git a/fyo/core/types.ts b/fyo/core/types.ts index e67ba07fb..1c401b744 100644 --- a/fyo/core/types.ts +++ b/fyo/core/types.ts @@ -83,6 +83,10 @@ export interface ConfigFile { companyName: string; dbPath: string; openCount: number; + /** True when this file was created via Create Demo (not New Company / ERPNext import). */ + isDemo?: boolean; + /** Demo persona key from subscription server, or flo-clothes for built-in offline demo. */ + demoKey?: string; } export interface FyoConfig { diff --git a/main/demoData.ts b/main/demoData.ts new file mode 100644 index 000000000..75d495c9f --- /dev/null +++ b/main/demoData.ts @@ -0,0 +1,384 @@ +import fetch from 'node-fetch'; +import type { DemoDatasetPayload, DemoItemSeed } from 'dummy/types'; +import { + getErrorMessageFromResponse, + SUBSCRIPTION_SERVER, +} from './subscription'; + +export type DemoDatasetListRow = { + name: string; + key: string; + title_en?: string; + title_ar?: string; + description_en?: string; + description_ar?: string; + locale?: string; + industry?: string; + country?: string; + currency?: string; + preview_images?: string[]; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function isOptionalStringField(value: unknown): boolean { + return value === undefined || typeof value === 'string'; +} + +/** Runtime check for API list_demo_datasets row shape. */ +function isDemoDatasetListRow(value: unknown): value is DemoDatasetListRow { + if (!isRecord(value)) { + return false; + } + if (!isNonEmptyString(value.name)) { + return false; + } + if (!isNonEmptyString(value.key)) { + return false; + } + if ( + !isOptionalStringField(value.title_en) || + !isOptionalStringField(value.title_ar) || + !isOptionalStringField(value.description_en) || + !isOptionalStringField(value.description_ar) || + !isOptionalStringField(value.locale) || + !isOptionalStringField(value.industry) || + !isOptionalStringField(value.country) || + !isOptionalStringField(value.currency) + ) { + return false; + } + if (value.preview_images !== undefined) { + if (!Array.isArray(value.preview_images)) { + return false; + } + for (const url of value.preview_images) { + if (typeof url !== 'string') { + return false; + } + } + } + return true; +} + +function validateDemoDatasetListRows( + datasets: unknown[] +): DemoDatasetListRow[] | null { + const out: DemoDatasetListRow[] = []; + for (const item of datasets) { + if (!isDemoDatasetListRow(item)) { + return null; + } + out.push(item); + } + return out; +} + +function getDemoItemSalesOrPurchasesScope( + value: Record +): string | null { + if ( + typeof value.forSalesOrPurchases === 'string' && + value.forSalesOrPurchases.length > 0 + ) { + return value.forSalesOrPurchases; + } + // API still sends JSON key "for" from Frappe (maps to Item.for on sync). + if (typeof value.for === 'string' && value.for.length > 0) { + return value.for; + } + return null; +} + +function isDemoItemSeed(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + if (!isNonEmptyString(value.name)) { + return false; + } + if (typeof value.unit !== 'string') { + return false; + } + if (typeof value.itemType !== 'string') { + return false; + } + if (typeof value.incomeAccount !== 'string') { + return false; + } + if (typeof value.expenseAccount !== 'string') { + return false; + } + if (typeof value.rate !== 'number' || Number.isNaN(value.rate)) { + return false; + } + if (getDemoItemSalesOrPurchasesScope(value) === null) { + return false; + } + if ( + value.tax !== undefined && + value.tax !== null && + typeof value.tax !== 'string' + ) { + return false; + } + return true; +} + +function normalizeDemoItemSeed(raw: Record): DemoItemSeed { + const forSalesOrPurchases = getDemoItemSalesOrPurchasesScope(raw); + if (forSalesOrPurchases === null) { + throw new Error('normalizeDemoItemSeed: missing forSalesOrPurchases'); + } + const rest = { ...raw }; + delete rest.for; + delete rest.forSalesOrPurchases; + return { + ...(rest as Omit), + forSalesOrPurchases, + }; +} + +function normalizeDemoDatasetPayload( + raw: Record +): DemoDatasetPayload { + const itemsRaw = raw.items; + if (!Array.isArray(itemsRaw)) { + throw new Error('normalizeDemoDatasetPayload: items must be an array'); + } + const items = itemsRaw.map((it) => { + if (!isRecord(it)) { + throw new Error('normalizeDemoDatasetPayload: invalid item'); + } + return normalizeDemoItemSeed(it); + }); + return { ...(raw as DemoDatasetPayload), items }; +} + +function isDemoPartySeed(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + if (!isNonEmptyString(value.name)) { + return false; + } + if (typeof value.role !== 'string') { + return false; + } + if (typeof value.defaultAccount !== 'string') { + return false; + } + if (typeof value.currency !== 'string') { + return false; + } + return true; +} + +function isPayloadOptions(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + return ( + isNonEmptyString(value.companyName) && + isNonEmptyString(value.country) && + isNonEmptyString(value.currency) && + isNonEmptyString(value.chartOfAccounts) && + isNonEmptyString(value.fiscalYearStartMD) && + isNonEmptyString(value.fiscalYearEndMD) + ); +} + +function isPartyPurchaseItemMap(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + for (const k of Object.keys(value)) { + const row = value[k]; + if (!Array.isArray(row)) { + return false; + } + for (const el of row) { + if (typeof el !== 'string') { + return false; + } + } + } + return true; +} + +function isNumberArray(value: unknown): boolean { + if (!Array.isArray(value)) { + return false; + } + for (const x of value) { + if (typeof x !== 'number' || Number.isNaN(x)) { + return false; + } + } + return true; +} + +function isPeriodicPurchasesMap(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + for (const k of Object.keys(value)) { + const n = value[k]; + if (typeof n !== 'number' || !Number.isFinite(n)) { + return false; + } + } + return true; +} + +function isDemoDatasetPayloadMessage( + value: unknown +): value is DemoDatasetPayload { + if (!isRecord(value)) { + return false; + } + if (!isNonEmptyString(value.key)) { + return false; + } + if (!isPayloadOptions(value.options)) { + return false; + } + if (!isRecord(value.address)) { + return false; + } + if (!isRecord(value.accounting)) { + return false; + } + if (!isRecord(value.printSettings)) { + return false; + } + if ( + !Array.isArray(value.items) || + value.items.length === 0 || + !value.items.every(isDemoItemSeed) + ) { + return false; + } + if ( + !Array.isArray(value.parties) || + value.parties.length === 0 || + !value.parties.every(isDemoPartySeed) + ) { + return false; + } + if (!isPartyPurchaseItemMap(value.partyPurchaseItemMap)) { + return false; + } + if (!isNumberArray(value.flow)) { + return false; + } + if ( + value.periodicPurchases !== undefined && + !isPeriodicPurchasesMap(value.periodicPurchases) + ) { + return false; + } + return true; +} + +export async function listDemoDatasets(token: string): Promise<{ + success: boolean; + message: string; + datasets: DemoDatasetListRow[]; +}> { + try { + const res = await fetch( + `${SUBSCRIPTION_SERVER}/api/method/rukn_books_subscription.api.list_demo_datasets`, + { + method: 'POST', + headers: { + Authorization: `token ${token}`, + }, + } + ); + if (res.status === 200) { + const body = (await res.json()) as { message?: unknown }; + const msg = body.message; + if (isRecord(msg) && Array.isArray(msg.datasets)) { + const datasets = validateDemoDatasetListRows(msg.datasets); + if (datasets === null) { + return { + success: false, + message: 'Invalid dataset item', + datasets: [], + }; + } + return { + success: true, + message: 'OK', + datasets, + }; + } + return { + success: false, + message: 'Unexpected response structure', + datasets: [], + }; + } + return { + success: false, + message: await getErrorMessageFromResponse(res), + datasets: [], + }; + } catch (err) { + const m = err instanceof Error ? err.message : String(err); + return { success: false, message: m, datasets: [] }; + } +} + +export async function getDemoDataset( + token: string, + key: string +): Promise<{ + success: boolean; + message: string; + payload?: DemoDatasetPayload; +}> { + try { + const res = await fetch( + `${SUBSCRIPTION_SERVER}/api/method/rukn_books_subscription.api.get_demo_dataset?key=${encodeURIComponent( + key + )}`, + { + method: 'POST', + headers: { + Authorization: `token ${token}`, + }, + } + ); + if (res.status === 200) { + const body = (await res.json()) as { message?: unknown }; + const msg = body.message; + if (isDemoDatasetPayloadMessage(msg)) { + return { + success: true, + message: 'OK', + payload: normalizeDemoDatasetPayload(msg as Record), + }; + } + return { + success: false, + message: + 'Invalid demo dataset response: missing or malformed payload fields', + }; + } + return { + success: false, + message: await getErrorMessageFromResponse(res), + }; + } catch (err) { + const m = err instanceof Error ? err.message : String(err); + return { success: false, message: m }; + } +} diff --git a/main/helpers.ts b/main/helpers.ts index 3ec78849b..979bab905 100644 --- a/main/helpers.ts +++ b/main/helpers.ts @@ -36,14 +36,12 @@ export async function setAndGetCleanedConfigFiles() { export async function getConfigFilesWithModified(files: ConfigFile[]) { const filesWithModified: ConfigFilesWithModified[] = []; - for (const { dbPath, id, companyName, openCount } of files) { + for (const file of files) { + const { dbPath } = file; const { mtime } = await fs.stat(dbPath); filesWithModified.push({ - id, - dbPath, - companyName, + ...file, modified: mtime.toISOString(), - openCount, }); } diff --git a/main/preload.ts b/main/preload.ts index ee7d215c8..6e3b4e096 100644 --- a/main/preload.ts +++ b/main/preload.ts @@ -359,6 +359,27 @@ const ipc = { }; }, + async listDemoDatasets() { + return (await ipcRenderer.invoke( + IPC_ACTIONS.LIST_DEMO_DATASETS + )) as { + success: boolean; + message: string; + datasets: Array>; + }; + }, + + async getDemoDataset(key: string) { + return (await ipcRenderer.invoke( + IPC_ACTIONS.GET_DEMO_DATASET, + key + )) as { + success: boolean; + message: string; + payload?: Record; + }; + }, + registerMainProcessErrorListener(listener: IPCRendererListener) { ipcRenderer.on(IPC_CHANNELS.LOG_MAIN_PROCESS_ERROR, listener); }, diff --git a/main/registerIpcMainActionListeners.ts b/main/registerIpcMainActionListeners.ts index 47620f23a..5aeafabee 100644 --- a/main/registerIpcMainActionListeners.ts +++ b/main/registerIpcMainActionListeners.ts @@ -41,6 +41,7 @@ import verifyTokenWithServer, { syncDatabaseToServer, reportIssueToServer, } from './subscription'; +import { getDemoDataset, listDemoDatasets } from './demoData'; function sanitizeFilename(name: string) { return name @@ -464,6 +465,41 @@ export default function registerIpcMainActionListeners(main: Main) { } ); + ipcMain.handle(IPC_ACTIONS.LIST_DEMO_DATASETS, async () => { + const token = retrieveToken(); + if (!token) { + return { + success: false, + message: 'No subscription token', + datasets: [], + }; + } + try { + return await listDemoDatasets(token); + } catch (error) { + return { + success: false, + message: (error as Error).message || 'Failed to list demo datasets', + datasets: [], + }; + } + }); + + ipcMain.handle(IPC_ACTIONS.GET_DEMO_DATASET, async (_, key: string) => { + const token = retrieveToken(); + if (!token) { + return { success: false, message: 'No subscription token' }; + } + try { + return await getDemoDataset(token, key); + } catch (error) { + return { + success: false, + message: (error as Error).message || 'Failed to get demo dataset', + }; + } + }); + /** * Database Related Actions */ diff --git a/main/subscription.ts b/main/subscription.ts index 6cf1bb79d..edc81b4e7 100644 --- a/main/subscription.ts +++ b/main/subscription.ts @@ -5,7 +5,7 @@ import type { Response } from 'node-fetch'; import { randomBytes } from 'crypto'; import { SUBSCRIPTION_FEATURE_SERVER_MAP } from 'utils/subscriptionFeatures'; -const SUBSCRIPTION_SERVER = +export const SUBSCRIPTION_SERVER = process.env.NODE_ENV === 'development' ? 'http://localhost:8001' : 'https://books.rukn.sh'; @@ -15,7 +15,9 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } -async function getErrorMessageFromResponse(res: Response): Promise { +export async function getErrorMessageFromResponse( + res: Response +): Promise { try { const raw = await res.text(); const t = raw.trim(); diff --git a/package.json b/package.json index 4f3402d01..a9b02e468 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rukn-books", - "version": "0.48.2", + "version": "0.49.0", "description": "Simple book-keeping app for everyone", "author": { "name": "Rukn Software", diff --git a/src/pages/DatabaseSelector.vue b/src/pages/DatabaseSelector.vue index 9a750bf01..52a49939a 100644 --- a/src/pages/DatabaseSelector.vue +++ b/src/pages/DatabaseSelector.vue @@ -26,17 +26,19 @@

{{ - t`Create a new company or select an existing one from your computer` + canShowDemo && filesLoaded && !files?.length + ? t`Use Create Demo below to start with sample data. Saved companies appear in the list.` + : t`Create a new company or select an existing one from your computer` }}


- +
+ +
+
+ +
+ + +
@@ -574,6 +620,15 @@