diff --git a/internal/app/app.go b/internal/app/app.go index 11093f0..f79dd26 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -399,6 +399,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor // Earn EnakPoint and EnakCoin when an order becomes fully paid (docs/prd-point-coin.md F3) earningProcessor := processor.NewEarningProcessor(repository.NewEarningRepository(a.db), loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager) orderProcessor.SetOrderPaidHook(earningProcessor) + orderProcessor.SetOrderRefundedHook(earningProcessor) return &processors{ userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo), diff --git a/internal/processor/earning_reversal.go b/internal/processor/earning_reversal.go new file mode 100644 index 0000000..e0dc1dd --- /dev/null +++ b/internal/processor/earning_reversal.go @@ -0,0 +1,167 @@ +package processor + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/repository" +) + +// ReversalOutcome is what a reversal took back, and what it could not. +type ReversalOutcome struct { + Points int64 + Coins int64 + PointShortfall int64 + CoinShortfall int64 +} + +// OnOrderRefunded is called after an order was voided or (partly) refunded and that +// has committed. It never fails the caller: a refund is never blocked by the loyalty +// balance (Q3), so errors are logged. +func (p *EarningProcessor) OnOrderRefunded(ctx context.Context, orderID uuid.UUID) { + defer func() { + if r := recover(); r != nil { + logger.NonContext.Error(fmt.Sprintf("Earning reversal for order %s panicked", orderID), fmt.Errorf("%v", r)) + } + }() + if _, err := p.ReverseForOrder(ctx, orderID); err != nil { + logger.NonContext.Error(fmt.Sprintf("Earning reversal for order %s failed", orderID), err) + } +} + +// ReverseForOrder takes back what an order earned, as far as it has been voided or +// refunded (docs/prd-point-coin.md F10): +// +// - void: everything the order earned; +// - refund: floor(earned × refunded / basis), never more than was earned, with the +// refunded amount being the order's cumulative refund. +// +// Only the part not asked back yet is taken, so calling it again, or after each of +// several partial refunds, never takes more than the order earned. It draws from the +// lots the EARN created first, then from the others in K9 order, and takes what the +// balance has when it is short, recording the rest as shortfall (Q3). +func (p *EarningProcessor) ReverseForOrder(ctx context.Context, orderID uuid.UUID) (*ReversalOutcome, error) { + order, err := p.orders.GetOrderForEarning(ctx, orderID) + if err != nil { + return nil, err + } + earns, err := p.orders.ListEarnTransactions(ctx, orderID) + if err != nil { + return nil, err + } + outcome := &ReversalOutcome{} + if len(earns) == 0 { + return outcome, nil + } + + err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { + for _, earn := range earns { + target := earningReversalTarget(order, earn) + requested, err := p.orders.ReversalRequested(ctx, earn.ID) + if err != nil { + return err + } + amount := target - requested + if amount <= 0 { + continue + } + lots, err := p.orders.LotIDsCreatedBy(ctx, earn.ID) + if err != nil { + return err + } + + reason := "REFUND" + if order.IsVoid { + reason = "VOID" + } + earnID := earn.ID + res, err := p.wallet.DebitUpTo(ctx, WalletDebitInput{ + WalletEntry: WalletEntry{ + CustomerID: earn.CustomerID, + Currency: earn.Currency, + Type: constants.WalletTxTypeEarnReversal, + Amount: amount, + ReferenceType: constants.WalletRefTypeOrder, + ReferenceID: order.ID, + ReversesTransactionID: &earnID, + OutletID: earn.OutletID, + Description: earningReversalDescription(order), + Metadata: entities.Metadata{ + "reason": reason, + "refund_amount": order.RefundAmount, + "target": target, + }, + // The target only grows with each refund, so each refund gets its own + // key while a retry of the same one replays. + IdempotencyKey: fmt.Sprintf("reverse:%s:%d", earn.ID, target), + }, + PreferredLotIDs: lots, + }) + if err != nil { + return fmt.Errorf("reversing %s: %w", earn.Currency, err) + } + + var taken int64 + if res.Transaction != nil { + taken = -res.Transaction.Amount + } else { + // Nothing to take: the ledger has no row to carry the shortfall. + logger.NonContext.WarnWithFields("Earning reversal found an empty balance; the whole amount is shortfall", map[string]interface{}{ + "order_id": order.ID.String(), "customer_id": earn.CustomerID.String(), + "currency": earn.Currency, "shortfall": res.Shortfall, + }, nil) + } + switch earn.Currency { + case constants.WalletCurrencyPoint: + outcome.Points += taken + outcome.PointShortfall += res.Shortfall + case constants.WalletCurrencyCoin: + outcome.Coins += taken + outcome.CoinShortfall += res.Shortfall + } + } + return nil + }) + if err != nil { + return nil, err + } + return outcome, nil +} + +// earningReversalTarget is how much of an EARN row should have been taken back in +// total, given the order's void flag and cumulative refund. It works in cents like +// CalculateEarning, with the basis frozen on the EARN row. +func earningReversalTarget(order *repository.EarningOrder, earn entities.WalletTransaction) int64 { + earned := earn.Amount + if order.IsVoid { + return earned + } + refundCents := toCents(order.RefundAmount) + if refundCents <= 0 { + return 0 + } + basis, _ := earn.Metadata["basis"].(float64) + basisCents := toCents(basis) + // A refund can include tax, which the basis does not, so it can reach past it. + if basisCents <= 0 || refundCents >= basisCents { + return earned + } + return earned * refundCents / basisCents +} + +func earningReversalDescription(order *repository.EarningOrder) string { + verb := "Refund" + if order.IsVoid { + verb = "Batal" + } + description := verb + " #" + order.OrderNumber + if order.OutletName != "" { + description += " di " + order.OutletName + } + return truncateRunes(description, walletDescriptionLimit) +} diff --git a/internal/processor/earning_reversal_db_test.go b/internal/processor/earning_reversal_db_test.go new file mode 100644 index 0000000..d17dd8e --- /dev/null +++ b/internal/processor/earning_reversal_db_test.go @@ -0,0 +1,206 @@ +package processor + +import ( + "context" + "os" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/repository" +) + +// Needs TEST_DATABASE_URL pointing at a migrated database; see +// internal/repository/wallet_repository_test.go. +func TestEarningReversal_AgainstPostgres(t *testing.T) { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + ctx := context.Background() + + org, user, outlet := uuid.New(), uuid.New(), uuid.New() + voider, refunder, spender, loyal := uuid.New(), uuid.New(), uuid.New(), uuid.New() + customers := []uuid.UUID{voider, refunder, spender, loyal} + exec := func(q string, args ...any) { + t.Helper() + require.NoError(t, db.Exec(q, args...).Error) + } + exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'reversal test', 'basic')`, org) + exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')`, user, org, user.String()+"@test") + exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org) + for _, c := range customers { + exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'c')`, c, org) + } + t.Cleanup(func() { + db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, customers) + db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ?`, customers) + db.Exec(`DELETE FROM wallet_transactions WHERE customer_id IN ?`, customers) + db.Exec(`DELETE FROM customer_wallets WHERE customer_id IN ?`, customers) + db.Exec(`DELETE FROM orders WHERE organization_id = ?`, org) + db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org) + db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, outlet) + db.Exec(`DELETE FROM customers WHERE id IN ?`, customers) + db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet) + db.Exec(`DELETE FROM users WHERE id = ?`, user) + db.Exec(`DELETE FROM organizations WHERE id = ?`, org) + }) + + txm := repository.NewTxManager(db) + settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm) + s, err := settings.Outlet(ctx, outlet) + require.NoError(t, err) + s.Point.Enabled, s.Coin.Enabled = true, true + _, err = settings.UpdateOutlet(ctx, org, outlet, user, *s) + require.NoError(t, err) + wallet := NewWalletProcessor(repository.NewWalletRepository(db)) + earning := NewEarningProcessor(repository.NewEarningRepository(db), settings, wallet, txm) + + // Each order is the PRD example: basis Rp 87.500, 875 EnakPoint and 3 EnakCoin. + paidOrder := func(customer uuid.UUID) uuid.UUID { + t.Helper() + id := uuid.New() + exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type, + subtotal, discount_amount, tax_amount, total_amount, payment_status) + VALUES (?, ?, ?, ?, ?, ?, 'dine_in', 97500, 10000, 8750, 96250, 'completed')`, + id, org, outlet, user, customer, "ORD-"+id.String()[:8]) + outcome, err := earning.EarnForOrder(ctx, id) + require.NoError(t, err) + require.Equal(t, int64(875), outcome.Points) + return id + } + balance := func(customer uuid.UUID) (points, coins int64) { + t.Helper() + var b struct{ PointBalance, CoinBalance int64 } + require.NoError(t, db.Raw(`SELECT point_balance, coin_balance FROM customer_wallets WHERE customer_id = ?`, customer).Scan(&b).Error) + return b.PointBalance, b.CoinBalance + } + reversed := func(order uuid.UUID, currency string) int64 { + t.Helper() + var total int64 + require.NoError(t, db.Raw(`SELECT COALESCE(SUM(-amount), 0) FROM wallet_transactions + WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = ?`, order, currency).Scan(&total).Error) + return total + } + + // Void: everything comes back. + voided := paidOrder(voider) + exec(`UPDATE orders SET is_void = true WHERE id = ?`, voided) + earning.OnOrderRefunded(ctx, voided) + assert.Equal(t, int64(875), reversed(voided, "POINT")) + assert.Equal(t, int64(3), reversed(voided, "COIN")) + p, c := balance(voider) + assert.Equal(t, [2]int64{0, 0}, [2]int64{p, c}) + + var rev struct { + ReversesTransactionID string + Description string + } + require.NoError(t, db.Raw(`SELECT reverses_transaction_id::text AS reverses_transaction_id, description FROM wallet_transactions + WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = 'POINT'`, voided).Scan(&rev).Error) + var earnID string + require.NoError(t, db.Raw(`SELECT id::text FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN' AND currency = 'POINT'`, voided).Scan(&earnID).Error) + assert.Equal(t, earnID, rev.ReversesTransactionID, "the reversal points at the EARN it reverses") + assert.Contains(t, rev.Description, "Batal #ORD-") + + // Repeating it takes nothing more. + earning.OnOrderRefunded(ctx, voided) + assert.Equal(t, int64(875), reversed(voided, "POINT")) + + // Partial refunds are proportional and add up to at most what was earned. + refunded := paidOrder(refunder) + for _, step := range []struct { + refundAmount float64 + wantTotal int64 + }{ + {20000, 200}, // floor(875 × 20000 / 87500) + {20000, 200}, // same refund again: nothing more + {50000, 500}, // cumulative refund grew + {96250, 875}, // the whole total, tax included, cannot exceed the earning + {96250, 875}, + } { + exec(`UPDATE orders SET refund_amount = ? WHERE id = ?`, step.refundAmount, refunded) + outcome, err := earning.ReverseForOrder(ctx, refunded) + require.NoError(t, err) + assert.Zero(t, outcome.PointShortfall) + assert.Equal(t, step.wantTotal, reversed(refunded, "POINT"), "after a refund of %.0f", step.refundAmount) + } + assert.Equal(t, int64(3), reversed(refunded, "COIN")) + + // Balance already spent: what is left is taken, the rest recorded as shortfall, + // and the refund is not blocked. + spent := paidOrder(spender) + committed(t, txm, func(ctx context.Context) (*WalletResult, error) { + return wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ + CustomerID: spender, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment, + Amount: 800, ReferenceType: constants.WalletRefTypePayment, ReferenceID: uuid.New(), OutletID: &outlet, + Description: "Bayar"}}) + }) + exec(`UPDATE orders SET is_void = true WHERE id = ?`, spent) + outcome, err := earning.ReverseForOrder(ctx, spent) + require.NoError(t, err) + assert.Equal(t, int64(75), outcome.Points) + assert.Equal(t, int64(800), outcome.PointShortfall) + p, _ = balance(spender) + assert.Equal(t, int64(0), p, "never negative") + var metadata string + require.NoError(t, db.Raw(`SELECT metadata::text FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = 'POINT'`, spent).Scan(&metadata).Error) + assert.Contains(t, metadata, `"shortfall": 800`) + assert.Contains(t, metadata, `"requested_amount": 875`) + // Asked back in full already, shortfall included: a repeat takes nothing. + outcome, err = earning.ReverseForOrder(ctx, spent) + require.NoError(t, err) + assert.Zero(t, outcome.Points) + + // The EARN's own lots are drawn first, even when an older lot would come first. + older := committed(t, txm, func(ctx context.Context) (*WalletResult, error) { + return wallet.Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{ + CustomerID: loyal, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration, + Amount: 500, ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"}}) + }) + loyalOrder := paidOrder(loyal) + exec(`UPDATE orders SET refund_amount = 20000 WHERE id = ?`, loyalOrder) + _, err = earning.ReverseForOrder(ctx, loyalOrder) + require.NoError(t, err) + var fromOlder int64 + require.NoError(t, db.Raw(`SELECT COALESCE(SUM(a.amount), 0) FROM wallet_lot_allocations a + JOIN wallet_transactions t ON t.id = a.transaction_id + WHERE t.reference_id = ? AND t.type = 'EARN_REVERSAL' AND a.lot_id = ?`, loyalOrder, older.Lots[0].ID).Scan(&fromOlder).Error) + assert.Zero(t, fromOlder, "the older lot is untouched") + assert.Equal(t, int64(200), reversed(loyalOrder, "POINT")) + + // An order that never earned has nothing to reverse. + outcome, err = earning.ReverseForOrder(ctx, uuid.New()) + assert.ErrorIs(t, err, repository.ErrEarningOrderNotFound) + assert.Nil(t, outcome) + + // Every reversal kept balances, ledger and lots in step (§7.5). + found, err := repository.NewWalletReconciliationRepository(db).FindDiscrepancies(ctx, 1000) + require.NoError(t, err) + for _, d := range found { + for _, c := range customers { + assert.NotEqual(t, c, d.CustomerID, "discrepancy %s for one of our customers", d.Check) + } + } +} + +// committed runs fn in a transaction that commits before it returns, for engine calls +// made directly from a test. +func committed[T any](t *testing.T, txm *repository.TxManager, fn func(ctx context.Context) (T, error)) T { + t.Helper() + var out T + require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { + var err error + out, err = fn(ctx) + return err + })) + return out +} diff --git a/internal/processor/earning_reversal_test.go b/internal/processor/earning_reversal_test.go new file mode 100644 index 0000000..760fae4 --- /dev/null +++ b/internal/processor/earning_reversal_test.go @@ -0,0 +1,25 @@ +package processor + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/repository" +) + +func TestEarningReversalTarget(t *testing.T) { + earn := entities.WalletTransaction{Amount: 875, Metadata: entities.Metadata{"basis": 87500.0}} + target := func(order repository.EarningOrder) int64 { return earningReversalTarget(&order, earn) } + + assert.Equal(t, int64(0), target(repository.EarningOrder{}), "nothing refunded, nothing to take") + assert.Equal(t, int64(875), target(repository.EarningOrder{IsVoid: true}), "void takes everything") + assert.Equal(t, int64(200), target(repository.EarningOrder{RefundAmount: 20000}), "floor(875 × 20000 / 87500)") + assert.Equal(t, int64(299), target(repository.EarningOrder{RefundAmount: 29999.99}), "floor, not round") + assert.Equal(t, int64(875), target(repository.EarningOrder{RefundAmount: 87500})) + assert.Equal(t, int64(875), target(repository.EarningOrder{RefundAmount: 96250}), "a refund including tax cannot take more than was earned") + + noBasis := entities.WalletTransaction{Amount: 10, Metadata: entities.Metadata{}} + assert.Equal(t, int64(10), earningReversalTarget(&repository.EarningOrder{RefundAmount: 1}, noBasis), "without a basis any refund takes it all") +} diff --git a/internal/processor/order_paid_hook_test.go b/internal/processor/order_paid_hook_test.go index b2ab4ba..964ca49 100644 --- a/internal/processor/order_paid_hook_test.go +++ b/internal/processor/order_paid_hook_test.go @@ -179,3 +179,68 @@ func TestOrderPaidHook_NoHookIsFine(t *testing.T) { _, err := p.UpdateOrder(context.Background(), orders.order.ID, &models.UpdateOrderRequest{}) assert.NoError(t, err) } + +func (r *hookOrderRepo) VoidOrderWithStatus(_ context.Context, _ uuid.UUID, status entities.OrderStatus, _ string, _ uuid.UUID) error { + r.order.Status, r.order.IsVoid = status, true + return nil +} + +func (r *hookOrderRepo) Update(_ context.Context, o *entities.Order) error { + r.order.RefundAmount = o.RefundAmount + return nil +} + +func (r *hookOrderRepo) RefundOrder(context.Context, uuid.UUID, string, uuid.UUID) error { + r.order.IsRefund = true + return nil +} + +func (r *hookPaymentRepo) RefundPayment(_ context.Context, id uuid.UUID, amount float64, _ string, _ uuid.UUID) error { + for _, p := range r.created { + if p.ID == id { + p.RefundAmount = amount + } + } + return nil +} + +type orderRefundedHookFake struct{ calls []uuid.UUID } + +func (h *orderRefundedHookFake) OnOrderRefunded(_ context.Context, orderID uuid.UUID) { + h.calls = append(h.calls, orderID) +} + +func TestOrderRefundedHook(t *testing.T) { + t.Run("void", func(t *testing.T) { + p, orders, _ := newHookedOrderProcessor(nil) + hook := &orderRefundedHookFake{} + p.SetOrderRefundedHook(hook) + require.NoError(t, p.VoidOrder(context.Background(), &models.VoidOrderRequest{OrderID: orders.order.ID, Type: "ALL", Reason: "salah input"}, uuid.New())) + assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls) + }) + + t.Run("refund order", func(t *testing.T) { + p, orders, _ := newHookedOrderProcessor(nil) + orders.order.PaymentStatus = entities.PaymentStatusCompleted + hook := &orderRefundedHookFake{} + p.SetOrderRefundedHook(hook) + amount := 25000.0 + require.NoError(t, p.RefundOrder(context.Background(), orders.order.ID, &models.RefundOrderRequest{RefundAmount: &amount}, uuid.New())) + assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls) + }) + + t.Run("refund payment", func(t *testing.T) { + p, orders, _ := newHookedOrderProcessor(nil) + payments := p.paymentRepo.(*hookPaymentRepo) + payment := &entities.Payment{ID: uuid.New(), OrderID: orders.order.ID, Amount: 50000, Status: entities.PaymentTransactionStatusCompleted} + payments.created = append(payments.created, payment) + hook := &orderRefundedHookFake{} + p.SetOrderRefundedHook(hook) + require.NoError(t, p.RefundPayment(context.Background(), payment.ID, 20000, "kembali", uuid.New())) + assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls) + + // A refund that fails does not run the hook. + assert.Error(t, p.RefundPayment(context.Background(), payment.ID, 60000, "terlalu besar", uuid.New())) + assert.Len(t, hook.calls, 1) + }) +} diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go index 6cf9eb0..2c6426c 100644 --- a/internal/processor/order_processor.go +++ b/internal/processor/order_processor.go @@ -110,6 +110,7 @@ type OrderProcessorImpl struct { inventoryMovementService InventoryMovementService productOutletPriceRepo repository.ProductOutletPriceRepository orderPaidHook OrderPaidHook + orderRefundedHook OrderRefundedHook } // OrderPaidHook is told when an order has just become fully paid and the payment has @@ -123,6 +124,27 @@ func (p *OrderProcessorImpl) SetOrderPaidHook(hook OrderPaidHook) { p.orderPaidHook = hook } +// OrderRefundedHook is told when an order has been voided or (partly) refunded and +// that has committed. EarningProcessor is one (docs/prd-point-coin.md F10). +type OrderRefundedHook interface { + OnOrderRefunded(ctx context.Context, orderID uuid.UUID) +} + +// SetOrderRefundedHook sets what runs when an order is voided or refunded. +func (p *OrderProcessorImpl) SetOrderRefundedHook(hook OrderRefundedHook) { + p.orderRefundedHook = hook +} + +// onOrderRefunded is called at the end of VoidOrder, RefundOrder and RefundPayment, +// after their writes have committed, with the same guarantees as onOrderPaid: it can +// never block or fail the void or refund. +func (p *OrderProcessorImpl) onOrderRefunded(ctx context.Context, orderID uuid.UUID) { + if p.orderRefundedHook == nil { + return + } + p.orderRefundedHook.OnOrderRefunded(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID) +} + // onOrderPaid is the single place every path that completes an order's payment goes // through: UpdateOrder, CreatePayment and both kinds of split bill. It must be called // after the payment has committed. The hook runs detached from the caller's @@ -717,6 +739,10 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde return fmt.Errorf("invalid void type: must be 'ALL' or 'ITEM'") } + // VoidOrder refuses fully paid orders today, so there is usually nothing to take + // back; this keeps a void correct if that rule changes. The order repository writes + // outside the caller's transaction, so the void is already committed here. + p.onOrderRefunded(ctx, req.OrderID) return nil } @@ -812,6 +838,7 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req } } + p.onOrderRefunded(ctx, id) return nil } @@ -869,7 +896,11 @@ func (p *OrderProcessorImpl) RefundPayment(ctx context.Context, paymentID uuid.U return fmt.Errorf("refund amount cannot exceed payment amount") } - return p.RefundPaymentWithInventoryMovement(ctx, paymentID, refundAmount, reason, refundedBy, payment) + if err := p.RefundPaymentWithInventoryMovement(ctx, paymentID, refundAmount, reason, refundedBy, payment); err != nil { + return err + } + p.onOrderRefunded(ctx, payment.OrderID) + return nil } func (p *OrderProcessorImpl) CreatePaymentWithInventoryMovement(ctx context.Context, req *models.CreatePaymentRequest, order *entities.Order, totalPaid float64) (*entities.Payment, error) { diff --git a/internal/repository/earning_repository.go b/internal/repository/earning_repository.go index f583c9d..517f3dc 100644 --- a/internal/repository/earning_repository.go +++ b/internal/repository/earning_repository.go @@ -28,6 +28,8 @@ type EarningOrder struct { DiscountAmount float64 PaymentStatus string IsVoid bool + // Cumulative rupiah refunded so far. + RefundAmount float64 // Nil when the order has no customer, or the customer row is gone. CustomerIsDefault *bool CustomerIsActive *bool @@ -52,6 +54,11 @@ type EarningRepository interface { ListPaidOrdersWithoutEarning(ctx context.Context, since time.Time, after *EarningCursor, limit int) ([]EarningCursor, error) // ListEarnTransactions returns the EARN rows written for an order. ListEarnTransactions(ctx context.Context, orderID uuid.UUID) ([]entities.WalletTransaction, error) + // ReversalRequested is how much has been asked back from an EARN row so far: what + // was taken plus any shortfall. + ReversalRequested(ctx context.Context, earnTransactionID uuid.UUID) (int64, error) + // LotIDsCreatedBy returns the lots a credit created. + LotIDsCreatedBy(ctx context.Context, transactionID uuid.UUID) ([]uuid.UUID, error) } type earningRepository struct { @@ -74,6 +81,7 @@ func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid DiscountAmount float64 PaymentStatus string IsVoid bool + RefundAmount float64 CustomerIsDefault *bool CustomerIsActive *bool } @@ -81,7 +89,7 @@ func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid SELECT o.id::text AS id, o.organization_id::text AS organization_id, o.outlet_id::text AS outlet_id, o.order_number, COALESCE(ou.name, '') AS outlet_name, o.customer_id::text AS customer_id, o.subtotal, COALESCE(o.discount_amount, 0) AS discount_amount, o.payment_status, - COALESCE(o.is_void, false) AS is_void, + COALESCE(o.is_void, false) AS is_void, COALESCE(o.refund_amount, 0) AS refund_amount, c.is_default AS customer_is_default, c.is_active AS customer_is_active FROM orders o LEFT JOIN outlets ou ON ou.id = o.outlet_id @@ -102,6 +110,7 @@ func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid DiscountAmount: row.DiscountAmount, PaymentStatus: row.PaymentStatus, IsVoid: row.IsVoid, + RefundAmount: row.RefundAmount, CustomerIsDefault: row.CustomerIsDefault, CustomerIsActive: row.CustomerIsActive, } @@ -191,3 +200,35 @@ func (r *earningRepository) ListEarnTransactions(ctx context.Context, orderID uu } return rows, nil } + +func (r *earningRepository) ReversalRequested(ctx context.Context, earnTransactionID uuid.UUID) (int64, error) { + var total int64 + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT COALESCE(SUM(COALESCE((metadata->>'requested_amount')::bigint, -amount)), 0) + FROM wallet_transactions + WHERE reverses_transaction_id = ? AND type = ?`, + earnTransactionID, constants.WalletTxTypeEarnReversal).Scan(&total).Error + if err != nil { + return 0, fmt.Errorf("failed to sum earning reversals: %w", err) + } + return total, nil +} + +func (r *earningRepository) LotIDsCreatedBy(ctx context.Context, transactionID uuid.UUID) ([]uuid.UUID, error) { + var ids []string + err := DBFromContext(ctx, r.db).WithContext(ctx). + Table("wallet_lots"). + Where("source_transaction_id = ?", transactionID). + Order("expires_at NULLS LAST, created_at, id"). + Pluck("id::text", &ids).Error + if err != nil { + return nil, fmt.Errorf("failed to list lots of a credit: %w", err) + } + out := make([]uuid.UUID, 0, len(ids)) + for _, s := range ids { + if id, err := uuid.Parse(s); err == nil { + out = append(out, id) + } + } + return out, nil +}