accounting-sync.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. /**
  2. * What every accounting connector shares.
  3. *
  4. * An invoice, a customer and a payment are loaded here in one shape, so a
  5. * ledger connector maps that shape to its vendor and never reads Prisma. The
  6. * loaders are scoped to the connection's organisation, and the writers only
  7. * ever touch what a pull brought back from the ledger: a payment recorded
  8. * there against an invoice this app issued.
  9. */
  10. import { db } from '@/lib/db'
  11. import { organizationTranslator } from '@/i18n/organization-translator'
  12. import type { Prisma } from '@/generated/prisma/client'
  13. import { effectiveInvoiceDate } from '@/lib/invoice-utils'
  14. import { richTextToPlain } from '@/lib/rich-text-plain'
  15. import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
  16. import { SHOP_FEE_PRICING } from '@/features/settings/Lib/shopFee'
  17. import { serviceUrl } from './calendar-sync'
  18. export const INVOICE_ENTITY = 'ServiceRecord'
  19. export const CUSTOMER_ENTITY = 'Customer'
  20. export const PAYMENT_ENTITY = 'Payment'
  21. export interface AccountingCustomer {
  22. id: string
  23. name: string
  24. email: string | null
  25. phone: string | null
  26. address: string | null
  27. company: string | null
  28. taxId: string | null
  29. taxExempt: boolean
  30. customerNumber: string | null
  31. }
  32. export interface AccountingLine {
  33. kind: 'labor' | 'part'
  34. description: string
  35. partNumber: string | null
  36. quantity: number
  37. unitPrice: number
  38. total: number
  39. /** Whether the invoice's tax was charged on the line; a ledger books it under a code that says so. */
  40. taxable: boolean
  41. /** The workshop's shop fee, a labor line of its own that a ledger may book to its own account. */
  42. shopFee?: boolean
  43. }
  44. export interface AccountingPayment {
  45. id: string
  46. serviceRecordId: string
  47. amount: number
  48. date: Date
  49. method: string
  50. note: string | null
  51. provider: string | null
  52. externalId: string | null
  53. }
  54. export interface AccountingInvoice {
  55. id: string
  56. vehicleId: string | null
  57. /** The job's own title, which a ledger can carry as the invoice's reference. */
  58. title?: string
  59. invoiceNumber: string | null
  60. status: string
  61. issuedAt: Date | null
  62. /** The date the sheet prints, which is the date the ledger should carry. */
  63. invoiceDate: Date
  64. /** When the work was done, for the service date on each line. */
  65. serviceDate: Date
  66. dueDate: Date | null
  67. mileage: number | null
  68. /** The notes to the customer as plain text, the editor's markup taken out. */
  69. notes: string | null
  70. subtotal: number
  71. /** The sum of the taxable lines, or null when every line is taxed. */
  72. taxableSubtotal: number | null
  73. discountType: string | null
  74. discountValue: number
  75. discountAmount: number
  76. taxRate: number
  77. taxAmount: number
  78. taxInclusive: boolean
  79. totalAmount: number
  80. manuallyPaid: boolean
  81. customer: AccountingCustomer | null
  82. vehicle: { year: number; make: string; model: string; licensePlate: string | null } | null
  83. lines: AccountingLine[]
  84. payments: AccountingPayment[]
  85. }
  86. const customerSelect = {
  87. id: true,
  88. name: true,
  89. email: true,
  90. phone: true,
  91. address: true,
  92. company: true,
  93. taxId: true,
  94. taxExempt: true,
  95. customerNumber: true,
  96. } as const
  97. export async function loadCustomerForAccounting(
  98. organizationId: string,
  99. customerId: string
  100. ): Promise<AccountingCustomer | null> {
  101. return db.customer.findFirst({
  102. where: { id: customerId, organizationId },
  103. select: customerSelect,
  104. })
  105. }
  106. /**
  107. * The invoice as the ledger needs it. The customer is the record's own or,
  108. * for a job on a vehicle, the vehicle's; a counter sale can have neither.
  109. */
  110. export async function loadInvoiceForAccounting(
  111. organizationId: string,
  112. serviceRecordId: string
  113. ): Promise<AccountingInvoice | null> {
  114. const r = await db.serviceRecord.findFirst({
  115. where: { id: serviceRecordId, organizationId },
  116. select: {
  117. id: true,
  118. vehicleId: true,
  119. title: true,
  120. invoiceNumber: true,
  121. status: true,
  122. issuedAt: true,
  123. sentAt: true,
  124. invoiceDate: true,
  125. startDateTime: true,
  126. serviceDate: true,
  127. invoiceDueDate: true,
  128. mileage: true,
  129. invoiceNotes: true,
  130. subtotal: true,
  131. taxableSubtotal: true,
  132. discountType: true,
  133. discountValue: true,
  134. discountAmount: true,
  135. taxRate: true,
  136. taxAmount: true,
  137. taxInclusive: true,
  138. totalAmount: true,
  139. cost: true,
  140. manuallyPaid: true,
  141. customer: { select: customerSelect },
  142. vehicle: {
  143. select: {
  144. year: true,
  145. make: true,
  146. model: true,
  147. licensePlate: true,
  148. customer: { select: customerSelect },
  149. },
  150. },
  151. laborItems: {
  152. select: {
  153. description: true,
  154. hours: true,
  155. rate: true,
  156. total: true,
  157. pricingType: true,
  158. taxable: true,
  159. },
  160. },
  161. partItems: {
  162. select: {
  163. partNumber: true,
  164. name: true,
  165. quantity: true,
  166. unitPrice: true,
  167. total: true,
  168. taxable: true,
  169. },
  170. },
  171. payments: {
  172. select: {
  173. id: true,
  174. serviceRecordId: true,
  175. amount: true,
  176. date: true,
  177. method: true,
  178. note: true,
  179. provider: true,
  180. externalId: true,
  181. },
  182. orderBy: { date: 'asc' },
  183. },
  184. },
  185. })
  186. if (!r) return null
  187. const lines: AccountingLine[] = [
  188. ...r.laborItems.map((l) => {
  189. const hourly = l.pricingType === 'hourly' && l.hours > 0
  190. return {
  191. kind: 'labor' as const,
  192. description: l.description,
  193. partNumber: null,
  194. quantity: hourly ? l.hours : 1,
  195. unitPrice: hourly ? l.rate : l.total,
  196. total: l.total,
  197. taxable: l.taxable,
  198. shopFee: l.pricingType === SHOP_FEE_PRICING,
  199. }
  200. }),
  201. ...r.partItems.map((p) => ({
  202. kind: 'part' as const,
  203. description: p.name,
  204. partNumber: p.partNumber,
  205. quantity: p.quantity > 0 ? p.quantity : 1,
  206. unitPrice: p.quantity > 0 ? p.unitPrice : p.total,
  207. total: p.total,
  208. taxable: p.taxable,
  209. })),
  210. ]
  211. return {
  212. id: r.id,
  213. vehicleId: r.vehicleId,
  214. title: r.title,
  215. invoiceNumber: r.invoiceNumber,
  216. status: r.status,
  217. issuedAt: issuedAtOf(r),
  218. invoiceDate: effectiveInvoiceDate(r),
  219. serviceDate: r.serviceDate,
  220. dueDate: r.invoiceDueDate,
  221. mileage: r.mileage,
  222. notes: richTextToPlain(r.invoiceNotes) || null,
  223. subtotal: r.subtotal,
  224. taxableSubtotal: r.taxableSubtotal,
  225. discountType: r.discountType,
  226. discountValue: r.discountValue,
  227. discountAmount: r.discountAmount,
  228. taxRate: r.taxRate,
  229. taxAmount: r.taxAmount,
  230. taxInclusive: r.taxInclusive,
  231. // Records from before itemised totals carry the amount in cost only.
  232. totalAmount: r.totalAmount > 0 ? r.totalAmount : r.cost,
  233. manuallyPaid: r.manuallyPaid,
  234. customer: r.customer ?? r.vehicle?.customer ?? null,
  235. vehicle: r.vehicle
  236. ? {
  237. year: r.vehicle.year,
  238. make: r.vehicle.make,
  239. model: r.vehicle.model,
  240. licensePlate: r.vehicle.licensePlate,
  241. }
  242. : null,
  243. lines,
  244. payments: r.payments,
  245. }
  246. }
  247. export function invoiceUrl(appUrl: string, invoice: Pick<AccountingInvoice, 'id' | 'vehicleId'>) {
  248. return serviceUrl(appUrl, invoice)
  249. }
  250. /**
  251. * The invoice as the customer received it, for a ledger that files the
  252. * document behind each sale. The same copy the share link serves, in the
  253. * language the workshop sends its customers.
  254. */
  255. export async function loadInvoicePdfForAccounting(
  256. organizationId: string,
  257. serviceRecordId: string
  258. ): Promise<{ buffer: Uint8Array; filename: string } | null> {
  259. const record = await db.serviceRecord.findFirst({
  260. where: { id: serviceRecordId, organizationId },
  261. select: { id: true },
  262. })
  263. if (!record) return null
  264. const { buildInvoicePdfBuffer } = await import('@/features/invoices/Pdf/buildInvoicePdfBuffer')
  265. return buildInvoicePdfBuffer(serviceRecordId, null)
  266. }
  267. export async function loadPaymentForAccounting(
  268. organizationId: string,
  269. paymentId: string
  270. ): Promise<AccountingPayment | null> {
  271. return db.payment.findFirst({
  272. where: { id: paymentId, serviceRecord: { organizationId } },
  273. select: {
  274. id: true,
  275. serviceRecordId: true,
  276. amount: true,
  277. date: true,
  278. method: true,
  279. note: true,
  280. provider: true,
  281. externalId: true,
  282. },
  283. })
  284. }
  285. /** ISO 4217 code the workshop prices in, or null when it never chose one. */
  286. export async function workshopCurrency(organizationId: string): Promise<string | null> {
  287. const row = await db.appSetting.findUnique({
  288. where: { organizationId_key: { organizationId, key: SETTING_KEYS.CURRENCY_CODE } },
  289. select: { value: true },
  290. })
  291. const code = row?.value?.trim().toUpperCase()
  292. return code && /^[A-Z]{3}$/.test(code) ? code : null
  293. }
  294. /**
  295. * The word in front of the vehicle on a ledger's invoice, in the language
  296. * the workshop's documents print in: "Vehicle", or "Vessel" for a workshop
  297. * that fixes boats. The same words the invoice PDF uses.
  298. */
  299. export async function workshopVehicleLabel(organizationId: string): Promise<string> {
  300. const row = await db.appSetting.findFirst({
  301. where: { organizationId, key: SETTING_KEYS.SERVICE_TYPE },
  302. select: { value: true },
  303. })
  304. const t = await organizationTranslator(organizationId, 'pdf')
  305. return t(row?.value === 'marine' ? 'invoice.vehicleMarine' : 'invoice.vehicle')
  306. }
  307. /** How the workshop writes mileage: km when it chose metric, miles otherwise (the default). */
  308. export async function workshopDistanceUnit(organizationId: string): Promise<'km' | 'mi'> {
  309. const row = await db.appSetting.findUnique({
  310. where: { organizationId_key: { organizationId, key: SETTING_KEYS.UNIT_SYSTEM } },
  311. select: { value: true },
  312. })
  313. return row?.value === 'metric' ? 'km' : 'mi'
  314. }
  315. export interface PulledPaymentInput {
  316. serviceRecordId: string
  317. amount: number
  318. date: Date
  319. /** One of the app's own methods: cash, card, transfer or other. */
  320. method: string
  321. provider: string
  322. externalId: string
  323. note: string | null
  324. }
  325. /**
  326. * A payment the ledger has and this app did not: recorded against the
  327. * invoice so the balance here matches the books. The provider and remote id
  328. * make it idempotent; a second pull of the same payment returns the row the
  329. * first one made. Money against an invoice issues it, the same as a payment
  330. * typed in by hand.
  331. */
  332. export async function recordPulledPayment(
  333. organizationId: string,
  334. input: PulledPaymentInput
  335. ): Promise<{ id: string; created: boolean } | null> {
  336. const record = await db.serviceRecord.findFirst({
  337. where: { id: input.serviceRecordId, organizationId },
  338. select: { id: true },
  339. })
  340. if (!record) return null
  341. const existing = await db.payment.findFirst({
  342. where: {
  343. serviceRecordId: input.serviceRecordId,
  344. provider: input.provider,
  345. externalId: input.externalId,
  346. },
  347. select: { id: true },
  348. })
  349. if (existing) return { id: existing.id, created: false }
  350. // Two pulls overlapping can both get past the lookup above; the write is
  351. // what books the payment once, and only the one that wrote issues the
  352. // invoice.
  353. const { recordVendorPayment } = await import('@/lib/payment-providers/record-payment')
  354. const recorded = await recordVendorPayment({
  355. serviceRecordId: input.serviceRecordId,
  356. amount: input.amount,
  357. date: input.date,
  358. method: input.method,
  359. note: input.note,
  360. provider: input.provider,
  361. externalId: input.externalId,
  362. })
  363. if (!recorded.created) return recorded
  364. const { issueInvoice } = await import('@/features/invoices/Lib/issueInvoice')
  365. await issueInvoice(input.serviceRecordId, organizationId, 'paid')
  366. return recorded
  367. }
  368. /** Undo a pulled payment the ledger has since deleted. Only rows a pull made are touched. */
  369. export async function removePulledPayment(
  370. organizationId: string,
  371. paymentId: string,
  372. provider: string
  373. ): Promise<boolean> {
  374. const r = await db.payment.deleteMany({
  375. where: { id: paymentId, provider, serviceRecord: { organizationId } },
  376. })
  377. return r.count > 0
  378. }
  379. /**
  380. * When the invoice reached the customer, which is when the ledger should have
  381. * it. Invoices issued since locking exists carry the stamp. Older ones were
  382. * sent, marked paid or paid before the stamp existed and never got one unless
  383. * the workshop locked them from invoice settings; for the ledger they count
  384. * as issued all the same, dated by the send or the first payment.
  385. */
  386. export function issuedAtOf(r: {
  387. issuedAt: Date | null
  388. sentAt: Date | null
  389. manuallyPaid: boolean
  390. payments: { date: Date }[]
  391. invoiceDate: Date | null
  392. serviceDate: Date
  393. }): Date | null {
  394. if (r.issuedAt) return r.issuedAt
  395. if (r.sentAt) return r.sentAt
  396. if (r.payments.length > 0) return r.payments[0].date
  397. if (r.manuallyPaid) return r.invoiceDate ?? r.serviceDate
  398. return null
  399. }
  400. /** Rows that count as issued for the ledger, mirroring issuedAtOf. */
  401. const ISSUED_WHERE: Prisma.ServiceRecordWhereInput = {
  402. OR: [
  403. { issuedAt: { not: null } },
  404. { sentAt: { not: null } },
  405. { manuallyPaid: true },
  406. { payments: { some: {} } },
  407. ],
  408. }
  409. /**
  410. * Every issued invoice, oldest first, for a fresh connection to push. The
  411. * date filter is coarse (the sheet's own date, or the service date without
  412. * one); the connector applies the workshop's start date exactly, in its
  413. * timezone, when each job runs.
  414. */
  415. export async function invoicesForBackfill(
  416. organizationId: string,
  417. since: Date | null,
  418. limit = 5000
  419. ): Promise<string[]> {
  420. const rows = await db.serviceRecord.findMany({
  421. where: {
  422. organizationId,
  423. invoiceNumber: { not: null },
  424. AND: [
  425. ISSUED_WHERE,
  426. ...(since
  427. ? [
  428. {
  429. OR: [
  430. { invoiceDate: { gte: since } },
  431. { invoiceDate: null, serviceDate: { gte: since } },
  432. ],
  433. },
  434. ]
  435. : []),
  436. ],
  437. },
  438. select: { id: true },
  439. orderBy: { createdAt: 'asc' },
  440. take: limit,
  441. })
  442. return rows.map((r) => r.id)
  443. }