1use anyhow::Result;
15use rust_decimal::Decimal;
16use sqlx::{PgPool, Row};
17use uuid::Uuid;
18
19use backbone_orm::org_scope;
20use backbone_orm::company_scope::{
25 fetch_one_row_scoped, fetch_one_scalar_scoped, fetch_optional_scalar_scoped,
26};
27
28use crate::domain::entity::PosInvoice;
29
30pub const TABLE_NAME: &str = "pos.pos_invoices";
32
33pub struct PosInvoiceRepository(
38 backbone_orm::GenericCrudRepository<PosInvoice, backbone_orm::SoftDelete>,
39);
40
41impl std::ops::Deref for PosInvoiceRepository {
42 type Target = backbone_orm::GenericCrudRepository<PosInvoice, backbone_orm::SoftDelete>;
43 fn deref(&self) -> &Self::Target { &self.0 }
44}
45
46impl PosInvoiceRepository {
47 pub fn new(pool: PgPool) -> Self {
49 Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
50 }
51}
52
53pub struct NewDraftInvoiceRow<'a> {
59 pub id: Uuid,
60 pub client_uuid: Option<Uuid>,
61 pub pos_profile_id: Uuid,
62 pub opening_entry_id: Uuid,
63 pub branch_id: Option<Uuid>,
64 pub customer_id: Option<Uuid>,
65 pub pos_table_id: Option<Uuid>,
66 pub receipt_number: &'a str,
67 pub posting_at: chrono::NaiveDateTime,
68 pub net_total: Decimal,
69 pub tax_total: Decimal,
70 pub grand_total: Decimal,
71 pub rounding_adjustment: Decimal,
72 pub rounded_total: Decimal,
73}
74
75pub struct SyncLookupRow {
79 pub id: Uuid,
80 pub status: String,
81 pub pos_profile_id: Uuid,
82 pub opening_entry_id: Uuid,
83 pub branch_id: Option<Uuid>,
84 pub customer_id: Option<Uuid>,
85 pub pos_table_id: Option<Uuid>,
87 pub is_return: bool,
88 pub return_against: Option<Uuid>,
90 pub net_total: Decimal,
91 pub tax_total: Decimal,
92 pub grand_total: Decimal,
93 pub rounding_adjustment: Decimal,
94 pub rounded_total: Decimal,
95 pub paid_total: Decimal,
96 pub change_due: Decimal,
97}
98
99pub struct NewReturnInvoiceRow {
103 pub id: Uuid,
104 pub pos_profile_id: Uuid,
105 pub opening_entry_id: Uuid,
106 pub branch_id: Option<Uuid>,
107 pub customer_id: Option<Uuid>,
108 pub receipt_number: String,
109 pub posting_at: chrono::DateTime<chrono::Utc>,
110 pub net_total: Decimal,
111 pub tax_total: Decimal,
112 pub grand_total: Decimal,
113 pub rounded_total: Decimal,
114 pub billing_invoice_id: Uuid,
115 pub return_against: Uuid,
116}
117
118pub struct TenderHeaderRow {
120 pub status: String,
121 pub rounded_total: Decimal,
122}
123
124pub struct PaidStateRow {
126 pub status: String,
127 pub billing_invoice_id: Option<Uuid>,
128 pub payment_entry_id: Option<Uuid>,
129}
130
131pub struct RecognitionRow {
134 pub customer_id: Option<Uuid>,
135 pub branch_id: Option<Uuid>,
136 pub posting_at: chrono::DateTime<chrono::Utc>,
137 pub rounded_total: Decimal,
138 pub grand_total: Decimal,
139 pub tax_total: Decimal,
140 pub paid_total: Decimal,
141 pub billing_invoice_id: Option<Uuid>,
142 pub receivable_account_id: Option<Uuid>,
143 pub income_account_id: Option<Uuid>,
144 pub cash_account_id: Option<Uuid>,
145 pub tax_account_id: Option<Uuid>,
146 pub tax_rate: Decimal,
147 pub warehouse_id: Option<Uuid>,
148 pub cogs_account_id: Option<Uuid>,
149 pub inventory_account_id: Option<Uuid>,
150 pub currency: String,
151 pub default_customer_id: Option<Uuid>,
152}
153
154pub struct ReturnSourceRow {
157 pub pos_profile_id: Uuid,
158 pub opening_entry_id: Uuid,
159 pub branch_id: Option<Uuid>,
160 pub customer_id: Option<Uuid>,
161 pub billing_invoice_id: Option<Uuid>,
162 pub payment_entry_id: Option<Uuid>,
163 pub rounded_total: Decimal,
164 pub net_total: Decimal,
165 pub tax_total: Decimal,
166 pub grand_total: Decimal,
167 pub posting_at: chrono::DateTime<chrono::Utc>,
168 pub status: String,
169}
170
171pub struct ReceiptHeaderRow {
176 pub receipt_number: String,
177 pub posting_at: chrono::DateTime<chrono::Utc>,
178 pub net_total: Decimal,
179 pub tax_total: Decimal,
180 pub rounding_adjustment: Decimal,
181 pub rounded_total: Decimal,
182 pub paid_total: Decimal,
183 pub change_due: Decimal,
184 pub status: String,
185 pub register_name: String,
186 pub currency: String,
187 pub tax_rate: Decimal,
188 pub is_invoiced: bool,
189}
190
191impl PosInvoiceRepository {
194 pub async fn insert_draft(
202 &self,
203 conn: &mut sqlx::PgConnection,
204 i: &NewDraftInvoiceRow<'_>,
205 ) -> Result<(), sqlx::Error> {
206 sqlx::query(
207 r#"INSERT INTO pos.pos_invoices
208 (id, client_uuid, pos_profile_id, opening_entry_id, branch_id, customer_id, pos_table_id, receipt_number,
209 posting_at, net_total, tax_total, grand_total, rounding_adjustment, rounded_total,
210 paid_total, change_due, is_return, status)
211 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,0,0,false,'draft'::pos_invoice_status)"#,
212 )
213 .bind(i.id).bind(i.client_uuid).bind(i.pos_profile_id).bind(i.opening_entry_id).bind(i.branch_id)
214 .bind(i.customer_id).bind(i.pos_table_id).bind(i.receipt_number).bind(i.posting_at).bind(i.net_total).bind(i.tax_total)
215 .bind(i.grand_total).bind(i.rounding_adjustment).bind(i.rounded_total)
216 .execute(conn)
217 .await?;
218 Ok(())
219 }
220
221 pub async fn find_by_client_uuid(
225 &self,
226 pool: &PgPool,
227 client_uuid: Uuid,
228 ) -> Result<Option<SyncLookupRow>, sqlx::Error> {
229 let row = org_scope::fetch_optional_row_scoped(
230 pool,
231 sqlx::query(
232 r#"SELECT id, status::text AS st, pos_profile_id, opening_entry_id, branch_id, customer_id,
233 pos_table_id, is_return, return_against, net_total, tax_total, grand_total, rounding_adjustment,
234 rounded_total, paid_total, change_due
235 FROM pos.pos_invoices WHERE client_uuid=$1 AND (metadata->>'deleted_at') IS NULL"#,
236 )
237 .bind(client_uuid),
238 )
239 .await?;
240 Ok(row.map(|r| SyncLookupRow {
241 id: r.get("id"), status: r.get("st"), pos_profile_id: r.get("pos_profile_id"),
242 opening_entry_id: r.get("opening_entry_id"), branch_id: r.get("branch_id"),
243 customer_id: r.get("customer_id"), pos_table_id: r.get("pos_table_id"), is_return: r.get("is_return"),
244 return_against: r.get("return_against"), net_total: r.get("net_total"),
245 tax_total: r.get("tax_total"), grand_total: r.get("grand_total"),
246 rounding_adjustment: r.get("rounding_adjustment"), rounded_total: r.get("rounded_total"),
247 paid_total: r.get("paid_total"), change_due: r.get("change_due"),
248 }))
249 }
250
251 pub async fn update_draft_from_sync(
258 &self,
259 conn: &mut sqlx::PgConnection,
260 pos_invoice_id: Uuid,
261 opening_entry_id: Uuid,
262 branch_id: Option<Uuid>,
263 customer_id: Option<Uuid>,
264 pos_table_id: Option<Uuid>,
265 net_total: Decimal,
266 tax_total: Decimal,
267 grand_total: Decimal,
268 rounding_adjustment: Decimal,
269 rounded_total: Decimal,
270 paid_total: Decimal,
271 change_due: Decimal,
272 ) -> Result<(), sqlx::Error> {
273 sqlx::query(
274 r#"UPDATE pos.pos_invoices SET
275 opening_entry_id=$2, branch_id=$3, customer_id=$4, pos_table_id=$5,
276 net_total=$6, tax_total=$7, grand_total=$8, rounding_adjustment=$9,
277 rounded_total=$10, paid_total=$11, change_due=$12
278 WHERE id=$1 AND status='draft'::pos_invoice_status"#,
279 )
280 .bind(pos_invoice_id).bind(opening_entry_id).bind(branch_id).bind(customer_id).bind(pos_table_id)
281 .bind(net_total).bind(tax_total).bind(grand_total).bind(rounding_adjustment)
282 .bind(rounded_total).bind(paid_total).bind(change_due)
283 .execute(conn)
284 .await?;
285 Ok(())
286 }
287
288 pub async fn find_draft_on_table(
295 &self,
296 conn: &mut sqlx::PgConnection,
297 pos_table_id: Uuid,
298 exclude_invoice_id: Option<Uuid>,
299 ) -> Result<Option<Uuid>, sqlx::Error> {
300 let row = sqlx::query(
301 r#"SELECT id FROM pos.pos_invoices
302 WHERE pos_table_id=$1 AND status='draft'::pos_invoice_status
303 AND (metadata->>'deleted_at') IS NULL AND ($2::uuid IS NULL OR id <> $2)
304 LIMIT 1"#,
305 )
306 .bind(pos_table_id)
307 .bind(exclude_invoice_id)
308 .fetch_optional(conn)
309 .await?;
310 Ok(row.map(|r| r.get("id")))
311 }
312
313 pub async fn find_draft_on_table_scoped(
318 &self,
319 pool: &PgPool,
320 pos_table_id: Uuid,
321 exclude_invoice_id: Option<Uuid>,
322 ) -> Result<Option<Uuid>, sqlx::Error> {
323 let row = org_scope::fetch_optional_row_scoped(
324 pool,
325 sqlx::query(
326 r#"SELECT id FROM pos.pos_invoices
327 WHERE pos_table_id=$1 AND status='draft'::pos_invoice_status
328 AND (metadata->>'deleted_at') IS NULL AND ($2::uuid IS NULL OR id <> $2)
329 LIMIT 1"#,
330 )
331 .bind(pos_table_id)
332 .bind(exclude_invoice_id),
333 )
334 .await?;
335 Ok(row.map(|r| r.get("id")))
336 }
337
338 pub async fn soft_delete_lines_for_ticket(
342 &self,
343 conn: &mut sqlx::PgConnection,
344 pos_invoice_id: Uuid,
345 now: chrono::DateTime<chrono::Utc>,
346 ) -> Result<(), sqlx::Error> {
347 sqlx::query(
348 r#"UPDATE pos.pos_invoice_items SET metadata = jsonb_set(metadata, '{deleted_at}', to_jsonb($2::timestamptz))
349 WHERE pos_invoice_id=$1 AND (metadata->>'deleted_at') IS NULL"#,
350 )
351 .bind(pos_invoice_id).bind(now)
352 .execute(conn)
353 .await?;
354 Ok(())
355 }
356
357 pub async fn count_draft_orders_for_session(
359 &self,
360 pool: &PgPool,
361 opening_entry_id: Uuid,
362 ) -> Result<i64, sqlx::Error> {
363 fetch_one_scalar_scoped(
364 pool,
365 sqlx::query_scalar(
366 "SELECT COUNT(*) FROM pos.pos_invoices WHERE opening_entry_id=$1 AND status='draft'::pos_invoice_status AND is_return=false AND (metadata->>'deleted_at') IS NULL",
367 )
368 .bind(opening_entry_id),
369 )
370 .await
371 }
372
373 pub async fn count_linked_unposted_for_session(
377 &self,
378 pool: &PgPool,
379 opening_entry_id: Uuid,
380 ) -> Result<i64, sqlx::Error> {
381 fetch_one_scalar_scoped(
382 pool,
383 sqlx::query_scalar(
384 "SELECT COUNT(*) FROM pos.pos_invoices WHERE opening_entry_id=$1 AND status='draft'::pos_invoice_status AND billing_invoice_id IS NOT NULL AND (metadata->>'deleted_at') IS NULL",
385 )
386 .bind(opening_entry_id),
387 )
388 .await
389 }
390
391 pub async fn fetch_tender_header(
397 &self,
398 conn: &mut sqlx::PgConnection,
399 pos_invoice_id: Uuid,
400 ) -> Result<Option<TenderHeaderRow>, sqlx::Error> {
401 let row = sqlx::query(
402 "SELECT status::text AS st, rounded_total FROM pos.pos_invoices WHERE id=$1 AND (metadata->>'deleted_at') IS NULL",
403 )
404 .bind(pos_invoice_id)
405 .fetch_optional(conn)
406 .await?;
407 Ok(row.map(|r| TenderHeaderRow {
408 status: r.get("st"), rounded_total: r.get("rounded_total"),
409 }))
410 }
411
412 pub async fn update_tender_totals(
415 &self,
416 conn: &mut sqlx::PgConnection,
417 pos_invoice_id: Uuid,
418 paid_total: Decimal,
419 change_due: Decimal,
420 ) -> Result<(), sqlx::Error> {
421 sqlx::query("UPDATE pos.pos_invoices SET paid_total=$2, change_due=$3 WHERE id=$1")
422 .bind(pos_invoice_id).bind(paid_total).bind(change_due)
423 .execute(conn)
424 .await?;
425 Ok(())
426 }
427
428 pub async fn fetch_paid_state(
431 &self,
432 pool: &PgPool,
433 pos_invoice_id: Uuid,
434 ) -> Result<Option<PaidStateRow>, sqlx::Error> {
435 let row = org_scope::fetch_optional_row_scoped(
436 pool,
437 sqlx::query(
438 "SELECT status::text AS st, billing_invoice_id, payment_entry_id FROM pos.pos_invoices WHERE id=$1 AND (metadata->>'deleted_at') IS NULL",
439 )
440 .bind(pos_invoice_id),
441 )
442 .await?;
443 Ok(row.map(|r| PaidStateRow {
444 status: r.get("st"), billing_invoice_id: r.get("billing_invoice_id"),
445 payment_entry_id: r.get("payment_entry_id"),
446 }))
447 }
448
449 pub async fn fetch_for_recognition(
458 &self,
459 pool: &PgPool,
460 pos_invoice_id: Uuid,
461 ) -> Result<Option<RecognitionRow>, sqlx::Error> {
462 let row = org_scope::fetch_optional_row_scoped(
463 pool,
464 sqlx::query(
465 r#"SELECT i.customer_id, i.pos_profile_id, i.branch_id, i.posting_at, i.rounded_total, i.grand_total,
466 i.tax_total, i.paid_total, i.billing_invoice_id,
467 p.receivable_account_id, p.income_account_id, p.cash_account_id, p.tax_account_id,
468 ROUND(COALESCE(i.tax_total / NULLIF(i.net_total, 0), 0), 6) AS tax_rate,
469 p.warehouse_id, p.cogs_account_id, p.inventory_account_id,
470 p.currency, p.default_customer_id
471 FROM pos.pos_invoices i JOIN pos.pos_profiles p ON p.id = i.pos_profile_id
472 WHERE i.id=$1 AND i.status='draft'::pos_invoice_status AND (i.metadata->>'deleted_at') IS NULL"#,
473 )
474 .bind(pos_invoice_id),
475 )
476 .await?;
477 Ok(row.map(|r| RecognitionRow {
478 customer_id: r.get("customer_id"), branch_id: r.get("branch_id"),
479 posting_at: r.get("posting_at"), rounded_total: r.get("rounded_total"), grand_total: r.get("grand_total"),
480 tax_total: r.get("tax_total"), paid_total: r.get("paid_total"),
481 billing_invoice_id: r.get("billing_invoice_id"),
482 receivable_account_id: r.get("receivable_account_id"), income_account_id: r.get("income_account_id"),
483 cash_account_id: r.get("cash_account_id"), tax_account_id: r.get("tax_account_id"),
484 tax_rate: r.get("tax_rate"), warehouse_id: r.get("warehouse_id"), cogs_account_id: r.get("cogs_account_id"),
485 inventory_account_id: r.get("inventory_account_id"), currency: r.get("currency"),
486 default_customer_id: r.get("default_customer_id"),
487 }))
488 }
489
490 pub async fn link_billing_invoice(
498 &self,
499 pool: &PgPool,
500 pos_invoice_id: Uuid,
501 billing_invoice_id: Uuid,
502 ) -> Result<(), sqlx::Error> {
503 org_scope::execute_scoped(
504 pool,
505 sqlx::query("UPDATE pos.pos_invoices SET billing_invoice_id=$2 WHERE id=$1 AND status='draft'::pos_invoice_status")
506 .bind(pos_invoice_id).bind(billing_invoice_id),
507 )
508 .await?;
509 Ok(())
510 }
511
512 pub async fn mark_paid(
520 &self,
521 pool: &PgPool,
522 pos_invoice_id: Uuid,
523 billing_invoice_id: Uuid,
524 payment_entry_id: Uuid,
525 ) -> Result<u64, sqlx::Error> {
526 let res = org_scope::execute_scoped(
527 pool,
528 sqlx::query(
529 "UPDATE pos.pos_invoices SET status='paid'::pos_invoice_status, billing_invoice_id=$2, payment_entry_id=$3 WHERE id=$1 AND status='draft'::pos_invoice_status",
530 )
531 .bind(pos_invoice_id).bind(billing_invoice_id).bind(payment_entry_id),
532 )
533 .await?;
534 Ok(res.rows_affected())
535 }
536
537 pub async fn fetch_return_source(
540 &self,
541 pool: &PgPool,
542 pos_invoice_id: Uuid,
543 ) -> Result<Option<ReturnSourceRow>, sqlx::Error> {
544 let row = org_scope::fetch_optional_row_scoped(
545 pool,
546 sqlx::query(
547 r#"SELECT pos_profile_id, opening_entry_id, branch_id, customer_id, billing_invoice_id,
548 payment_entry_id, rounded_total, net_total, tax_total, grand_total, posting_at, status::text AS st
549 FROM pos.pos_invoices WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
550 )
551 .bind(pos_invoice_id),
552 )
553 .await?;
554 Ok(row.map(|r| ReturnSourceRow {
555 pos_profile_id: r.get("pos_profile_id"),
556 opening_entry_id: r.get("opening_entry_id"), branch_id: r.get("branch_id"),
557 customer_id: r.get("customer_id"), billing_invoice_id: r.get("billing_invoice_id"),
558 payment_entry_id: r.get("payment_entry_id"), rounded_total: r.get("rounded_total"),
559 net_total: r.get("net_total"), tax_total: r.get("tax_total"), grand_total: r.get("grand_total"),
560 posting_at: r.get("posting_at"), status: r.get("st"),
561 }))
562 }
563
564 pub async fn mark_returned(
568 &self,
569 pool: &PgPool,
570 pos_invoice_id: Uuid,
571 ) -> Result<u64, sqlx::Error> {
572 let res = org_scope::execute_scoped(
573 pool,
574 sqlx::query("UPDATE pos.pos_invoices SET status='returned'::pos_invoice_status WHERE id=$1 AND status='paid'::pos_invoice_status")
575 .bind(pos_invoice_id),
576 )
577 .await?;
578 Ok(res.rows_affected())
579 }
580
581 pub async fn insert_return_ticket(
584 &self,
585 pool: &PgPool,
586 r: &NewReturnInvoiceRow,
587 ) -> Result<(), sqlx::Error> {
588 org_scope::execute_scoped(
589 pool,
590 sqlx::query(
591 r#"INSERT INTO pos.pos_invoices
592 (id, pos_profile_id, opening_entry_id, branch_id, customer_id, receipt_number,
593 posting_at, net_total, tax_total, grand_total, rounding_adjustment, rounded_total,
594 paid_total, change_due, billing_invoice_id, is_return, return_against, status)
595 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,0,$11,$11,0,$12,true,$13,'returned'::pos_invoice_status)"#,
596 )
597 .bind(r.id).bind(r.pos_profile_id).bind(r.opening_entry_id)
598 .bind(r.branch_id).bind(r.customer_id).bind(&r.receipt_number)
599 .bind(r.posting_at).bind(r.net_total).bind(r.tax_total).bind(r.grand_total).bind(r.rounded_total)
600 .bind(r.billing_invoice_id).bind(r.return_against),
601 )
602 .await?;
603 Ok(())
604 }
605
606 pub async fn find_return_ticket(
609 &self,
610 pool: &PgPool,
611 original_pos_invoice_id: Uuid,
612 ) -> Result<Option<Uuid>, sqlx::Error> {
613 fetch_optional_scalar_scoped(
614 pool,
615 sqlx::query_scalar(
616 "SELECT id FROM pos.pos_invoices WHERE return_against=$1 AND is_return=true AND (metadata->>'deleted_at') IS NULL LIMIT 1",
617 )
618 .bind(original_pos_invoice_id),
619 )
620 .await
621 }
622
623 pub async fn sum_change_due_for_session(
626 &self,
627 pool: &PgPool,
628 opening_entry_id: Uuid,
629 ) -> Result<Decimal, sqlx::Error> {
630 fetch_one_scalar_scoped(
631 pool,
632 sqlx::query_scalar(
633 "SELECT COALESCE(SUM(change_due),0) FROM pos.pos_invoices WHERE opening_entry_id=$1 AND status='paid'::pos_invoice_status AND (metadata->>'deleted_at') IS NULL",
634 )
635 .bind(opening_entry_id),
636 )
637 .await
638 }
639
640 pub async fn session_totals(
643 &self,
644 pool: &PgPool,
645 opening_entry_id: Uuid,
646 ) -> Result<(Decimal, i64), sqlx::Error> {
647 let row = fetch_one_row_scoped(
648 pool,
649 sqlx::query(
650 "SELECT COALESCE(SUM(rounded_total),0), COUNT(*) FROM pos.pos_invoices WHERE opening_entry_id=$1 AND status='paid'::pos_invoice_status AND (metadata->>'deleted_at') IS NULL",
651 )
652 .bind(opening_entry_id),
653 )
654 .await?;
655 Ok((row.get(0), row.get(1)))
656 }
657
658 pub async fn fetch_receipt_header(
661 &self,
662 pool: &PgPool,
663 pos_invoice_id: Uuid,
664 ) -> Result<Option<ReceiptHeaderRow>, sqlx::Error> {
665 let row = org_scope::fetch_optional_row_scoped(
666 pool,
667 sqlx::query(
668 r#"SELECT i.receipt_number, i.posting_at, i.net_total, i.tax_total, i.rounding_adjustment,
669 i.rounded_total, i.paid_total, i.change_due, i.status::text AS status,
670 p.name AS register_name, p.currency,
671 ROUND(COALESCE(i.tax_total / NULLIF(i.net_total, 0), 0), 6) AS tax_rate,
672 (i.billing_invoice_id IS NOT NULL) AS is_invoiced
673 FROM pos.pos_invoices i JOIN pos.pos_profiles p ON p.id = i.pos_profile_id
674 WHERE i.id=$1 AND (i.metadata->>'deleted_at') IS NULL"#,
675 )
676 .bind(pos_invoice_id),
677 )
678 .await?;
679 Ok(row.map(|r| ReceiptHeaderRow {
680 receipt_number: r.get("receipt_number"), posting_at: r.get("posting_at"),
681 net_total: r.get("net_total"), tax_total: r.get("tax_total"),
682 rounding_adjustment: r.get("rounding_adjustment"), rounded_total: r.get("rounded_total"),
683 paid_total: r.get("paid_total"), change_due: r.get("change_due"), status: r.get("status"),
684 register_name: r.get("register_name"), currency: r.get("currency"), tax_rate: r.get("tax_rate"),
685 is_invoiced: r.get("is_invoiced"),
686 }))
687 }
688}
689
690backbone_core::impl_crud_repository!(PosInvoiceRepository, PosInvoice, soft_delete);