feat(payments): integrate VAT into gift cards, payment handlers, and till sales
Track voucher_type_at_purchase on gift card creation. Apply VAT at gift card purchase (SPV), at redemption (MPV). Use transaction-aware GetCardByIDQuerier for till saved card lookups. Apply VAT to all booking payments (split records, terminal, tip, checkout). Add TotalVATAmount and TotalNetAmount to PaymentSummary responses. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -354,11 +354,16 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var gc GiftCard
|
var gc GiftCard
|
||||||
var lastUsedAt sql.NullTime
|
var lastUsedAt sql.NullTime
|
||||||
|
var purchaseVoucherType string
|
||||||
|
_ = tx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType)
|
||||||
|
if purchaseVoucherType == "" {
|
||||||
|
purchaseVoucherType = "SPV"
|
||||||
|
}
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at)
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, voucher_type_at_purchase)
|
||||||
VALUES ($1, $1, $2, $3, NOW())
|
VALUES ($1, $1, $2, $3, NOW(), $4)
|
||||||
RETURNING id, total_funds_added, amount_remaining, created_by, created_at, is_inventory, last_used_at
|
RETURNING id, total_funds_added, amount_remaining, created_by, created_at, is_inventory, last_used_at
|
||||||
`, req.Amount, adminID, req.IsInventory).Scan(
|
`, req.Amount, adminID, req.IsInventory, purchaseVoucherType).Scan(
|
||||||
&gc.ID,
|
&gc.ID,
|
||||||
&gc.TotalFundsAdded,
|
&gc.TotalFundsAdded,
|
||||||
&gc.AmountRemaining,
|
&gc.AmountRemaining,
|
||||||
@@ -903,11 +908,16 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
var cardID string
|
var cardID string
|
||||||
|
|
||||||
if req.RecipientType == "self" {
|
if req.RecipientType == "self" {
|
||||||
|
var purchaseVoucherType string
|
||||||
|
_ = tx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType)
|
||||||
|
if purchaseVoucherType == "" {
|
||||||
|
purchaseVoucherType = "SPV"
|
||||||
|
}
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by, is_inventory)
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by, is_inventory, voucher_type_at_purchase)
|
||||||
VALUES ($1, 0, $2, NOW(), $2, FALSE)
|
VALUES ($1, 0, $2, NOW(), $2, FALSE, $3)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, amountPounds, userID).Scan(&cardID)
|
`, amountPounds, userID, purchaseVoucherType).Scan(&cardID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to insert gift card: %v", err)
|
log.Printf("Failed to insert gift card: %v", err)
|
||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
@@ -937,11 +947,16 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
var purchaseVoucherType string
|
||||||
|
_ = tx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType)
|
||||||
|
if purchaseVoucherType == "" {
|
||||||
|
purchaseVoucherType = "SPV"
|
||||||
|
}
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory)
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
|
||||||
VALUES ($1, $1, $2, FALSE)
|
VALUES ($1, $1, $2, FALSE, $3)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, amountPounds, userID).Scan(&cardID)
|
`, amountPounds, userID, purchaseVoucherType).Scan(&cardID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to insert gift card: %v", err)
|
log.Printf("Failed to insert gift card: %v", err)
|
||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
@@ -982,16 +997,25 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
CreatedBy: &userID,
|
CreatedBy: &userID,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, `
|
var buyPaymentID string
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
INSERT INTO payments (payment_type, payment_method, status, amount, square_payment_id, idempotency_key, fees, user_saved_card_id, created_by, created_at, updated_at)
|
INSERT INTO payments (payment_type, payment_method, status, amount, square_payment_id, idempotency_key, fees, user_saved_card_id, created_by, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
`, record.PaymentType, record.PaymentMethod, record.Status, record.Amount, record.SquarePaymentID, record.IdempotencyKey, record.Fees, record.UserSavedCardID, record.CreatedBy, record.CreatedAt, record.UpdatedAt)
|
RETURNING id
|
||||||
|
`, record.PaymentType, record.PaymentMethod, record.Status, record.Amount, record.SquarePaymentID, record.IdempotencyKey, record.Fees, record.UserSavedCardID, record.CreatedBy, record.CreatedAt, record.UpdatedAt).Scan(&buyPaymentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to insert payment record: %v", err)
|
log.Printf("Failed to insert payment record: %v", err)
|
||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vatCfg, vatErr := GetVATConfig(ctx, tx)
|
||||||
|
if vatErr == nil && vatCfg.IsVATRegistered && vatCfg.VoucherType == "SPV" {
|
||||||
|
if _, vatExecErr := tx.Exec(ctx, "SELECT apply_vat_to_payment($1, $2)", buyPaymentID, vatCfg.DefaultVATRate); vatExecErr != nil {
|
||||||
|
log.Printf("Failed to apply VAT to buy gift card payment %s: %v", buyPaymentID, vatExecErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
log.Printf("Failed to commit buy transaction: %v", err)
|
log.Printf("Failed to commit buy transaction: %v", err)
|
||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ type PaymentSummaryResponse struct {
|
|||||||
PaidAmount int64 `json:"paid_amount"`
|
PaidAmount int64 `json:"paid_amount"`
|
||||||
RefundedAmount int64 `json:"refunded_amount"`
|
RefundedAmount int64 `json:"refunded_amount"`
|
||||||
RemainingAmount int64 `json:"remaining_amount"`
|
RemainingAmount int64 `json:"remaining_amount"`
|
||||||
|
TotalVATAmount int64 `json:"total_vat_amount"`
|
||||||
|
TotalNetAmount int64 `json:"total_net_amount"`
|
||||||
Payments []PaymentResponse `json:"payments"`
|
Payments []PaymentResponse `json:"payments"`
|
||||||
Refunds []RefundResponse `json:"refunds"`
|
Refunds []RefundResponse `json:"refunds"`
|
||||||
}
|
}
|
||||||
@@ -383,6 +385,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ApplyVATToBookingPayment(r.Context(), tx, paymentID)
|
||||||
} else { // giftcard
|
} else { // giftcard
|
||||||
var customerID sql.NullString
|
var customerID sql.NullString
|
||||||
err = tx.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&customerID)
|
err = tx.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&customerID)
|
||||||
@@ -416,6 +419,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cardVoucherType string // voucher_type_at_purchase from the gift card
|
||||||
if !usedBalance {
|
if !usedBalance {
|
||||||
// Try direct card redemption (for guests or users without a redeemed balance)
|
// Try direct card redemption (for guests or users without a redeemed balance)
|
||||||
if req.GiftCardID == nil || *req.GiftCardID == "" {
|
if req.GiftCardID == nil || *req.GiftCardID == "" {
|
||||||
@@ -426,7 +430,8 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var gcRemaining float64
|
var gcRemaining float64
|
||||||
var redeemedBy sql.NullString
|
var redeemedBy sql.NullString
|
||||||
err = tx.QueryRow(r.Context(), "SELECT amount_remaining, redeemed_by FROM gift_cards WHERE id = $1 FOR UPDATE", cleanCardID).Scan(&gcRemaining, &redeemedBy)
|
var vtp sql.NullString
|
||||||
|
err = tx.QueryRow(r.Context(), "SELECT amount_remaining, redeemed_by, voucher_type_at_purchase FROM gift_cards WHERE id = $1 FOR UPDATE", cleanCardID).Scan(&gcRemaining, &redeemedBy, &vtp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "Gift card not found", http.StatusNotFound)
|
http.Error(w, "Gift card not found", http.StatusNotFound)
|
||||||
@@ -447,6 +452,14 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record the voucher_type_at_purchase for later VAT decision.
|
||||||
|
// Legacy cards (created before this column existed) have NULL → default to SPV.
|
||||||
|
if vtp.Valid {
|
||||||
|
cardVoucherType = vtp.String
|
||||||
|
} else {
|
||||||
|
cardVoucherType = "SPV"
|
||||||
|
}
|
||||||
|
|
||||||
// Deduct directly from card remaining amount
|
// Deduct directly from card remaining amount
|
||||||
_, err = tx.Exec(r.Context(), "UPDATE gift_cards SET amount_remaining = amount_remaining - $1, last_used_at = NOW() WHERE id = $2", amountPounds, cleanCardID)
|
_, err = tx.Exec(r.Context(), "UPDATE gift_cards SET amount_remaining = amount_remaining - $1, last_used_at = NOW() WHERE id = $2", amountPounds, cleanCardID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -467,6 +480,21 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply VAT at redemption only if the gift card was purchased as MPV
|
||||||
|
// (VAT deferred to redemption). For SPV, VAT was already paid at sale.
|
||||||
|
// For account balance payments (usedBalance=true), VAT was already paid
|
||||||
|
// when the original card was purchased.
|
||||||
|
if usedBalance {
|
||||||
|
// VAT already paid at purchase time — nothing to do here.
|
||||||
|
} else if cardVoucherType == "MPV" {
|
||||||
|
vatCfg, vatErr := GetVATConfig(r.Context(), tx)
|
||||||
|
if vatErr == nil && vatCfg.IsVATRegistered {
|
||||||
|
if _, vatExecErr := tx.Exec(r.Context(), "SELECT apply_vat_to_payment($1, $2)", paymentID, vatCfg.DefaultVATRate); vatExecErr != nil {
|
||||||
|
log.Printf("Failed to apply VAT to giftcard payment %s: %v", paymentID, vatExecErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(r.Context()); err != nil {
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
@@ -574,12 +602,27 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
paymentID, err := service.CreatePaymentRecord(r.Context(), record, nil)
|
tx, err := db.Conn.Begin(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to begin transaction: %v", err)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
|
paymentID, err := service.CreatePaymentRecordTx(r.Context(), tx, record, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to create payment record: %v", err)
|
log.Printf("Failed to create payment record: %v", err)
|
||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ApplyVATToBookingPayment(r.Context(), tx, paymentID)
|
||||||
|
|
||||||
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
|
log.Printf("Failed to commit transaction: %v", err)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(PaymentStatusResponse{
|
json.NewEncoder(w).Encode(PaymentStatusResponse{
|
||||||
@@ -886,6 +929,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer tx.Rollback(r.Context())
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
var primaryPaymentID string
|
var primaryPaymentID string
|
||||||
|
var paymentIDs []string
|
||||||
for i, rec := range records {
|
for i, rec := range records {
|
||||||
pid, cErr := service.CreatePaymentRecordTx(r.Context(), tx, rec, nil)
|
pid, cErr := service.CreatePaymentRecordTx(r.Context(), tx, rec, nil)
|
||||||
if cErr != nil {
|
if cErr != nil {
|
||||||
@@ -893,11 +937,23 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
paymentIDs = append(paymentIDs, pid)
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
primaryPaymentID = pid
|
primaryPaymentID = pid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply VAT to all split records if the business is VAT-registered.
|
||||||
|
// Must be inside the transaction so VAT updates are atomic with inserts.
|
||||||
|
vatCfg, vatErr := GetVATConfig(r.Context(), tx)
|
||||||
|
if vatErr == nil && vatCfg.IsVATRegistered {
|
||||||
|
for _, pid := range paymentIDs {
|
||||||
|
if _, execErr := tx.Exec(r.Context(), "SELECT apply_vat_to_payment($1, $2)", pid, vatCfg.DefaultVATRate); execErr != nil {
|
||||||
|
log.Printf("Failed to apply VAT to payment %s: %v", pid, execErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Promote deposit to confirmed if total paid meets the 20% threshold.
|
// Promote deposit to confirmed if total paid meets the 20% threshold.
|
||||||
// Check is inside the transaction so it sees the just-inserted payments.
|
// Check is inside the transaction so it sees the just-inserted payments.
|
||||||
var depositMet bool
|
var depositMet bool
|
||||||
@@ -1623,12 +1679,27 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
CreatedBy: &userID,
|
CreatedBy: &userID,
|
||||||
}
|
}
|
||||||
|
|
||||||
paymentID, err := service.CreatePaymentRecord(r.Context(), record, nil)
|
tx, err := db.Conn.Begin(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to begin transaction: %v", err)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
|
paymentID, err := service.CreatePaymentRecordTx(r.Context(), tx, record, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to create payment record: %v", err)
|
log.Printf("Failed to create payment record: %v", err)
|
||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ApplyVATToBookingPayment(r.Context(), tx, paymentID)
|
||||||
|
|
||||||
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
|
log.Printf("Failed to commit transaction: %v", err)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(PaymentResponse{
|
json.NewEncoder(w).Encode(PaymentResponse{
|
||||||
@@ -1712,6 +1783,8 @@ func GetBookingPaymentSummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
PaidAmount: int64(summary.PaidAmount * 100),
|
PaidAmount: int64(summary.PaidAmount * 100),
|
||||||
RefundedAmount: int64(summary.RefundedAmount * 100),
|
RefundedAmount: int64(summary.RefundedAmount * 100),
|
||||||
RemainingAmount: int64(summary.RemainingAmount * 100),
|
RemainingAmount: int64(summary.RemainingAmount * 100),
|
||||||
|
TotalVATAmount: int64(summary.TotalVATAmount * 100),
|
||||||
|
TotalNetAmount: int64(summary.TotalNetAmount * 100),
|
||||||
Payments: payments,
|
Payments: payments,
|
||||||
Refunds: refunds,
|
Refunds: refunds,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ type PaymentSummary struct {
|
|||||||
PaidAmount float64
|
PaidAmount float64
|
||||||
RefundedAmount float64
|
RefundedAmount float64
|
||||||
RemainingAmount float64
|
RemainingAmount float64
|
||||||
|
TotalVATAmount float64
|
||||||
|
TotalNetAmount float64
|
||||||
Payments []PaymentRecord
|
Payments []PaymentRecord
|
||||||
Refunds []RefundRecord
|
Refunds []RefundRecord
|
||||||
}
|
}
|
||||||
@@ -201,7 +203,7 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var paidAmount float64
|
var paidAmount, totalVATAmount, totalNetAmount float64
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p PaymentRecord
|
var p PaymentRecord
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
@@ -216,9 +218,22 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
|
|||||||
summary.Payments = append(summary.Payments, p)
|
summary.Payments = append(summary.Payments, p)
|
||||||
if p.Status == "completed" {
|
if p.Status == "completed" {
|
||||||
paidAmount += p.Amount
|
paidAmount += p.Amount
|
||||||
|
if p.VATAmount != nil {
|
||||||
|
totalVATAmount += *p.VATAmount
|
||||||
|
}
|
||||||
|
if p.NetAmount != nil {
|
||||||
|
totalNetAmount += *p.NetAmount
|
||||||
|
} else if p.VATAmount == nil {
|
||||||
|
// Only fallback to gross amount if no VAT was applied at all.
|
||||||
|
// When VAT is present, net_amount is always set by apply_vat_to_payment,
|
||||||
|
// so this fallback only applies to non-VAT payments where net == gross.
|
||||||
|
totalNetAmount += p.Amount
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
summary.PaidAmount = paidAmount
|
summary.PaidAmount = paidAmount
|
||||||
|
summary.TotalVATAmount = totalVATAmount
|
||||||
|
summary.TotalNetAmount = totalNetAmount
|
||||||
|
|
||||||
refundRows, err := db.Conn.Query(ctx, `
|
refundRows, err := db.Conn.Query(ctx, `
|
||||||
SELECT id, payment_id, booking_id, amount, square_refund_id, status, reason, created_by, created_at
|
SELECT id, payment_id, booking_id, amount, square_refund_id, status, reason, created_by, created_at
|
||||||
@@ -371,12 +386,9 @@ func (s *PaymentService) GetBookingPaymentInfo(ctx context.Context, bookingID st
|
|||||||
var info BookingPaymentInfo
|
var info BookingPaymentInfo
|
||||||
err := db.Conn.QueryRow(ctx, `
|
err := db.Conn.QueryRow(ctx, `
|
||||||
SELECT b.start_time, b.status,
|
SELECT b.start_time, b.status,
|
||||||
COALESCE(bt.total_amount, 0),
|
COALESCE(b.total_amount, 0),
|
||||||
COALESCE(pt.total_paid, 0)
|
COALESCE(pt.total_paid, 0)
|
||||||
FROM bookings b
|
FROM bookings b
|
||||||
LEFT JOIN (
|
|
||||||
SELECT id, total_amount FROM bookings WHERE id = $1
|
|
||||||
) bt ON b.id = bt.id
|
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT booking_id, SUM(amount) AS total_paid
|
SELECT booking_id, SUM(amount) AS total_paid
|
||||||
FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') GROUP BY booking_id
|
FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') GROUP BY booking_id
|
||||||
@@ -526,8 +538,15 @@ func (s *PaymentService) SaveCardForUser(ctx context.Context, userID, squareCard
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *PaymentService) GetCardByID(ctx context.Context, cardID, userID string) (*SavedCard, error) {
|
func (s *PaymentService) GetCardByID(ctx context.Context, cardID, userID string) (*SavedCard, error) {
|
||||||
|
return s.GetCardByIDQuerier(ctx, db.Conn, cardID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCardByIDQuerier is identical to GetCardByID but accepts a db.Querier
|
||||||
|
// so the lookup can be performed inside a transaction. Callers inside an
|
||||||
|
// existing transaction should pass their tx variable instead of db.Conn.
|
||||||
|
func (s *PaymentService) GetCardByIDQuerier(ctx context.Context, q db.Querier, cardID, userID string) (*SavedCard, error) {
|
||||||
var c SavedCard
|
var c SavedCard
|
||||||
err := db.Conn.QueryRow(ctx, `
|
err := q.QueryRow(ctx, `
|
||||||
SELECT id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default
|
SELECT id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default
|
||||||
FROM user_saved_cards
|
FROM user_saved_cards
|
||||||
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
|
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
|
||||||
|
|||||||
@@ -120,11 +120,16 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var giftCardID string
|
var giftCardID string
|
||||||
if req.Action == "create" {
|
if req.Action == "create" {
|
||||||
|
var purchaseVoucherType string
|
||||||
|
_ = tx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType)
|
||||||
|
if purchaseVoucherType == "" {
|
||||||
|
purchaseVoucherType = "SPV"
|
||||||
|
}
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory)
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
|
||||||
VALUES ($1, $1, $2, FALSE)
|
VALUES ($1, $1, $2, FALSE, $3)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, req.Amount, adminID).Scan(&giftCardID)
|
`, req.Amount, adminID, purchaseVoucherType).Scan(&giftCardID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to create gift card: %v", err)
|
log.Printf("Failed to create gift card: %v", err)
|
||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
@@ -245,23 +250,23 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.IdempotencyKey == "" {
|
if req.IdempotencyKey == "" {
|
||||||
req.IdempotencyKey = "till-cash-" + giftCardID + "-" + time.Now().Format("20060102150405.000000")
|
req.IdempotencyKey = "till-cash-" + giftCardID + "-" + time.Now().Format("20060102150405.000000")
|
||||||
}
|
}
|
||||||
case "saved_card":
|
case "saved_card":
|
||||||
dbPaymentMethod = "online_square"
|
dbPaymentMethod = "online_square"
|
||||||
if req.UserID != nil && *req.UserID != "" {
|
if req.UserID != nil && *req.UserID != "" {
|
||||||
_, err = service.GetCardByID(ctx, *req.UserSavedCardID, *req.UserID)
|
_, err = service.GetCardByIDQuerier(ctx, tx, *req.UserSavedCardID, *req.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "Saved card not found", http.StatusNotFound)
|
http.Error(w, "Saved card not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Failed to verify saved card: %v", err)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("Failed to verify saved card: %v", err)
|
|
||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var sqCardID string
|
var sqCardID string
|
||||||
err = db.Conn.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
SELECT square_card_id
|
SELECT square_card_id
|
||||||
FROM user_saved_cards
|
FROM user_saved_cards
|
||||||
WHERE id = $1 AND deleted_at IS NULL
|
WHERE id = $1 AND deleted_at IS NULL
|
||||||
@@ -395,6 +400,15 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.PaymentMethod != "on_the_house" && saleStatus == "completed" {
|
||||||
|
vatCfg, vatErr := GetVATConfig(ctx, tx)
|
||||||
|
if vatErr == nil && vatCfg.IsVATRegistered && vatCfg.VoucherType == "SPV" {
|
||||||
|
if _, vatExecErr := tx.Exec(ctx, "SELECT apply_vat_to_till_sale($1, $2)", tillSaleID, vatCfg.DefaultVATRate); vatExecErr != nil {
|
||||||
|
log.Printf("Failed to apply VAT to till sale %s: %v", tillSaleID, vatExecErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
log.Printf("Failed to commit till sale transaction: %v", err)
|
log.Printf("Failed to commit till sale transaction: %v", err)
|
||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
@@ -458,7 +472,15 @@ func GetTillCheckoutStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if paymentResult.Status == "COMPLETED" {
|
if paymentResult.Status == "COMPLETED" {
|
||||||
_, err = db.Conn.Exec(r.Context(), `
|
tx, err := db.Conn.Begin(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to begin transaction: %v", err)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
|
_, err = tx.Exec(r.Context(), `
|
||||||
UPDATE till_sales
|
UPDATE till_sales
|
||||||
SET status = 'completed',
|
SET status = 'completed',
|
||||||
square_payment_id = $1,
|
square_payment_id = $1,
|
||||||
@@ -470,6 +492,13 @@ func GetTillCheckoutStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ApplyVATToTillSale(r.Context(), tx, tillSaleID)
|
||||||
|
|
||||||
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
|
log.Printf("Failed to commit transaction: %v", err)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(PaymentStatusResponse{
|
json.NewEncoder(w).Encode(PaymentStatusResponse{
|
||||||
|
|||||||
Reference in New Issue
Block a user