diff --git a/web/src/padelnomics/credits.py b/web/src/padelnomics/credits.py index ac58f37..79cf79d 100644 --- a/web/src/padelnomics/credits.py +++ b/web/src/padelnomics/credits.py @@ -5,7 +5,7 @@ All balance mutations go through this module to keep credit_ledger (source of tr and suppliers.credit_balance (denormalized cache) in sync within a single transaction. """ -from datetime import datetime +from datetime import UTC, datetime from .core import execute, fetch_all, fetch_one, transaction @@ -204,3 +204,92 @@ async def get_ledger(supplier_id: int, limit: int = 50) -> list[dict]: ORDER BY cl.created_at DESC LIMIT ?""", (supplier_id, limit), ) + + +class GuaranteeAlreadyClaimed(Exception): + """Raised when supplier tries to claim guarantee twice for the same lead.""" + + +class GuaranteeWindowClosed(Exception): + """Raised when the 3-30 day guarantee window has passed.""" + + +async def refund_lead_guarantee( + supplier_id: int, + forward_id: int, + contact_method: str, +) -> int: + """Refund credits for a non-responding lead. Returns new balance. + + Preconditions: + - forward must exist, belong to supplier_id, status='sent' + - guarantee_claimed_at must be NULL (not already claimed) + - created_at must be 3–30 days ago (calendar days) + - contact_method must be one of: 'email', 'phone', 'both' + + The refund is a ledger credit entry (event_type='guarantee_refund') referencing + the forward_id. lead_forwards.status is updated to 'no_response'. + We keep the cash — only credits are returned to the supplier balance. + """ + assert contact_method in ("email", "phone", "both"), ( + f"Invalid contact_method: {contact_method!r}" + ) + + forward = await fetch_one( + "SELECT * FROM lead_forwards WHERE id = ? AND supplier_id = ?", + (forward_id, supplier_id), + ) + assert forward is not None, f"Forward {forward_id} not found for supplier {supplier_id}" + + if forward["guarantee_claimed_at"] is not None: + raise GuaranteeAlreadyClaimed( + f"Guarantee already claimed for forward {forward_id}" + ) + + # Check 3–30 day window (calendar days) + created = datetime.fromisoformat(forward["created_at"].replace("Z", "+00:00")) + now = datetime.now(UTC) + age_days = (now - created).days + + if age_days < 3 or age_days > 30: + raise GuaranteeWindowClosed( + f"Guarantee window closed: forward is {age_days} days old (must be 3–30)" + ) + + refund_amount = forward["credit_cost"] + now_iso = datetime.utcnow().isoformat() + + async with transaction() as db: + row = await db.execute_fetchall( + "SELECT credit_balance FROM suppliers WHERE id = ?", (supplier_id,) + ) + current = row[0][0] if row else 0 + new_balance = current + refund_amount + + await db.execute( + """INSERT INTO credit_ledger + (supplier_id, delta, balance_after, event_type, reference_id, note, created_at) + VALUES (?, ?, ?, 'guarantee_refund', ?, ?, ?)""", + ( + supplier_id, + refund_amount, + new_balance, + forward_id, + f"Lead-back guarantee refund for forward #{forward_id}", + now_iso, + ), + ) + await db.execute( + "UPDATE suppliers SET credit_balance = ? WHERE id = ?", + (new_balance, supplier_id), + ) + await db.execute( + """UPDATE lead_forwards + SET status = 'no_response', + guarantee_claimed_at = ?, + guarantee_contact_method = ? + WHERE id = ?""", + (now_iso, contact_method, forward_id), + ) + + return new_balance diff --git a/web/src/padelnomics/locales/de.json b/web/src/padelnomics/locales/de.json index 920e7b9..d292f9a 100644 --- a/web/src/padelnomics/locales/de.json +++ b/web/src/padelnomics/locales/de.json @@ -1633,7 +1633,6 @@ "sup_roi_line": "Ein einziges 4-Court-Projekt = €30.000+ Gewinn. Growth-Plan: €2.388/Jahr. Die Rechnung ist einfach.", "sup_credits_only_pre": "Noch nicht bereit für ein Abo? Kaufe ein Credit-Paket und schalte Leads einzeln frei. Keine Bindung, keine Monatsgebühr.", "sup_credits_only_cta": "Credits kaufen →", - "sup_credits_only_post": "", "sup_step1_free_forever": "Dauerhaft kostenlos", "sd_guarantee_btn": "Lead hat nicht geantwortet", "sd_guarantee_contact_label": "Wie hast du versucht, den Lead zu erreichen?", diff --git a/web/src/padelnomics/locales/en.json b/web/src/padelnomics/locales/en.json index a8d4ca6..5c6af14 100644 --- a/web/src/padelnomics/locales/en.json +++ b/web/src/padelnomics/locales/en.json @@ -932,7 +932,7 @@ "sup_lead_timeline": "Timeline", "sup_lead_contact": "Contact", "sup_leads_unlock_pre": "Unlock full contact details and project specs with credits.", - "sup_leads_unlock_cta": "Get started →", + "sup_leads_unlock_cta": "Start Getting Leads", "sup_leads_example": "These are example leads. Real leads appear as entrepreneurs submit quote requests.", "sup_why_h2": "Why Padelnomics Leads Are Different", "sup_why_sub": "Every lead has already built a financial model for their project.", @@ -1055,11 +1055,9 @@ "sup_guarantee_badge": "No-risk guarantee", "sup_leads_section_h2": "See What Your Prospects Look Like", "sup_leads_section_sub": "Every lead has used our financial planner. Contact details are blurred until you unlock.", - "sup_leads_unlock_cta": "Start Getting Leads", "sup_roi_line": "A single 4-court project = €30,000+ in profit. Growth plan costs €2,388/year. The math is simple.", "sup_credits_only_pre": "Not ready for a subscription? Buy a credit pack and unlock leads one at a time. No commitment, no monthly fee.", "sup_credits_only_cta": "Buy Credits →", - "sup_credits_only_post": "", "sup_step1_free_forever": "Free forever", "sd_guarantee_btn": "Lead didn’t respond", "sd_guarantee_contact_label": "How did you try to reach them?", @@ -1494,7 +1492,6 @@ "sd_flash_valid_email": "Please enter a valid email address.", "sd_flash_claim_error": "This listing has already been claimed or does not exist.", "sd_flash_listing_saved": "Listing saved successfully.", - "bp_indoor": "Indoor", "bp_outdoor": "Outdoor", "bp_own": "Own", @@ -1503,24 +1500,20 @@ "bp_payback_not_reached": "Not reached in 60 months", "bp_months": "{n} months", "bp_years": "{n} years", - "bp_exec_paragraph": "This business plan models a {facility_type} padel facility with {courts} courts ({sqm} m\u00b2). Total investment is {total_capex}, financed with {equity} equity and {loan} debt. The projected IRR is {irr} with a payback period of {payback}.", - + "bp_exec_paragraph": "This business plan models a {facility_type} padel facility with {courts} courts ({sqm} m²). Total investment is {total_capex}, financed with {equity} equity and {loan} debt. The projected IRR is {irr} with a payback period of {payback}.", "bp_lbl_scenario": "Scenario", - "bp_lbl_generated_by": "Generated by Padelnomics \u2014 padelnomics.io", - + "bp_lbl_generated_by": "Generated by Padelnomics — padelnomics.io", "bp_lbl_total_investment": "Total Investment", "bp_lbl_equity_required": "Equity Required", "bp_lbl_year3_ebitda": "Year 3 EBITDA", "bp_lbl_irr": "IRR", "bp_lbl_payback_period": "Payback Period", "bp_lbl_year1_revenue": "Year 1 Revenue", - "bp_lbl_item": "Item", "bp_lbl_amount": "Amount", "bp_lbl_notes": "Notes", "bp_lbl_total_capex": "Total CAPEX", - "bp_lbl_capex_stats": "CAPEX per court: {per_court} \u2022 CAPEX per m\u00b2: {per_sqm}", - + "bp_lbl_capex_stats": "CAPEX per court: {per_court} • CAPEX per m²: {per_sqm}", "bp_lbl_equity": "Equity", "bp_lbl_loan": "Loan", "bp_lbl_interest_rate": "Interest Rate", @@ -1528,24 +1521,20 @@ "bp_lbl_monthly_payment": "Monthly Payment", "bp_lbl_annual_debt_service": "Annual Debt Service", "bp_lbl_ltv": "Loan-to-Value", - "bp_lbl_monthly": "Monthly", "bp_lbl_total_monthly_opex": "Total Monthly OPEX", "bp_lbl_annual_opex": "Annual OPEX", - "bp_lbl_weighted_hourly_rate": "Weighted Hourly Rate", "bp_lbl_target_utilization": "Target Utilization", "bp_lbl_gross_monthly_revenue": "Gross Monthly Revenue", "bp_lbl_net_monthly_revenue": "Net Monthly Revenue", "bp_lbl_monthly_ebitda": "Monthly EBITDA", "bp_lbl_monthly_net_cf": "Monthly Net Cash Flow", - "bp_lbl_year": "Year", "bp_lbl_revenue": "Revenue", "bp_lbl_ebitda": "EBITDA", "bp_lbl_debt_service": "Debt Service", "bp_lbl_net_cf": "Net CF", - "bp_lbl_moic": "MOIC", "bp_lbl_cash_on_cash": "Cash-on-Cash (Y3)", "bp_lbl_payback": "Payback", @@ -1553,46 +1542,40 @@ "bp_lbl_ebitda_margin": "EBITDA Margin", "bp_lbl_dscr_y3": "DSCR (Y3)", "bp_lbl_yield_on_cost": "Yield on Cost", - "bp_lbl_month": "Month", "bp_lbl_opex": "OPEX", "bp_lbl_debt": "Debt", "bp_lbl_cumulative": "Cumulative", - - "bp_lbl_disclaimer": "Disclaimer: This business plan is generated from user-provided assumptions using the Padelnomics financial model. All projections are estimates and do not constitute financial advice. Actual results may vary significantly based on market conditions, execution, and other factors. Consult with financial advisors before making investment decisions. \u00a9 Padelnomics \u2014 padelnomics.io", - + "bp_lbl_disclaimer": "Disclaimer: This business plan is generated from user-provided assumptions using the Padelnomics financial model. All projections are estimates and do not constitute financial advice. Actual results may vary significantly based on market conditions, execution, and other factors. Consult with financial advisors before making investment decisions. © Padelnomics — padelnomics.io", "email_magic_link_heading": "Sign in to {app_name}", "email_magic_link_body": "Here's your sign-in link. It expires in {expiry_minutes} minutes.", - "email_magic_link_btn": "Sign In \u2192", + "email_magic_link_btn": "Sign In →", "email_magic_link_fallback": "If the button doesn't work, copy and paste this URL into your browser:", "email_magic_link_ignore": "If you didn't request this, you can safely ignore this email.", "email_magic_link_subject": "Your sign-in link for {app_name}", "email_magic_link_preheader": "This link expires in {expiry_minutes} minutes", - "email_quote_verify_heading": "Verify your email to get quotes", "email_quote_verify_greeting": "Hi {first_name},", "email_quote_verify_body": "Thanks for requesting quotes. Verify your email to activate your quote request and create your {app_name} account.", "email_quote_verify_project_label": "Your project:", "email_quote_verify_urgency": "Verified requests get prioritized by our supplier network.", - "email_quote_verify_btn": "Verify & Activate \u2192", + "email_quote_verify_btn": "Verify & Activate →", "email_quote_verify_expires": "This link expires in 60 minutes.", "email_quote_verify_fallback": "If the button doesn't work, copy and paste this URL into your browser:", "email_quote_verify_ignore": "If you didn't request this, you can safely ignore this email.", - "email_quote_verify_subject": "Verify your email \u2014 suppliers are ready to quote", + "email_quote_verify_subject": "Verify your email — suppliers are ready to quote", "email_quote_verify_preheader": "One click to activate your quote request", "email_quote_verify_preheader_courts": "One click to activate your {court_count}-court project", - "email_welcome_heading": "Welcome to {app_name}", "email_welcome_greeting": "Hi {first_name},", - "email_welcome_body": "You now have access to the financial planner, market data, and supplier directory \u2014 everything you need to plan your padel business.", + "email_welcome_body": "You now have access to the financial planner, market data, and supplier directory — everything you need to plan your padel business.", "email_welcome_quickstart_heading": "Quick start:", - "email_welcome_link_planner": "Financial Planner \u2014 model your investment", - "email_welcome_link_markets": "Market Data \u2014 explore padel demand by city", - "email_welcome_link_quotes": "Get Quotes \u2014 connect with verified suppliers", - "email_welcome_btn": "Start Planning \u2192", - "email_welcome_subject": "You're in \u2014 here's how to start planning", + "email_welcome_link_planner": "Financial Planner — model your investment", + "email_welcome_link_markets": "Market Data — explore padel demand by city", + "email_welcome_link_quotes": "Get Quotes — connect with verified suppliers", + "email_welcome_btn": "Start Planning →", + "email_welcome_subject": "You're in — here's how to start planning", "email_welcome_preheader": "Your padel business planning toolkit is ready", - "email_waitlist_supplier_heading": "You're on the Supplier Waitlist", "email_waitlist_supplier_body": "Thanks for your interest in the {plan_name} plan. We're building a platform to connect you with qualified leads from padel entrepreneurs actively planning projects.", "email_waitlist_supplier_perks_intro": "As an early waitlist member, you'll get:", @@ -1600,20 +1583,19 @@ "email_waitlist_supplier_perk_2": "Exclusive launch pricing (locked in)", "email_waitlist_supplier_perk_3": "Dedicated onboarding call", "email_waitlist_supplier_meanwhile": "In the meantime, explore our free resources:", - "email_waitlist_supplier_link_planner": "Financial Planning Tool \u2014 model your padel facility", - "email_waitlist_supplier_link_directory": "Supplier Directory \u2014 browse verified suppliers", - "email_waitlist_supplier_subject": "You're in \u2014 {plan_name} early access is coming", + "email_waitlist_supplier_link_planner": "Financial Planning Tool — model your padel facility", + "email_waitlist_supplier_link_directory": "Supplier Directory — browse verified suppliers", + "email_waitlist_supplier_subject": "You're in — {plan_name} early access is coming", "email_waitlist_supplier_preheader": "Exclusive launch pricing + priority onboarding", "email_waitlist_general_heading": "You're on the Waitlist", - "email_waitlist_general_body": "Thanks for joining. We're building the planning platform for padel entrepreneurs \u2014 financial modelling, market data, and supplier connections in one place.", + "email_waitlist_general_body": "Thanks for joining. We're building the planning platform for padel entrepreneurs — financial modelling, market data, and supplier connections in one place.", "email_waitlist_general_perks_intro": "As an early waitlist member, you'll get:", "email_waitlist_general_perk_1": "Early access before public launch", "email_waitlist_general_perk_2": "Exclusive launch pricing", "email_waitlist_general_perk_3": "Priority onboarding and support", "email_waitlist_general_outro": "We'll be in touch soon.", - "email_waitlist_general_subject": "You're on the list \u2014 we'll notify you at launch", + "email_waitlist_general_subject": "You're on the list — we'll notify you at launch", "email_waitlist_general_preheader": "Early access + exclusive launch pricing", - "email_lead_forward_heading": "New Project Lead", "email_lead_forward_urgency": "This lead was just unlocked. Suppliers who respond within 24 hours are 3x more likely to win the project.", "email_lead_forward_section_brief": "Project Brief", @@ -1630,22 +1612,20 @@ "email_lead_forward_lbl_phone": "Phone", "email_lead_forward_lbl_company": "Company", "email_lead_forward_lbl_role": "Role", - "email_lead_forward_btn": "View in Lead Feed \u2192", + "email_lead_forward_btn": "View in Lead Feed →", "email_lead_forward_reply_direct": "or reply directly to {contact_email}", "email_lead_forward_preheader_suffix": "contact details inside", - "email_lead_matched_heading": "A supplier wants to discuss your project", "email_lead_matched_greeting": "Hi {first_name},", - "email_lead_matched_body": "Great news \u2014 a verified supplier has been matched with your padel project. They have your project brief and contact details.", + "email_lead_matched_body": "Great news — a verified supplier has been matched with your padel project. They have your project brief and contact details.", "email_lead_matched_context": "You submitted a quote request for a {facility_type} facility with {court_count} courts in {country}.", "email_lead_matched_next_heading": "What happens next", - "email_lead_matched_next_body": "The supplier has received your project brief and contact details. Most suppliers respond within 24\u201348 hours via email or phone.", + "email_lead_matched_next_body": "The supplier has received your project brief and contact details. Most suppliers respond within 24–48 hours via email or phone.", "email_lead_matched_tip": "Tip: Responding quickly to supplier outreach increases your chance of getting competitive quotes.", - "email_lead_matched_btn": "View Your Dashboard \u2192", + "email_lead_matched_btn": "View Your Dashboard →", "email_lead_matched_note": "You'll receive this notification each time a new supplier unlocks your project details.", "email_lead_matched_subject": "{first_name}, a supplier wants to discuss your project", - "email_lead_matched_preheader": "They'll reach out to you directly \u2014 here's what to expect", - + "email_lead_matched_preheader": "They'll reach out to you directly — here's what to expect", "email_enquiry_heading": "New enquiry from {contact_name}", "email_enquiry_body": "You have a new enquiry via your {supplier_name} directory listing.", "email_enquiry_lbl_from": "From", @@ -1654,15 +1634,13 @@ "email_enquiry_reply": "Reply directly to {contact_email} to connect.", "email_enquiry_subject": "New enquiry from {contact_name} via your directory listing", "email_enquiry_preheader": "Reply to connect with this potential client", - "email_business_plan_heading": "Your business plan is ready", "email_business_plan_body": "Your padel business plan PDF has been generated and is ready for download.", "email_business_plan_includes": "Your plan includes investment breakdown, revenue projections, and break-even analysis.", - "email_business_plan_btn": "Download PDF \u2192", - "email_business_plan_quote_cta": "Ready for the next step? Get quotes from suppliers \u2192", + "email_business_plan_btn": "Download PDF →", + "email_business_plan_quote_cta": "Ready for the next step? Get quotes from suppliers →", "email_business_plan_subject": "Your business plan PDF is ready to download", - "email_business_plan_preheader": "Professional padel facility financial plan \u2014 download now", - + "email_business_plan_preheader": "Professional padel facility financial plan — download now", "email_footer_tagline": "The padel business planning platform", - "email_footer_copyright": "\u00a9 {year} {app_name}. You received this email because you have an account or submitted a request." + "email_footer_copyright": "© {year} {app_name}. You received this email because you have an account or submitted a request." } diff --git a/web/src/padelnomics/migrations/versions/0020_lead_guarantee_columns.py b/web/src/padelnomics/migrations/versions/0020_lead_guarantee_columns.py new file mode 100644 index 0000000..f3c132d --- /dev/null +++ b/web/src/padelnomics/migrations/versions/0020_lead_guarantee_columns.py @@ -0,0 +1,16 @@ +"""Add lead-back guarantee columns to lead_forwards. + +guarantee_claimed_at: ISO timestamp when supplier claimed the guarantee refund. +guarantee_contact_method: how the supplier tried to reach the lead (email/phone/both). +Status 'no_response' added to lead_forwards.status by convention (no CHECK constraint change +needed — existing status TEXT column accepts any value). +""" + + +def up(conn): + conn.execute( + "ALTER TABLE lead_forwards ADD COLUMN guarantee_claimed_at TEXT" + ) + conn.execute( + "ALTER TABLE lead_forwards ADD COLUMN guarantee_contact_method TEXT" + ) diff --git a/web/src/padelnomics/public/templates/suppliers.html b/web/src/padelnomics/public/templates/suppliers.html index 0bd5acc..3039b71 100644 --- a/web/src/padelnomics/public/templates/suppliers.html +++ b/web/src/padelnomics/public/templates/suppliers.html @@ -554,7 +554,7 @@
{{ t.sup_credits_only_pre }} {{ t.sup_credits_only_cta }} {{ t.sup_credits_only_post }}
+{{ t.sup_credits_only_pre }} {{ t.sup_credits_only_cta }}
{{ credit_cost }} {{ t.sd_unlocked_credits_used }} · {{ supplier.credit_balance }} {{ t.sd_unlocked_remaining }}
{% endif %} + + {# --- Lead-Back Guarantee button --- + Show after 3 days, hide after 30 days, hide if already claimed. + forward_id comes from routes (unlock response) or from lead.forward_id (dashboard feed). + #} + {% set _fid = forward_id if forward_id is defined else lead.get('forward_id') %} + {% set _claimed_at = (guarantee_claimed_at if guarantee_claimed_at is defined else lead.get('guarantee_claimed_at')) %} + {% set _forward_created = lead.get('forward_created_at') or lead.get('created_at') %} + + {% if guarantee_claimed %} ++ ✓ {{ guarantee_success_msg or t.sd_guarantee_success }} +
+ {% elif _fid and not _claimed_at %} + {# JS calculates age client-side to avoid server-side timezone math in template #} +{{ t.sd_guarantee_already_claimed }}
+ {% endif %} {% if credit_cost is defined %}