backbone_pos/application/service/
pos_recognition.rs1use rust_decimal::Decimal;
20use uuid::Uuid;
21
22use crate::infrastructure::persistence::NewReturnInvoiceRow;
23
24use super::pos_events::{PosEvent, PosInvoicePaid, PosInvoiceReturned};
25use super::pos_ports::{
26 BillingPort, CreditNoteRequest, InventoryPort, PartialCredit, PaymentPort, RefundRequest,
27 SaleInvoiceRequest, SaleLine, SettlementRequest, StockIssueLine, StockIssueRequest,
28};
29use super::pos_write_service::{legacy_company_echo, PosError, PosWriteService, RecognizeOutcome, ReturnOutcome};
30
31impl PosWriteService {
32 pub async fn recognize_sale(&self, pos_invoice_id: Uuid, billing: &dyn BillingPort, payment: &dyn PaymentPort, inventory: Option<&dyn InventoryPort>) -> Result<RecognizeOutcome, PosError> {
36 if let Some(o) = self.short_circuit_paid(pos_invoice_id).await? { return Ok(o); }
38 let inv = self.invoices
44 .fetch_for_recognition(&self.rpool(), pos_invoice_id).await?
45 .ok_or(PosError::InvoiceNotFound(pos_invoice_id))?;
46
47 let rounded_total = inv.rounded_total;
48 let paid_total = inv.paid_total;
49 if paid_total < rounded_total {
50 return Err(PosError::NotFullyTendered { rounded_total, paid_total });
51 }
52 let tax_total = inv.tax_total;
53 let tax_account_id = inv.tax_account_id;
54 let tax_rate = inv.tax_rate;
55 if tax_total > Decimal::ZERO && tax_account_id.is_none() {
57 return Err(PosError::MissingAccount("tax_account_id (register charges PPN but has no tax account)"));
58 }
59 let company_echo = legacy_company_echo();
60 let currency = inv.currency.clone();
61 let receivable: Uuid = inv.receivable_account_id.ok_or(PosError::MissingAccount("receivable_account_id"))?;
62 let income: Option<Uuid> = inv.income_account_id;
63 let cash: Uuid = inv.cash_account_id.ok_or(PosError::MissingAccount("cash_account_id"))?;
64 let customer: Uuid = inv.customer_id
65 .or(inv.default_customer_id)
66 .ok_or(PosError::MissingAccount("customer_id (no ticket or profile default)"))?;
67
68 let posting_at = inv.posting_at;
69 let posting_date = posting_at.date_naive();
70
71 let existing_billing: Option<Uuid> = inv.billing_invoice_id;
77 let invoice_id = if let Some(id) = existing_billing {
78 id
79 } else {
80 let item_rows = self.items.fetch_revenue_lines(&self.rpool(), pos_invoice_id).await?;
82 let mut lines = Vec::with_capacity(item_rows.len());
83 for r in &item_rows {
84 let rev = r.revenue_account_id.or(income).ok_or(PosError::MissingAccount("revenue account (line or profile income)"))?;
85 lines.push(SaleLine { item_id: r.item_id, revenue_account_id: rev, quantity: Decimal::ONE, unit_price: r.net_amount });
87 }
88 let ack = billing.raise_and_post(&SaleInvoiceRequest {
89 customer_id: customer, currency: currency.clone(), source_pos_id: pos_invoice_id,
90 receivable_account_id: receivable, posting_date, lines, tax_total, tax_account_id, tax_rate,
91 }).await.map_err(|e| PosError::BillingRejected { code: e.code, message: e.message })?;
92 self.invoices.link_billing_invoice(&self.rpool(), pos_invoice_id, ack.invoice_id).await?;
94 ack.invoice_id
95 };
96
97 let sack = payment.settle(&SettlementRequest {
99 customer_id: customer, currency, invoice_ref: invoice_id,
100 bank_account_id: cash, party_account_id: receivable, posting_date, amount: rounded_total,
101 }).await.map_err(|e| PosError::PaymentRejected { code: e.code, message: e.message })?;
102
103 let rows_affected = self.invoices.mark_paid(&self.rpool(), pos_invoice_id, invoice_id, sack.payment_id).await?;
107 if rows_affected == 1 {
108 self.sink.publish(PosEvent::PosInvoicePaid(PosInvoicePaid {
109 pos_invoice_id, company_id: company_echo, grand_total: inv.grand_total, rounded_total,
110 billing_invoice_id: invoice_id, payment_id: sack.payment_id,
111 }));
112 if let (Some(inv_port), Some(warehouse), Some(cogs), Some(inv_acct)) = (
116 inventory,
117 inv.warehouse_id,
118 inv.cogs_account_id,
119 inv.inventory_account_id,
120 ) {
121 let qty_rows = self.items.fetch_quantity_lines(&self.rpool(), pos_invoice_id).await?;
122 let lines: Vec<StockIssueLine> = qty_rows.iter()
123 .map(|r| StockIssueLine { item_id: r.item_id, quantity: r.quantity })
124 .filter(|l| l.quantity > Decimal::ZERO)
125 .collect();
126 if !lines.is_empty() {
127 inv_port.issue(&StockIssueRequest {
128 company_id: company_echo, branch_id: inv.branch_id, customer_id: customer,
129 source_pos_id: pos_invoice_id, warehouse_id: warehouse, cogs_account_id: cogs,
130 inventory_account_id: inv_acct, posting_date, lines,
131 }).await.map_err(|e| PosError::InventoryRejected { code: e.code, message: e.message })?;
132 }
133 }
134 }
135 Ok(RecognizeOutcome { pos_invoice_id, billing_invoice_id: invoice_id, payment_id: sack.payment_id })
136 }
137
138 async fn short_circuit_paid(&self, pos_invoice_id: Uuid) -> Result<Option<RecognizeOutcome>, PosError> {
139 let row = self.invoices
140 .fetch_paid_state(&self.rpool(), pos_invoice_id).await?
141 .ok_or(PosError::InvoiceNotFound(pos_invoice_id))?;
142 if row.status == "paid" {
143 if let Some(bid) = row.billing_invoice_id {
144 let payment_id = row.payment_entry_id.unwrap_or(Uuid::nil());
147 return Ok(Some(RecognizeOutcome { pos_invoice_id, billing_invoice_id: bid, payment_id }));
148 }
149 }
150 Ok(None)
151 }
152
153 pub async fn return_sale(&self, original_pos_invoice_id: Uuid, billing: &dyn BillingPort, payment: &dyn PaymentPort, partial: Option<PartialCredit>) -> Result<ReturnOutcome, PosError> {
160 if partial.is_some() {
164 return Err(PosError::PartialReturnsNotImplemented);
165 }
166 let o = self.invoices
169 .fetch_return_source(&self.rpool(), original_pos_invoice_id).await?
170 .ok_or(PosError::InvoiceNotFound(original_pos_invoice_id))?;
171 let status = o.status.clone();
172 if status != "paid" && status != "returned" {
173 return Err(PosError::NotReturnable(status));
174 }
175 let billing_invoice_id: Uuid = o.billing_invoice_id.ok_or(PosError::NotReturnable("no billing invoice".into()))?;
176 let company_echo = legacy_company_echo();
177 let rounded_total = o.rounded_total;
178 let payment_id: Uuid = o.payment_entry_id.unwrap_or(Uuid::nil());
180
181 payment.refund(&RefundRequest { invoice_ref: billing_invoice_id, payment_id, amount: rounded_total })
183 .await.map_err(|e| PosError::PaymentRejected { code: e.code, message: e.message })?;
184 billing.credit_note(&CreditNoteRequest { invoice_ref: billing_invoice_id, partial: None })
185 .await.map_err(|e| PosError::BillingRejected { code: e.code, message: e.message })?;
186
187 let rows_affected = self.invoices.mark_returned(&self.rpool(), original_pos_invoice_id).await?;
189 let return_ticket_id = if rows_affected == 1 {
190 let rt = Uuid::new_v4();
191 self.invoices.insert_return_ticket(&self.rpool(), &NewReturnInvoiceRow {
192 id: rt,
193 pos_profile_id: o.pos_profile_id,
194 opening_entry_id: o.opening_entry_id,
195 branch_id: o.branch_id,
196 customer_id: o.customer_id,
197 receipt_number: format!("RET-{}", &original_pos_invoice_id.simple().to_string()[..12]),
198 posting_at: o.posting_at,
199 net_total: o.net_total,
200 tax_total: o.tax_total,
201 grand_total: o.grand_total,
202 rounded_total,
203 billing_invoice_id,
204 return_against: original_pos_invoice_id,
205 }).await?;
206 self.sink.publish(PosEvent::PosInvoiceReturned(PosInvoiceReturned {
207 pos_invoice_id: original_pos_invoice_id, return_ticket_id: rt, company_id: company_echo,
208 billing_invoice_id, amount: rounded_total,
209 }));
210 rt
211 } else {
212 self.invoices
214 .find_return_ticket(&self.rpool(), original_pos_invoice_id).await?
215 .unwrap_or(original_pos_invoice_id)
216 };
217 Ok(ReturnOutcome { pos_invoice_id: original_pos_invoice_id, return_ticket_id, billing_invoice_id })
218 }
219}