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> {
41 let mut guard = self.rpool().begin().await?;
42 sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))")
43 .bind(format!("pos.recognize:{pos_invoice_id}"))
44 .execute(&mut *guard)
45 .await?;
46 let out = self.recognize_sale_locked(pos_invoice_id, billing, payment, inventory).await;
47 guard.commit().await?;
49 out
50 }
51
52 async fn recognize_sale_locked(&self, pos_invoice_id: Uuid, billing: &dyn BillingPort, payment: &dyn PaymentPort, inventory: Option<&dyn InventoryPort>) -> Result<RecognizeOutcome, PosError> {
53 if let Some(o) = self.short_circuit_paid(pos_invoice_id).await? {
55 self.relieve_stock_once(pos_invoice_id, inventory).await?;
56 return Ok(o);
57 }
58 let inv = self.invoices
64 .fetch_for_recognition(&self.rpool(), pos_invoice_id).await?
65 .ok_or(PosError::InvoiceNotFound(pos_invoice_id))?;
66
67 let rounded_total = inv.rounded_total;
68 let paid_total = inv.paid_total;
69 if paid_total < rounded_total {
70 return Err(PosError::NotFullyTendered { rounded_total, paid_total });
71 }
72 let tax_total = inv.tax_total;
73 let tax_account_id = inv.tax_account_id;
74 let tax_rate = inv.tax_rate;
75 if tax_total > Decimal::ZERO && tax_account_id.is_none() {
77 return Err(PosError::MissingAccount("tax_account_id (register charges PPN but has no tax account)"));
78 }
79 let company_echo = legacy_company_echo();
80 let currency = inv.currency.clone();
81 let receivable: Uuid = inv.receivable_account_id.ok_or(PosError::MissingAccount("receivable_account_id"))?;
82 let income: Option<Uuid> = inv.income_account_id;
83 let cash: Uuid = inv.cash_account_id.ok_or(PosError::MissingAccount("cash_account_id"))?;
84 let customer: Uuid = inv.customer_id
85 .or(inv.default_customer_id)
86 .ok_or(PosError::MissingAccount("customer_id (no ticket or profile default)"))?;
87
88 let posting_at = inv.posting_at;
89 let posting_date = posting_at.date_naive();
90
91 let existing_billing: Option<Uuid> = inv.billing_invoice_id;
97 let invoice_id = if let Some(id) = existing_billing {
98 id
99 } else {
100 let item_rows = self.items.fetch_revenue_lines(&self.rpool(), pos_invoice_id).await?;
102 let mut lines = Vec::with_capacity(item_rows.len());
103 for r in &item_rows {
104 let rev = r.revenue_account_id.or(income).ok_or(PosError::MissingAccount("revenue account (line or profile income)"))?;
105 lines.push(SaleLine { item_id: r.item_id, revenue_account_id: rev, quantity: Decimal::ONE, unit_price: r.net_amount });
107 }
108 let ack = billing.raise_and_post(&SaleInvoiceRequest {
109 customer_id: customer, currency: currency.clone(), source_pos_id: pos_invoice_id,
110 receivable_account_id: receivable, posting_date, lines, tax_total, tax_account_id, tax_rate,
111 }).await.map_err(|e| PosError::BillingRejected { code: e.code, message: e.message })?;
112 if self.invoices.link_billing_invoice(&self.rpool(), pos_invoice_id, ack.invoice_id).await? != 1 {
116 return Err(PosError::NotDraft);
117 }
118 ack.invoice_id
119 };
120
121 let sack = payment.settle(&SettlementRequest {
123 customer_id: customer, currency, invoice_ref: invoice_id,
124 bank_account_id: cash, party_account_id: receivable, posting_date, amount: rounded_total,
125 }).await.map_err(|e| PosError::PaymentRejected { code: e.code, message: e.message })?;
126
127 let rows_affected = self.invoices.mark_paid(&self.rpool(), pos_invoice_id, invoice_id, sack.payment_id).await?;
131 if rows_affected == 1 {
132 self.sink.publish(PosEvent::PosInvoicePaid(PosInvoicePaid {
133 pos_invoice_id, company_id: company_echo, grand_total: inv.grand_total, rounded_total,
134 billing_invoice_id: invoice_id, payment_id: sack.payment_id,
135 }));
136 }
137 self.relieve_stock_once(pos_invoice_id, inventory).await?;
142 Ok(RecognizeOutcome { pos_invoice_id, billing_invoice_id: invoice_id, payment_id: sack.payment_id })
143 }
144
145 async fn relieve_stock_once(&self, pos_invoice_id: Uuid, inventory: Option<&dyn InventoryPort>) -> Result<(), PosError> {
150 let Some(inv_port) = inventory else { return Ok(()) };
151 let Some(t) = self.invoices.fetch_for_stock_relief(&self.rpool(), pos_invoice_id).await? else { return Ok(()) };
152 if t.stock_delivery_note_id.is_some() {
153 return Ok(());
154 }
155 let (Some(warehouse), Some(cogs), Some(inv_acct), Some(customer)) =
156 (t.warehouse_id, t.cogs_account_id, t.inventory_account_id, t.customer_id)
157 else {
158 return Ok(());
159 };
160 let qty_rows = self.items.fetch_quantity_lines(&self.rpool(), pos_invoice_id).await?;
161 let lines: Vec<StockIssueLine> = qty_rows.iter()
162 .map(|r| StockIssueLine { item_id: r.item_id, quantity: r.quantity })
163 .filter(|l| l.quantity > Decimal::ZERO)
164 .collect();
165 if lines.is_empty() {
166 return Ok(());
167 }
168 let ack = inv_port.issue(&StockIssueRequest {
169 company_id: legacy_company_echo(), branch_id: t.branch_id, customer_id: customer,
170 source_pos_id: pos_invoice_id, warehouse_id: warehouse, cogs_account_id: cogs,
171 inventory_account_id: inv_acct, posting_date: t.posting_at.date_naive(), lines,
172 }).await.map_err(|e| PosError::InventoryRejected { code: e.code, message: e.message })?;
173 self.invoices.link_stock_delivery_note(&self.rpool(), pos_invoice_id, ack.delivery_note_id).await?;
174 Ok(())
175 }
176
177 async fn short_circuit_paid(&self, pos_invoice_id: Uuid) -> Result<Option<RecognizeOutcome>, PosError> {
178 let row = self.invoices
179 .fetch_paid_state(&self.rpool(), pos_invoice_id).await?
180 .ok_or(PosError::InvoiceNotFound(pos_invoice_id))?;
181 if row.status == "paid" {
182 if let Some(bid) = row.billing_invoice_id {
183 let payment_id = row.payment_entry_id.unwrap_or(Uuid::nil());
186 return Ok(Some(RecognizeOutcome { pos_invoice_id, billing_invoice_id: bid, payment_id }));
187 }
188 }
189 Ok(None)
190 }
191
192 pub async fn return_sale(&self, original_pos_invoice_id: Uuid, billing: &dyn BillingPort, payment: &dyn PaymentPort, partial: Option<PartialCredit>) -> Result<ReturnOutcome, PosError> {
199 if partial.is_some() {
203 return Err(PosError::PartialReturnsNotImplemented);
204 }
205 let o = self.invoices
208 .fetch_return_source(&self.rpool(), original_pos_invoice_id).await?
209 .ok_or(PosError::InvoiceNotFound(original_pos_invoice_id))?;
210 let status = o.status.clone();
211 if status != "paid" && status != "returned" {
212 return Err(PosError::NotReturnable(status));
213 }
214 let billing_invoice_id: Uuid = o.billing_invoice_id.ok_or(PosError::NotReturnable("no billing invoice".into()))?;
215 let company_echo = legacy_company_echo();
216 let rounded_total = o.rounded_total;
217 let payment_id: Uuid = o.payment_entry_id.unwrap_or(Uuid::nil());
219
220 payment.refund(&RefundRequest { invoice_ref: billing_invoice_id, payment_id, amount: rounded_total })
222 .await.map_err(|e| PosError::PaymentRejected { code: e.code, message: e.message })?;
223 billing.credit_note(&CreditNoteRequest { invoice_ref: billing_invoice_id, partial: None })
224 .await.map_err(|e| PosError::BillingRejected { code: e.code, message: e.message })?;
225
226 let rows_affected = self.invoices.mark_returned(&self.rpool(), original_pos_invoice_id).await?;
228 let return_ticket_id = if rows_affected == 1 {
229 let rt = Uuid::new_v4();
230 self.invoices.insert_return_ticket(&self.rpool(), &NewReturnInvoiceRow {
231 id: rt,
232 pos_profile_id: o.pos_profile_id,
233 opening_entry_id: o.opening_entry_id,
234 branch_id: o.branch_id,
235 customer_id: o.customer_id,
236 receipt_number: format!("RET-{}", &original_pos_invoice_id.simple().to_string()[..12]),
237 posting_at: o.posting_at,
238 net_total: o.net_total,
239 tax_total: o.tax_total,
240 grand_total: o.grand_total,
241 rounded_total,
242 billing_invoice_id,
243 return_against: original_pos_invoice_id,
244 }).await?;
245 self.sink.publish(PosEvent::PosInvoiceReturned(PosInvoiceReturned {
246 pos_invoice_id: original_pos_invoice_id, return_ticket_id: rt, company_id: company_echo,
247 billing_invoice_id, amount: rounded_total,
248 }));
249 rt
250 } else {
251 self.invoices
253 .find_return_ticket(&self.rpool(), original_pos_invoice_id).await?
254 .unwrap_or(original_pos_invoice_id)
255 };
256 Ok(ReturnOutcome { pos_invoice_id: original_pos_invoice_id, return_ticket_id, billing_invoice_id })
257 }
258}