feat(backend): add referral discount preview and transactional campaign safety
Add referral discount to payment preview for users with unused referral discounts. - Check referral_discounts table for unused discounts in calculateDiscountPreview - Wrap campaign discount application in a proper database transaction - Apply eligibility guard for 2nd+ payments to prevent duplicate discount application Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -265,6 +265,28 @@ func calculateDiscountPreview(ctx context.Context, bookingID string, userID stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for referrer's unused referral discount
|
||||||
|
var rdID string
|
||||||
|
var rdPercent float64
|
||||||
|
if err := db.DB.QueryRow(ctx, `
|
||||||
|
SELECT id, discount_percent FROM referral_discounts
|
||||||
|
WHERE user_id = $1 AND used = FALSE
|
||||||
|
LIMIT 1
|
||||||
|
`, userID).Scan(&rdID, &rdPercent); err == nil && rdID != "" {
|
||||||
|
exists := 0
|
||||||
|
db.DB.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'referral' AND source_id = $2`, bookingID, rdID).Scan(&exists)
|
||||||
|
if exists == 0 {
|
||||||
|
amount := roundTo2(bookingTotal * rdPercent / 100)
|
||||||
|
resp.Discounts = append(resp.Discounts, DiscountPreview{
|
||||||
|
Source: "referral",
|
||||||
|
Name: "Referral Discount (10%)",
|
||||||
|
Percent: rdPercent,
|
||||||
|
Amount: amount,
|
||||||
|
})
|
||||||
|
discountTotal += amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resp.Eligible = len(resp.Discounts) > 0
|
resp.Eligible = len(resp.Discounts) > 0
|
||||||
resp.DiscountedTotal = roundTo2(bookingTotal - discountTotal)
|
resp.DiscountedTotal = roundTo2(bookingTotal - discountTotal)
|
||||||
return resp
|
return resp
|
||||||
@@ -954,8 +976,12 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, user
|
|||||||
SELECT COUNT(*) FROM payments
|
SELECT COUNT(*) FROM payments
|
||||||
WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house')
|
WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house')
|
||||||
`, bookingID).Scan(&existingPayment)
|
`, bookingID).Scan(&existingPayment)
|
||||||
// Only block if this is the 2nd+ payment — the first payment should still
|
// Only block if this is the 2nd+ real payment — the first payment should still
|
||||||
// trigger discount application. Subsequent payments should not add new discounts.
|
// trigger discount application (existingPayment counts already-completed payments).
|
||||||
|
// When called before payment commit (line 850), existingPayment=0 so discounts
|
||||||
|
// are applied. When called after commit (line 952), existingPayment=1 and the
|
||||||
|
// idempotency check handles it. At the 2nd+ payment attempt, this guard prevents
|
||||||
|
// applying any new discounts.
|
||||||
if existingPayment >= 2 {
|
if existingPayment >= 2 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -981,9 +1007,16 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, user
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tx, err := db.DB.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to begin discount application transaction: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
var campaignID string
|
var campaignID string
|
||||||
var campaignPercent float64
|
var campaignPercent float64
|
||||||
if err := db.DB.QueryRow(ctx, `
|
if err := tx.QueryRow(ctx, `
|
||||||
SELECT id, discount_percent FROM discount_campaigns
|
SELECT id, discount_percent FROM discount_campaigns
|
||||||
WHERE status = 'active' AND campaign_type = 'time_based'
|
WHERE status = 'active' AND campaign_type = 'time_based'
|
||||||
AND start_date <= NOW() AND end_date >= NOW()
|
AND start_date <= NOW() AND end_date >= NOW()
|
||||||
@@ -991,20 +1024,20 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, user
|
|||||||
ORDER BY discount_percent DESC LIMIT 1
|
ORDER BY discount_percent DESC LIMIT 1
|
||||||
`).Scan(&campaignID, &campaignPercent); err == nil && campaignID != "" {
|
`).Scan(&campaignID, &campaignPercent); err == nil && campaignID != "" {
|
||||||
var exists int
|
var exists int
|
||||||
db.DB.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, campaignID).Scan(&exists)
|
tx.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, campaignID).Scan(&exists)
|
||||||
if exists == 0 {
|
if exists == 0 {
|
||||||
discountAmount := roundTo2(bookingTotal * campaignPercent / 100)
|
discountAmount := roundTo2(bookingTotal * campaignPercent / 100)
|
||||||
if _, err := db.DB.Exec(ctx, `
|
if _, err := tx.Exec(ctx, `
|
||||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||||
VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6)
|
VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6)
|
||||||
`, bookingID, userID, campaignID, campaignPercent, bookingTotal, discountAmount); err != nil {
|
`, bookingID, userID, campaignID, campaignPercent, bookingTotal, discountAmount); err != nil {
|
||||||
log.Printf("Failed to insert time-based campaign discount: %v", err)
|
log.Printf("Failed to insert time-based campaign discount: %v", err)
|
||||||
} else {
|
} else {
|
||||||
db.DB.Exec(ctx, `
|
tx.Exec(ctx, `
|
||||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||||
`, bookingID, discountAmount, userID)
|
`, bookingID, discountAmount, userID)
|
||||||
db.DB.Exec(ctx, `
|
tx.Exec(ctx, `
|
||||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
||||||
`, campaignID)
|
`, campaignID)
|
||||||
}
|
}
|
||||||
@@ -1012,11 +1045,11 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, user
|
|||||||
}
|
}
|
||||||
|
|
||||||
var userBookingCount int
|
var userBookingCount int
|
||||||
db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&userBookingCount)
|
tx.QueryRow(ctx, `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&userBookingCount)
|
||||||
|
|
||||||
var milestoneCampaignID string
|
var milestoneCampaignID string
|
||||||
var milestonePercent float64
|
var milestonePercent float64
|
||||||
db.DB.QueryRow(ctx, `
|
tx.QueryRow(ctx, `
|
||||||
SELECT id, discount_percent FROM discount_campaigns
|
SELECT id, discount_percent FROM discount_campaigns
|
||||||
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count'
|
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count'
|
||||||
AND milestone_value = $1
|
AND milestone_value = $1
|
||||||
@@ -1025,20 +1058,20 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, user
|
|||||||
|
|
||||||
if milestoneCampaignID != "" {
|
if milestoneCampaignID != "" {
|
||||||
var exists int
|
var exists int
|
||||||
db.DB.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, milestoneCampaignID).Scan(&exists)
|
tx.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, milestoneCampaignID).Scan(&exists)
|
||||||
if exists == 0 {
|
if exists == 0 {
|
||||||
discountAmount := roundTo2(bookingTotal * milestonePercent / 100)
|
discountAmount := roundTo2(bookingTotal * milestonePercent / 100)
|
||||||
if _, err := db.DB.Exec(ctx, `
|
if _, err := tx.Exec(ctx, `
|
||||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||||
VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6)
|
VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6)
|
||||||
`, bookingID, userID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount); err != nil {
|
`, bookingID, userID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount); err != nil {
|
||||||
log.Printf("Failed to insert per-user milestone discount: %v", err)
|
log.Printf("Failed to insert per-user milestone discount: %v", err)
|
||||||
} else {
|
} else {
|
||||||
db.DB.Exec(ctx, `
|
tx.Exec(ctx, `
|
||||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||||
`, bookingID, discountAmount, userID)
|
`, bookingID, discountAmount, userID)
|
||||||
db.DB.Exec(ctx, `
|
tx.Exec(ctx, `
|
||||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
||||||
`, milestoneCampaignID)
|
`, milestoneCampaignID)
|
||||||
}
|
}
|
||||||
@@ -1046,9 +1079,9 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, user
|
|||||||
}
|
}
|
||||||
|
|
||||||
var firstVisitDate time.Time
|
var firstVisitDate time.Time
|
||||||
db.DB.QueryRow(ctx, `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&firstVisitDate)
|
tx.QueryRow(ctx, `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&firstVisitDate)
|
||||||
if !firstVisitDate.IsZero() {
|
if !firstVisitDate.IsZero() {
|
||||||
annRows, err := db.DB.Query(ctx, `
|
annRows, err := tx.Query(ctx, `
|
||||||
SELECT id, discount_percent, milestone_value, milestone_unit FROM discount_campaigns
|
SELECT id, discount_percent, milestone_value, milestone_unit FROM discount_campaigns
|
||||||
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'anniversary'
|
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'anniversary'
|
||||||
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id AND milestone_type = 'anniversary')
|
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id AND milestone_type = 'anniversary')
|
||||||
@@ -1071,7 +1104,7 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, user
|
|||||||
|
|
||||||
for _, c := range campaigns {
|
for _, c := range campaigns {
|
||||||
var exists int
|
var exists int
|
||||||
db.DB.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, c.id).Scan(&exists)
|
tx.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, c.id).Scan(&exists)
|
||||||
if exists > 0 {
|
if exists > 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1088,17 +1121,17 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, user
|
|||||||
}
|
}
|
||||||
if matches {
|
if matches {
|
||||||
discountAmount := roundTo2(bookingTotal * c.pct / 100)
|
discountAmount := roundTo2(bookingTotal * c.pct / 100)
|
||||||
if _, err := db.DB.Exec(ctx, `
|
if _, err := tx.Exec(ctx, `
|
||||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||||
VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6)
|
VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6)
|
||||||
`, bookingID, userID, c.id, c.pct, bookingTotal, discountAmount); err != nil {
|
`, bookingID, userID, c.id, c.pct, bookingTotal, discountAmount); err != nil {
|
||||||
log.Printf("Failed to insert anniversary discount: %v", err)
|
log.Printf("Failed to insert anniversary discount: %v", err)
|
||||||
} else {
|
} else {
|
||||||
db.DB.Exec(ctx, `
|
tx.Exec(ctx, `
|
||||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||||
`, bookingID, discountAmount, userID)
|
`, bookingID, discountAmount, userID)
|
||||||
db.DB.Exec(ctx, `
|
tx.Exec(ctx, `
|
||||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
||||||
`, c.id)
|
`, c.id)
|
||||||
}
|
}
|
||||||
@@ -1111,15 +1144,15 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, user
|
|||||||
}
|
}
|
||||||
|
|
||||||
var firstPaymentMethod string
|
var firstPaymentMethod string
|
||||||
if err := db.DB.QueryRow(ctx, `
|
if err := tx.QueryRow(ctx, `
|
||||||
SELECT payment_method FROM payments WHERE booking_id = $1 AND payment_method NOT IN ('discount', 'on_the_house') ORDER BY created_at ASC LIMIT 1
|
SELECT payment_method FROM payments WHERE booking_id = $1 AND payment_method NOT IN ('discount', 'on_the_house') ORDER BY created_at ASC LIMIT 1
|
||||||
`, bookingID).Scan(&firstPaymentMethod); err == nil && firstPaymentMethod == "in_person_card" {
|
`, bookingID).Scan(&firstPaymentMethod); err == nil && firstPaymentMethod == "in_person_card" {
|
||||||
var globalCount int
|
var globalCount int
|
||||||
db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount)
|
tx.QueryRow(ctx, `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount)
|
||||||
|
|
||||||
var globalCampaignID string
|
var globalCampaignID string
|
||||||
var globalPercent float64
|
var globalPercent float64
|
||||||
db.DB.QueryRow(ctx, `
|
tx.QueryRow(ctx, `
|
||||||
SELECT id, discount_percent FROM discount_campaigns
|
SELECT id, discount_percent FROM discount_campaigns
|
||||||
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count'
|
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count'
|
||||||
AND milestone_value <= $1
|
AND milestone_value <= $1
|
||||||
@@ -1130,26 +1163,59 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, user
|
|||||||
|
|
||||||
if globalCampaignID != "" {
|
if globalCampaignID != "" {
|
||||||
var exists int
|
var exists int
|
||||||
db.DB.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, globalCampaignID).Scan(&exists)
|
tx.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, globalCampaignID).Scan(&exists)
|
||||||
if exists == 0 {
|
if exists == 0 {
|
||||||
discountAmount := roundTo2(bookingTotal * globalPercent / 100)
|
discountAmount := roundTo2(bookingTotal * globalPercent / 100)
|
||||||
if _, err := db.DB.Exec(ctx, `
|
if _, err := tx.Exec(ctx, `
|
||||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||||
VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6)
|
VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6)
|
||||||
`, bookingID, userID, globalCampaignID, globalPercent, bookingTotal, discountAmount); err != nil {
|
`, bookingID, userID, globalCampaignID, globalPercent, bookingTotal, discountAmount); err != nil {
|
||||||
log.Printf("Failed to insert global milestone discount: %v", err)
|
log.Printf("Failed to insert global milestone discount: %v", err)
|
||||||
} else {
|
} else {
|
||||||
db.DB.Exec(ctx, `
|
tx.Exec(ctx, `
|
||||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||||
`, bookingID, discountAmount, userID)
|
`, bookingID, discountAmount, userID)
|
||||||
db.DB.Exec(ctx, `
|
tx.Exec(ctx, `
|
||||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
||||||
`, globalCampaignID)
|
`, globalCampaignID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply referrer's referral discount if available
|
||||||
|
if bookingTotal > 0 {
|
||||||
|
var rdID string
|
||||||
|
var rdPercent float64
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT id, discount_percent FROM referral_discounts
|
||||||
|
WHERE user_id = $1 AND used = FALSE
|
||||||
|
LIMIT 1
|
||||||
|
`, userID).Scan(&rdID, &rdPercent); err == nil && rdID != "" {
|
||||||
|
exists := 0
|
||||||
|
tx.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'referral' AND source_id = $2`, bookingID, rdID).Scan(&exists)
|
||||||
|
if exists == 0 {
|
||||||
|
discountAmount := roundTo2(bookingTotal * rdPercent / 100)
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||||
|
VALUES ($1, $2, 'referral', $3, NULL, NULL, $4, $5, $6)
|
||||||
|
`, bookingID, userID, rdID, rdPercent, bookingTotal, discountAmount); err == nil {
|
||||||
|
tx.Exec(ctx, `
|
||||||
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||||
|
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||||
|
`, bookingID, discountAmount, userID)
|
||||||
|
tx.Exec(ctx, `
|
||||||
|
UPDATE referral_discounts SET used = TRUE, used_at = NOW() WHERE id = $1
|
||||||
|
`, rdID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
log.Printf("Failed to commit discount application: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildSplitRecords determines whether to split a single Square charge into
|
// buildSplitRecords determines whether to split a single Square charge into
|
||||||
|
|||||||
Reference in New Issue
Block a user