Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/config/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,8 @@ export const ERROR_CODES = {
// Group Payment
GROUP_PAYMENT_MISSING_FIELDS: 'GROUP_PAYMENT_MISSING_FIELDS',
GROUP_PAYMENT_INVALID_AMOUNT: 'GROUP_PAYMENT_INVALID_AMOUNT',
GROUP_PAYMENT_OVERPAID: 'GROUP_PAYMENT_OVERPAID',
GROUP_PAYMENT_PERCENTAGE_OVER_100: 'GROUP_PAYMENT_PERCENTAGE_OVER_100',
GROUP_PAYMENT_NOT_FOUND: 'GROUP_PAYMENT_NOT_FOUND',
GROUP_PAYMENT_NOT_IN_GROUP: 'GROUP_PAYMENT_NOT_IN_GROUP',
GROUP_PAYMENT_FETCH_ERROR: 'GROUP_PAYMENT_FETCH_ERROR',
Expand Down
90 changes: 88 additions & 2 deletions backend/controllers/group/group-payment-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,16 @@ import { GroupPaymentRepository } from '../../repositories/group/group-payment-r
import { GroupRepository } from '../../repositories/group/group-repository'
import { PaymentCalculatorService } from '../../services/group/payment-calculator-service'
import { GroupHistoryService } from '../../services/group/group-history-service'
import {
checkPercentages,
PaymentRuleError,
reconcilePayment,
} from '../../services/group/payment-rules.js'
import {
CreateGroupPaymentDTO,
UpdateGroupPaymentDTO,
HistoryAction,
PaymentStatus,
} from '../../models/group/index'
import { ERROR_CODES, SUCCESS_CODES } from '../../config/error-codes.js'
import { logger } from '../../config/logger.js'
Expand All @@ -21,6 +27,12 @@ import {
validationError,
} from '../../validations/group/group-schemas.js'

// A broken payment rule answers 400 with its code; anything else is rethrown
function ruleError(res: Response, error: unknown): Response {
if (!(error instanceof PaymentRuleError)) throw error
return res.status(400).json({ success: false, error: error.code, code: error.code })
}

export class GroupPaymentController {
/**
* GET /api/groups/:id/payments
Expand Down Expand Up @@ -129,6 +141,25 @@ export class GroupPaymentController {
})
}

try {
if (paymentData.percentage != null) {
checkPercentages(
await GroupPaymentRepository.getByGroupId(groupId),
paymentData.percentage
)
}
Object.assign(
paymentData,
reconcilePayment({
amount: paymentData.amount,
amountPaid: paymentData.amount_paid ?? 0,
status: paymentData.status,
})
)
} catch (error) {
return ruleError(res, error)
}

const newPayment = await GroupPaymentRepository.create(paymentData)

await GroupHistoryService.logChange(
Expand Down Expand Up @@ -209,6 +240,32 @@ export class GroupPaymentController {
}
const updateData = parsed.data as UpdateGroupPaymentDTO

try {
if (updateData.percentage != null) {
const others = (await GroupPaymentRepository.getByGroupId(groupId)).filter(
(p) => p.id !== paymentId
)
checkPercentages(others, updateData.percentage)
}
if (
updateData.amount != null ||
updateData.amount_paid !== undefined ||
updateData.status !== undefined
) {
Object.assign(
updateData,
reconcilePayment({
amount: updateData.amount ?? oldPayment.amount,
amountPaid: updateData.amount_paid ?? oldPayment.amount_paid,
status: updateData.status,
current: oldPayment.status,
})
)
}
} catch (error) {
return ruleError(res, error)
}

const updated = await GroupPaymentRepository.update(paymentId, updateData)

if (!updated) {
Expand Down Expand Up @@ -295,7 +352,12 @@ export class GroupPaymentController {
})
}

const updated = await GroupPaymentRepository.updateStatus(paymentId, status)
// Marking it paid by hand fills in what was paid
const filled = status === PaymentStatus.PAID ? Number(oldPayment.amount) : null
const updated =
filled === null
? await GroupPaymentRepository.updateStatus(paymentId, status)
: await GroupPaymentRepository.update(paymentId, { status, amount_paid: filled })

if (!updated) {
return res.status(500).json({
Expand All @@ -316,6 +378,19 @@ export class GroupPaymentController {
status,
'Estado de pago actualizado'
)
if (filled !== null && Number(oldPayment.amount_paid) !== filled) {
await GroupHistoryService.logChange(
oldPayment.group_id,
userId,
HistoryAction.PAYMENT_UPDATED,
'group_payments',
paymentId,
'amount_paid',
oldPayment.amount_paid,
filled,
'Cantidad pagada completada al marcar el pago como pagado'
)
}

return res.status(200).json({
success: true,
Expand Down Expand Up @@ -382,7 +457,18 @@ export class GroupPaymentController {
})
}

const updated = await GroupPaymentRepository.updateAmountPaid(paymentId, amount_paid)
let next: ReturnType<typeof reconcilePayment>
try {
next = reconcilePayment({
amount: oldPayment.amount,
amountPaid: amount_paid,
current: oldPayment.status,
})
} catch (error) {
return ruleError(res, error)
}

const updated = await GroupPaymentRepository.update(paymentId, next)

if (!updated) {
return res.status(500).json({
Expand Down
37 changes: 8 additions & 29 deletions backend/repositories/group/group-payment-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,34 +159,6 @@ export class GroupPaymentRepository {
return result.affectedRows > 0
}

/**
* Actualizar cantidad pagada (pagos parciales)
*/
static async updateAmountPaid(id: number, amountPaid: number): Promise<boolean> {
const payment = await this.getById(id)

if (!payment) {
throw new Error('Pago no encontrado')
}

// Calcular nuevo estado automáticamente
let newStatus = PaymentStatus.PENDING
if (amountPaid >= payment.amount) {
newStatus = PaymentStatus.PAID
} else if (amountPaid > 0) {
newStatus = PaymentStatus.PARTIAL
}

const query = `
UPDATE group_payments
SET amount_paid = ?, status = ?, status_updated_at = NOW()
WHERE id = ?
`

const [result] = await db.query<ResultSetHeader>(query, [amountPaid, newStatus, id])
return result.affectedRows > 0
}

/**
* Eliminar pago
*/
Expand All @@ -203,7 +175,14 @@ export class GroupPaymentRepository {
static async recalculateAmounts(groupId: number, totalAmount: number): Promise<number> {
const query = `
UPDATE group_payments
SET amount = (? * percentage / 100)
SET amount = (? * percentage / 100),
-- The status follows the new amount (MySQL assigns left to right)
status = CASE
WHEN amount > 0 AND amount_paid >= amount THEN 'paid'
WHEN amount_paid > 0 THEN 'partial'
WHEN status IN ('paid', 'partial') THEN 'pending'
ELSE status
END
WHERE group_id = ? AND percentage IS NOT NULL
`

Expand Down
59 changes: 59 additions & 0 deletions backend/services/group/payment-rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// services/group/payment-rules.ts
// Coherence rules for a group payment, decided on 2026-10-11: what was paid
// never exceeds what is owed, marking a payment paid by hand fills in what was
// paid, the status follows the amounts, and the percentages of a group's
// payments add up to 100 at most. DECIMAL columns arrive as strings, hence
// the Number() calls.

import { PaymentStatus } from '../../models/group/index.js'
import { ERROR_CODES } from '../../config/error-codes.js'

/** A rule the payment breaks; `code` is the ERROR_CODES entry to answer with */
export class PaymentRuleError extends Error {
constructor(readonly code: string) {
super(code)
}
}

/** Status that follows from what is owed and what was paid; "requested" stays until money arrives */
export function statusFor(
amount: number,
amountPaid: number,
current?: PaymentStatus
): PaymentStatus {
if (amount > 0 && amountPaid >= amount) return PaymentStatus.PAID
if (amountPaid > 0) return PaymentStatus.PARTIAL
return current === PaymentStatus.REQUESTED ? PaymentStatus.REQUESTED : PaymentStatus.PENDING
}

/**
* amount_paid and status to store after a change. An explicit status is kept,
* except that "paid" fills amount_paid with the amount; without one, the
* status follows the amounts.
*/
export function reconcilePayment(input: {
amount: number | string
amountPaid: number | string
status?: PaymentStatus
current?: PaymentStatus
}): { amount_paid: number; status: PaymentStatus } {
const amount = Number(input.amount)
const given = Number(input.amountPaid)
if (given > amount) throw new PaymentRuleError(ERROR_CODES.GROUP_PAYMENT_OVERPAID)

const amountPaid = input.status === PaymentStatus.PAID ? amount : given
return {
amount_paid: amountPaid,
status: input.status ?? statusFor(amount, amountPaid, input.current),
}
}

/** Throws when the group's percentages would add up to more than 100 */
export function checkPercentages(
others: ReadonlyArray<{ percentage: number | string | null }>,
next: number | null | undefined
): void {
const total = others.reduce((sum, p) => sum + Number(p.percentage ?? 0), Number(next ?? 0))
// Two decimals in the column: allow the rounding of thirds and the like
if (total > 100.001) throw new PaymentRuleError(ERROR_CODES.GROUP_PAYMENT_PERCENTAGE_OVER_100)
}
Loading
Loading