diff --git a/modules/meteroid/crates/meteroid-store/src/repositories/credit_notes.rs b/modules/meteroid/crates/meteroid-store/src/repositories/credit_notes.rs index 692902026..0adaa3373 100644 --- a/modules/meteroid/crates/meteroid-store/src/repositories/credit_notes.rs +++ b/modules/meteroid/crates/meteroid-store/src/repositories/credit_notes.rs @@ -26,13 +26,15 @@ use std::collections::HashMap; pub use crate::domain::enums::CreditType; -/// Specifies a line item to credit. +/// Specifies a line item to credit. `unit_price`, when provided, must be +/// <= the original and replaces it for the credited amount calculation. #[derive(Debug, Clone)] pub enum CreditLineItem { - /// Credit `quantity × line.unit_price`. The line must have a unit_price. - Line { local_id: String, quantity: Decimal }, - /// Credit specific sub-lines; line subtotal is summed from their - /// `quantity × unit_price`. Sub-lines not listed are dropped from the credited line. + Line { + local_id: String, + quantity: Decimal, + unit_price: Option, + }, SubLines { local_id: String, sub_lines: Vec, @@ -53,6 +55,7 @@ impl CreditLineItem { pub struct CreditSubLineItem { pub local_id: String, pub quantity: Decimal, + pub unit_price: Option, } /// Parameters for creating a credit note via the public API @@ -230,7 +233,7 @@ fn negate_sub_lines( name: sl.name.clone(), total: -ov.new_total_cents, quantity: -ov.new_quantity, - unit_price: sl.unit_price, + unit_price: ov.new_unit_price, attributes: sl.attributes.clone(), }) }) @@ -246,12 +249,22 @@ struct ResolvedCredit { /// When set, replaces the line's sub_lines with these (already non-negated; will be negated). /// Each entry is the subline's new (quantity, total_cents) and the original subline to copy from. sub_lines_override: Option>, + /// For line-level partial credits (no sub_lines override): the qty and effective unit price + /// that produced `credit_subtotal`. Used so the credit note line reads as "qty × unit" on the PDF. + line_display: Option, +} + +#[derive(Debug, Clone)] +struct LineDisplay { + quantity: Decimal, + unit_price: Decimal, } #[derive(Debug, Clone)] struct SubLineOverride { original_local_id: String, new_quantity: Decimal, + new_unit_price: Decimal, new_total_cents: i64, } @@ -328,11 +341,13 @@ fn negate_line_items( // Total = taxable + tax (uses taxable to account for discounts) let credit_total = prorated_taxable + prorated_tax; - // For sub_lines override the parent line has no meaningful unit_price/quantity - // (the credited amount is the sum of negated sublines). Otherwise we collapse - // to "1 × credited_subtotal" so the PDF stays consistent. + // Sub-line override: parent line has no meaningful qty/unit (sum of sublines). + // Line display present: preserve qty × effective unit price. + // Fallback: collapse to 1 × credited_subtotal. let (credited_quantity, credited_unit_price) = if has_override { (None, None) + } else if let Some(d) = &r.line_display { + (Some(d.quantity), Some(-d.unit_price)) } else { ( Some(Decimal::ONE), @@ -789,6 +804,7 @@ pub(crate) async fn create_credit_note_tx( ResolvedCredit { credit_subtotal, sub_lines_override: None, + line_display: None, }, ); } @@ -853,7 +869,25 @@ pub(crate) async fn create_credit_note_tx( csl.local_id, csl.quantity, original_sl.quantity ))); } - let new_total = (original_sl.unit_price * csl.quantity) + let effective_unit_price = match csl.unit_price { + Some(up) => { + if up <= Decimal::ZERO { + bail!(StoreError::InvalidArgument(format!( + "Sub-line '{}' unit_price must be positive", + csl.local_id + ))); + } + if up > original_sl.unit_price { + bail!(StoreError::InvalidArgument(format!( + "Sub-line '{}' unit_price {} exceeds original {}", + csl.local_id, up, original_sl.unit_price + ))); + } + up + } + None => original_sl.unit_price, + }; + let new_total = (effective_unit_price * csl.quantity) .to_subunit_opt(precision) .ok_or_else(|| { Report::from(StoreError::InvalidArgument(format!( @@ -865,6 +899,7 @@ pub(crate) async fn create_credit_note_tx( overrides.push(SubLineOverride { original_local_id: csl.local_id.clone(), new_quantity: csl.quantity, + new_unit_price: effective_unit_price, new_total_cents: new_total, }); } @@ -883,21 +918,44 @@ pub(crate) async fn create_credit_note_tx( ResolvedCredit { credit_subtotal: Some(total_cents), sub_lines_override: Some(overrides), + line_display: None, } } - CreditLineItem::Line { quantity: qty, .. } => { + CreditLineItem::Line { + quantity: qty, + unit_price: override_unit_price, + .. + } => { if qty <= Decimal::ZERO { bail!(StoreError::InvalidArgument(format!( "Line item '{}' quantity must be positive", item.local_id ))); } - let unit_price = item.unit_price.ok_or_else(|| { + let original_unit_price = item.unit_price.ok_or_else(|| { Report::from(StoreError::InvalidArgument(format!( "Line item '{}' has no unit_price; provide sub_lines instead", item.local_id ))) })?; + let effective_unit_price = match override_unit_price { + Some(up) => { + if up <= Decimal::ZERO { + bail!(StoreError::InvalidArgument(format!( + "Line item '{}' unit_price must be positive", + item.local_id + ))); + } + if up > original_unit_price { + bail!(StoreError::InvalidArgument(format!( + "Line item '{}' unit_price {} exceeds original {}", + item.local_id, up, original_unit_price + ))); + } + up + } + None => original_unit_price, + }; let original_qty = item.quantity.unwrap_or(Decimal::ONE); if original_qty <= Decimal::ZERO { bail!(StoreError::InvalidArgument(format!( @@ -911,15 +969,14 @@ pub(crate) async fn create_credit_note_tx( item.local_id, qty, original_qty ))); } - let credit_cents = - (unit_price * qty) - .to_subunit_opt(precision) - .ok_or_else(|| { - Report::from(StoreError::InvalidArgument(format!( - "Line item '{}' credit amount overflow", - item.local_id - ))) - })?; + let credit_cents = (effective_unit_price * qty) + .to_subunit_opt(precision) + .ok_or_else(|| { + Report::from(StoreError::InvalidArgument(format!( + "Line item '{}' credit amount overflow", + item.local_id + ))) + })?; if credit_cents > remaining { bail!(StoreError::InvalidArgument(format!( "Credit amount {} for line item '{}' exceeds remaining {}", @@ -929,6 +986,10 @@ pub(crate) async fn create_credit_note_tx( ResolvedCredit { credit_subtotal: Some(credit_cents), sub_lines_override: None, + line_display: Some(LineDisplay { + quantity: qty, + unit_price: effective_unit_price, + }), } } }; @@ -1406,6 +1467,7 @@ mod tests { ResolvedCredit { credit_subtotal: *credit_subtotal, sub_lines_override: None, + line_display: None, }, ) }) diff --git a/modules/meteroid/proto/api/creditnotes/v1/models.proto b/modules/meteroid/proto/api/creditnotes/v1/models.proto index 2c9dd8051..80a4bb769 100644 --- a/modules/meteroid/proto/api/creditnotes/v1/models.proto +++ b/modules/meteroid/proto/api/creditnotes/v1/models.proto @@ -72,11 +72,17 @@ message CreditLineItem { // computed from the listed sublines (sub.unit_price * quantity, summed). // Sublines not listed (or with zero quantity) are excluded from the credit note. repeated CreditSubLineItem sub_lines = 3; + // Optional override unit price (decimal string). Must be <= the original + // line unit price. Applied only when sub_lines is empty. + optional string unit_price = 4; } message CreditSubLineItem { string sub_line_local_id = 1; string quantity = 2; // decimal string + // Optional override unit price (decimal string). Must be <= the original + // sub-line unit price. + optional string unit_price = 3; } message NewCreditNote { diff --git a/modules/meteroid/src/api/creditnotes/service.rs b/modules/meteroid/src/api/creditnotes/service.rs index 4c52bd048..ca98a2b19 100644 --- a/modules/meteroid/src/api/creditnotes/service.rs +++ b/modules/meteroid/src/api/creditnotes/service.rs @@ -219,22 +219,35 @@ impl CreditNotesService for CreditNoteServiceComponents { local_id ))); } - let sub_lines = - li.sub_lines - .iter() - .map(|sl| { - Ok::<_, CreditNoteApiError>(CreditSubLineItem { - local_id: sl.sub_line_local_id.clone(), - quantity: rust_decimal::Decimal::from_str(&sl.quantity) - .map_err(|e| { - CreditNoteApiError::InvalidArgument(format!( - "Invalid sub-line quantity: {}", - e - )) - })?, - }) + let sub_lines = li + .sub_lines + .iter() + .map(|sl| { + let quantity = + rust_decimal::Decimal::from_str(&sl.quantity).map_err(|e| { + CreditNoteApiError::InvalidArgument(format!( + "Invalid sub-line quantity: {}", + e + )) + })?; + let unit_price = sl + .unit_price + .as_ref() + .map(|s| rust_decimal::Decimal::from_str(s)) + .transpose() + .map_err(|e| { + CreditNoteApiError::InvalidArgument(format!( + "Invalid sub-line unit_price: {}", + e + )) + })?; + Ok::<_, CreditNoteApiError>(CreditSubLineItem { + local_id: sl.sub_line_local_id.clone(), + quantity, + unit_price, }) - .collect::, _>>()?; + }) + .collect::, _>>()?; Ok(CreditLineItem::SubLines { local_id, sub_lines, @@ -249,7 +262,22 @@ impl CreditNotesService for CreditNoteServiceComponents { let quantity = rust_decimal::Decimal::from_str(qty_str).map_err(|e| { CreditNoteApiError::InvalidArgument(format!("Invalid quantity: {}", e)) })?; - Ok(CreditLineItem::Line { local_id, quantity }) + let unit_price = li + .unit_price + .as_ref() + .map(|s| rust_decimal::Decimal::from_str(s)) + .transpose() + .map_err(|e| { + CreditNoteApiError::InvalidArgument(format!( + "Invalid unit_price: {}", + e + )) + })?; + Ok(CreditLineItem::Line { + local_id, + quantity, + unit_price, + }) } }) .collect::, _>>()?; diff --git a/modules/meteroid/tests/integration/test_credit_note.rs b/modules/meteroid/tests/integration/test_credit_note.rs index 40b0cf115..f51bfa1d2 100644 --- a/modules/meteroid/tests/integration/test_credit_note.rs +++ b/modules/meteroid/tests/integration/test_credit_note.rs @@ -223,10 +223,12 @@ async fn test_credit_note_partial_credits() { CreditLineItem::Line { local_id: line_ids[0].clone(), quantity: dec!(1), + unit_price: None, }, CreditLineItem::Line { local_id: line_ids[1].clone(), quantity: dec!(1), + unit_price: None, }, ], reason: Some("Partial refund - first batch".to_string()), @@ -282,10 +284,12 @@ async fn test_credit_note_partial_credits() { CreditLineItem::Line { local_id: line_ids[2].clone(), quantity: dec!(1), + unit_price: None, }, CreditLineItem::Line { local_id: line_ids[3].clone(), quantity: dec!(1), + unit_price: None, }, ], reason: Some("Partial refund - second batch".to_string()), @@ -453,6 +457,7 @@ async fn test_credit_note_partial_credits() { CreditLineItem::Line { local_id: line_ids[0].clone(), quantity: dec!(1), + unit_price: None, }, // Already credited ], reason: Some("Should fail - duplicate".to_string()), @@ -583,6 +588,7 @@ async fn test_credit_note_race_condition() { line_items: vec![CreditLineItem::Line { local_id: line_id, quantity: dec!(1), + unit_price: None, }], reason: Some("Concurrent 1".to_string()), memo: None, @@ -600,6 +606,7 @@ async fn test_credit_note_race_condition() { line_items: vec![CreditLineItem::Line { local_id: line_id_clone, quantity: dec!(1), + unit_price: None, }], reason: Some("Concurrent 2".to_string()), memo: None, @@ -1032,10 +1039,12 @@ async fn test_credit_note_partial_amounts() { CreditLineItem::Line { local_id: line_ids[0].clone(), quantity: dec!(0.5), + unit_price: None, }, // Half of subtotal (qty 0.5 × unit 10.00 = 500 cents) CreditLineItem::Line { local_id: line_ids[1].clone(), quantity: dec!(1), + unit_price: None, }, // Full ], reason: Some("Partial amount credit test".to_string()), @@ -1127,6 +1136,7 @@ async fn test_credit_note_partial_amounts() { CreditLineItem::Line { local_id: line_ids[2].clone(), quantity: dec!(100), + unit_price: None, }, // Exceeds original quantity (1) ], reason: Some("Should fail - exceeds subtotal".to_string()), @@ -1157,6 +1167,7 @@ async fn test_credit_note_partial_amounts() { line_items: vec![CreditLineItem::Line { local_id: line_ids[2].clone(), quantity: dec!(-1), + unit_price: None, }], reason: Some("Should fail - negative amount".to_string()), memo: None, @@ -1361,6 +1372,7 @@ async fn test_credit_note_debt_cancellation_partial_leaves_unpaid() { line_items: vec![CreditLineItem::Line { local_id: line_ids[0].clone(), quantity: dec!(1), + unit_price: None, }], reason: Some("Partial debt cancellation".to_string()), memo: None, @@ -1656,6 +1668,7 @@ async fn test_corrected_invoice_rejected_after_partial_debt_cancellation() { line_items: vec![CreditLineItem::Line { local_id: line_id, quantity: dec!(1), + unit_price: None, }], reason: None, memo: None, @@ -1792,6 +1805,7 @@ async fn test_cn_with_reissue_rejected_when_partial() { line_items: vec![CreditLineItem::Line { local_id: line_id, quantity: dec!(1), + unit_price: None, }], reason: None, memo: None, diff --git a/modules/web/web-app/features/invoices/CreateCreditNoteDialog.tsx b/modules/web/web-app/features/invoices/CreateCreditNoteDialog.tsx index 48a71bf2b..15a753069 100644 --- a/modules/web/web-app/features/invoices/CreateCreditNoteDialog.tsx +++ b/modules/web/web-app/features/invoices/CreateCreditNoteDialog.tsx @@ -52,6 +52,7 @@ interface SubLineSelection { quantity: string originalQuantity: string unitPrice: string + originalUnitPrice: string originalTotal: number } @@ -61,12 +62,50 @@ interface LineItemSelection { quantity: string originalQuantity: string unitPrice: string | undefined + originalUnitPrice: string | undefined maxAmount: number fullyCredited: boolean name: string subLines: SubLineSelection[] } +const BarePriceInput: React.FC<{ + currency: string + value: string + placeholder?: string + disabled?: boolean + onChange: (value: string) => void + className?: string + inputClassName?: string +}> = ({ currency, value, placeholder, disabled, onChange, className, inputClassName }) => { + const symbol = useMemo(() => { + const f = new Intl.NumberFormat('en-US', { style: 'currency', currency, minimumFractionDigits: 2 }) + return f.format(0).replace(/\d|\./g, '').trim() + }, [currency]) + return ( +
+ {symbol && ( +
+ {symbol} +
+ )} + onChange(e.target.value)} + className={`pl-6 pr-10 bg-input block w-full border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground disabled:opacity-50 ${inputClassName ?? ''}`} + /> +
+ {currency} +
+
+ ) +} + export const CreateCreditNoteDialog: React.FC = ({ open, onOpenChange, @@ -132,6 +171,7 @@ export const CreateCreditNoteDialog: React.FC = ({ quantity: item.quantity ?? '1', originalQuantity: item.quantity ?? '1', unitPrice: item.unitPrice, + originalUnitPrice: item.unitPrice, maxAmount: remaining, fullyCredited: remaining === 0, name: item.name, @@ -142,6 +182,7 @@ export const CreateCreditNoteDialog: React.FC = ({ quantity: sl.quantity, originalQuantity: sl.quantity, unitPrice: sl.unitPrice, + originalUnitPrice: sl.unitPrice, originalTotal: Math.abs(Number(sl.total)), })), } @@ -206,6 +247,31 @@ export const CreateCreditNoteDialog: React.FC = ({ ) } + const handleUnitPriceChange = (localId: string, value: string) => { + setLineItems(prev => + prev.map(item => (item.lineItemLocalId === localId ? { ...item, unitPrice: value } : item)) + ) + } + + const handleSubLineUnitPriceChange = ( + lineLocalId: string, + subLocalId: string, + value: string + ) => { + setLineItems(prev => + prev.map(item => + item.lineItemLocalId === lineLocalId + ? { + ...item, + subLines: item.subLines.map(sl => + sl.subLineLocalId === subLocalId ? { ...sl, unitPrice: value } : sl + ), + } + : item + ) + ) + } + const handleSubLineQuantityChange = (lineLocalId: string, subLocalId: string, value: string) => { setLineItems(prev => prev.map(item => @@ -257,15 +323,18 @@ export const CreateCreditNoteDialog: React.FC = ({ const total = item.subLines.reduce((sum, sl) => { if (!sl.included) return sum const q = parseDecimal(sl.quantity) - if (!q || q.lte(0)) return sum - return sum.plus(toSubunit(new Decimal(sl.unitPrice).times(q))) + const up = parseDecimal(sl.unitPrice) + if (!q || q.lte(0) || !up || up.lte(0)) return sum + return sum.plus(toSubunit(up.times(q))) }, new Decimal(0)) return total.toNumber() } - if (item.quantity.trim() === '') return item.maxAmount - const q = parseDecimal(item.quantity) - if (!q || q.lte(0) || !item.unitPrice) return 0 - return toSubunit(new Decimal(item.unitPrice).times(q)).toNumber() + if (!item.unitPrice) return 0 + const up = parseDecimal(item.unitPrice) + if (!up || up.lte(0)) return 0 + const q = item.quantity.trim() === '' ? parseDecimal(item.originalQuantity) : parseDecimal(item.quantity) + if (!q || q.lte(0)) return 0 + return toSubunit(up.times(q)).toNumber() } const handleSelectAll = () => { @@ -286,7 +355,8 @@ export const CreateCreditNoteDialog: React.FC = ({ let creditLineItems: Array<{ lineItemLocalId: string quantity?: string - subLines: Array<{ subLineLocalId: string; quantity: string }> + unitPrice?: string + subLines: Array<{ subLineLocalId: string; quantity: string; unitPrice?: string }> }> = [] if (scope === 'partial') { @@ -313,16 +383,46 @@ export const CreateCreditNoteDialog: React.FC = ({ ) return } - } else if (item.quantity.trim() !== '') { - const q = parseDecimal(item.quantity) - if (!q || q.lte(0)) { - toast.error(`"${item.name}": quantity must be a positive number`) - return + for (const sl of kept) { + const up = parseDecimal(sl.unitPrice) + if (!up || up.lte(0)) { + toast.error(`"${item.name} / ${sl.name}": unit price must be positive`) + return + } + const origUp = parseDecimal(sl.originalUnitPrice) + if (origUp && up.gt(origUp)) { + toast.error( + `"${item.name} / ${sl.name}": unit price exceeds original (${sl.originalUnitPrice})` + ) + return + } } - const maxQ = parseDecimal(item.originalQuantity) - if (maxQ && q.gt(maxQ)) { - toast.error(`"${item.name}": quantity exceeds original (${item.originalQuantity})`) - return + } else { + if (item.quantity.trim() !== '') { + const q = parseDecimal(item.quantity) + if (!q || q.lte(0)) { + toast.error(`"${item.name}": quantity must be a positive number`) + return + } + const maxQ = parseDecimal(item.originalQuantity) + if (maxQ && q.gt(maxQ)) { + toast.error(`"${item.name}": quantity exceeds original (${item.originalQuantity})`) + return + } + } + if (item.unitPrice !== undefined && item.unitPrice.trim() !== '') { + const up = parseDecimal(item.unitPrice) + if (!up || up.lte(0)) { + toast.error(`"${item.name}": unit price must be positive`) + return + } + const origUp = parseDecimal(item.originalUnitPrice ?? '') + if (origUp && up.gt(origUp)) { + toast.error( + `"${item.name}": unit price exceeds original (${item.originalUnitPrice})` + ) + return + } } } } @@ -338,6 +438,7 @@ export const CreateCreditNoteDialog: React.FC = ({ .map(sl => ({ subLineLocalId: sl.subLineLocalId, quantity: sl.quantity, + unitPrice: sl.unitPrice !== sl.originalUnitPrice ? sl.unitPrice : undefined, })) return { lineItemLocalId: item.lineItemLocalId, @@ -345,9 +446,14 @@ export const CreateCreditNoteDialog: React.FC = ({ subLines, } } + const unitPriceChanged = + item.unitPrice !== undefined && + item.unitPrice.trim() !== '' && + item.unitPrice !== item.originalUnitPrice return { lineItemLocalId: item.lineItemLocalId, quantity: item.quantity.trim() === '' ? undefined : item.quantity, + unitPrice: unitPriceChanged ? item.unitPrice : undefined, subLines: [], } }) @@ -542,26 +648,29 @@ export const CreateCreditNoteDialog: React.FC = ({ {item.selected && item.subLines.length === 0 && ( -
- +
handleQuantityChange(item.lineItemLocalId, e.target.value)} - className="w-32 h-8" + className="w-20 h-8" /> - {item.unitPrice && ( - - × {item.unitPrice} {invoice.currency} ={' '} - {formatCurrency(computeLineCreditSubunit(item), invoice.currency)} - - )} + × + handleUnitPriceChange(item.lineItemLocalId, v)} + className="w-36" + inputClassName="h-8 text-sm" + /> + + = {formatCurrency(computeLineCreditSubunit(item), invoice.currency)} +
)} @@ -597,11 +706,24 @@ export const CreateCreditNoteDialog: React.FC = ({ e.target.value ) } - className="w-24 h-7 text-xs" + className="w-20 h-7 text-xs" + /> + × + + handleSubLineUnitPriceChange( + item.lineItemLocalId, + sl.subLineLocalId, + v + ) + } + className="w-32" + inputClassName="h-7 text-xs" /> - - × {sl.unitPrice} {invoice.currency} -
))}