Created
January 29, 2026 04:43
-
-
Save patio11/f4efb6a71d587d35355c2cefcf89add5 to your computer and use it in GitHub Desktop.
Bits about Money subscription management logic, substantially authored by Claude Code. I do not recommend using this
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # patio11 notes: This file was substantially authored by Claude Code. | |
| # I do not recommend using it; it is extremely customized to particular requirements of my own business. | |
| # My purpose for publishing it is to show technologists (and others) an actual sample of non-trivial live | |
| # code authored by a modern coding tool, so they can get a bead on code quality, style, and similar. | |
| # Spoiler: this is a lot of boilerplate and bookkeeping to solve a task which this engineer | |
| # would also tell you is dominated by boilerplate and bookkeeping. | |
| # Code substantially similar to this runs in production. | |
| require 'stripe' | |
| require 'net/https' | |
| require 'json' | |
| class BamSubscriptions | |
| # Wrapper for Bits about Money subscription data in Stripe. | |
| # Supports both read operations (dashboards, reports) and write operations (invoice management). | |
| # | |
| # API Key Policy: | |
| # - readonly_api_key: Use for all read operations (listing, fetching). Safe for dashboards, | |
| # reports, and informational tasks. This is the default and should be used in most cases. | |
| # - elevated_permissions_api_key: Use ONLY for operations that modify Stripe data (marking | |
| # invoices uncollectible, updating subscriptions, etc.). Requires explicit justification. | |
| # This key has full write access to production Stripe data - use with extreme caution. | |
| # =========================================== | |
| # Constants | |
| # =========================================== | |
| # Stripe API keys | |
| READONLY_KEY = begin | |
| settings = YAML.load(File.open(File.join(Rails.root, 'config/stripe_keys.yml'))) | |
| settings["production"][:readonly_key] | |
| end | |
| # Scoped write key with minimal permissions for specific operations (e.g., marking invoices uncollectible). | |
| # Principle of least privilege: this key has fewer permissions than the full private_key. | |
| WRITABLE_KEY = begin | |
| settings = YAML.load(File.open(File.join(Rails.root, 'config/stripe_keys.yml'))) | |
| settings["production"][:writable_key] | |
| end | |
| # Postmark API key for BAM email server | |
| POSTMARK_API_KEY = begin | |
| settings = YAML.load(File.open(File.join(Rails.root, 'config/postmark.yml'))) | |
| settings["bam_server_token"] | |
| end | |
| # Caching | |
| CACHE_DIR = Rails.root.join('tmp', 'cache', 'bam_subscriptions') | |
| CACHE_TTL = 60 * 60 # 60 minutes | |
| # Email tracking directories | |
| SENT_EMAILS_DIR = Rails.root.join('tmp', 'bam_sent_emails') | |
| PAYMENT_FAILURE_EMAILS_DIR = Rails.root.join('tmp', 'bam_payment_failure_emails') | |
| # Postmark tags for duplicate detection | |
| REMINDER_TAG = "bam_subscription_reminder" | |
| PAYMENT_FAILURE_TAG = "bam_payment_failure" | |
| class << self | |
| # Returns the readonly API key. Safe for all read operations. | |
| # Use this key for dashboards, reports, listing subscriptions, etc. | |
| def readonly_api_key | |
| raise "No production readonly Stripe key configured in config/stripe_keys.yml" unless READONLY_KEY.present? | |
| READONLY_KEY | |
| end | |
| # DANGEROUS: Returns a scoped write API key for modifying Stripe data. | |
| # This key has limited permissions (principle of least privilege) but can still | |
| # make changes to production data. Use ONLY for operations that require writes. | |
| # Examples: marking invoices uncollectible. | |
| # Always prefer readonly_api_key unless write access is required. | |
| def elevated_permissions_api_key | |
| raise "No production writable Stripe key configured in config/stripe_keys.yml" unless WRITABLE_KEY.present? | |
| WRITABLE_KEY | |
| end | |
| # REMOVED: Legacy method that didn't make permissions explicit. | |
| # All callers must now explicitly choose readonly_api_key or elevated_permissions_api_key. | |
| def api_key | |
| raise "BamSubscriptions.api_key is deprecated. Use readonly_api_key for read operations " \ | |
| "or elevated_permissions_api_key for write operations. See class comments for API key policy." | |
| end | |
| def client | |
| @client ||= Stripe::StripeClient.new(readonly_api_key) | |
| end | |
| # File-based caching for expensive list operations | |
| def cache_path(key) | |
| FileUtils.mkdir_p(CACHE_DIR) | |
| CACHE_DIR.join("#{key}.json") | |
| end | |
| def read_cache(key) | |
| path = cache_path(key) | |
| return nil unless File.exist?(path) | |
| return nil if File.mtime(path) < Time.now - CACHE_TTL | |
| JSON.parse(File.read(path), symbolize_names: true) | |
| rescue JSON::ParserError | |
| nil | |
| end | |
| def write_cache(key, data) | |
| path = cache_path(key) | |
| File.write(path, data.to_json) | |
| data | |
| end | |
| def clear_list_cache! | |
| FileUtils.rm_rf(CACHE_DIR) | |
| end | |
| # Find a Stripe customer by email address. | |
| # Returns nil if not found. | |
| def find_customer_by_email(email) | |
| customers = Stripe::Customer.list({ email: email, limit: 1 }, { api_key: readonly_api_key }) | |
| customers.data.first | |
| end | |
| # Get all subscriptions for a customer email. | |
| # Returns array of Stripe::Subscription objects. | |
| def subscriptions_for_email(email) | |
| customer = find_customer_by_email(email) | |
| return [] unless customer | |
| subscriptions_for_customer(customer.id) | |
| end | |
| # Get all subscriptions for a customer ID. | |
| def subscriptions_for_customer(customer_id) | |
| Stripe::Subscription.list( | |
| { customer: customer_id, limit: 100 }, | |
| { api_key: readonly_api_key } | |
| ).data | |
| end | |
| # Get only BAM subscriptions for a customer email. | |
| # Filters by product name containing "Bits about Money" or product metadata. | |
| # Note: Requires rak_product_read permission on the API key to filter by product. | |
| def bam_subscriptions_for_email(email) | |
| subscriptions_for_email(email).select { |sub| bam_subscription?(sub) } | |
| end | |
| # Get all subscriptions for a customer (no BAM filtering). | |
| # Useful when product read permissions aren't available. | |
| def all_subscriptions_for_email(email) | |
| subscriptions_for_email(email) | |
| end | |
| # Check if a subscription is a BAM subscription. | |
| def bam_subscription?(subscription) | |
| subscription.items.data.any? do |item| | |
| product = fetch_product(item.price.product) | |
| next false unless product | |
| product.name.to_s.downcase.include?("bits about money") || | |
| product.metadata["bam"] == "true" || | |
| product.metadata["ghost"] == "true" | |
| end | |
| end | |
| # Fetch product details (cached per request). | |
| def fetch_product(product_id) | |
| @products_cache ||= {} | |
| @products_cache[product_id] ||= begin | |
| Stripe::Product.retrieve(product_id, { api_key: readonly_api_key }) | |
| rescue Stripe::InvalidRequestError, Stripe::PermissionError | |
| nil | |
| end | |
| end | |
| # Clear the products cache (useful between operations). | |
| def clear_cache! | |
| @products_cache = {} | |
| @client = nil | |
| end | |
| # Get a summary of a subscriber's BAM status. | |
| # Returns a hash with subscription details or nil if not a subscriber. | |
| # Set filter_bam: false to skip BAM product filtering (useful if rak_product_read unavailable). | |
| def subscriber_summary(email, filter_bam: true) | |
| customer = find_customer_by_email(email) | |
| return nil unless customer | |
| subs = filter_bam ? bam_subscriptions_for_email(email) : all_subscriptions_for_email(email) | |
| return nil if subs.empty? | |
| active_sub = subs.find { |s| s.status == "active" } | |
| { | |
| email: email, | |
| customer_id: customer.id, | |
| customer_name: customer.name, | |
| customer_created: Time.at(customer.created), | |
| subscription_count: subs.count, | |
| active_subscription: active_sub ? subscription_details(active_sub) : nil, | |
| all_subscriptions: subs.map { |s| subscription_details(s) } | |
| } | |
| end | |
| # Extract key details from a subscription. | |
| def subscription_details(subscription) | |
| { | |
| id: subscription.id, | |
| status: subscription.status, | |
| created: Time.at(subscription.created), | |
| current_period_start: Time.at(subscription.current_period_start), | |
| current_period_end: Time.at(subscription.current_period_end), | |
| cancel_at_period_end: subscription.cancel_at_period_end, | |
| canceled_at: subscription.canceled_at ? Time.at(subscription.canceled_at) : nil, | |
| items: subscription.items.data.map { |item| item_details(item) } | |
| } | |
| end | |
| # Extract details from a subscription item. | |
| def item_details(item) | |
| product = fetch_product(item.price.product) | |
| { | |
| price_id: item.price.id, | |
| product_id: item.price.product, | |
| product_name: product&.name, | |
| amount: item.price.unit_amount, | |
| currency: item.price.currency, | |
| interval: item.price.recurring&.interval | |
| } | |
| end | |
| # Fetch all BAM subscribers for dashboard display (cached). | |
| # Returns array of hashes with subscriber details including billing info. | |
| def all_subscribers(force_refresh: false) | |
| cache_key = "all_subscribers" | |
| cached = read_cache(cache_key) unless force_refresh | |
| return cached if cached | |
| subscribers = [] | |
| cursor = nil | |
| loop do | |
| params = { limit: 100, status: 'all', expand: ['data.customer', 'data.latest_invoice'] } | |
| params[:starting_after] = cursor if cursor | |
| all_subs = Stripe::Subscription.list(params, { api_key: readonly_api_key }) | |
| bam_subs = all_subs.data.select { |sub| bam_subscription?(sub) } | |
| bam_subs.each do |sub| | |
| customer = sub.customer | |
| latest_invoice = sub.latest_invoice | |
| # Handle various customer states: string ID, deleted, or full object | |
| customer_id = customer.is_a?(String) ? customer : customer.id rescue customer.to_s | |
| customer_email = customer.respond_to?(:email) ? customer.email : nil rescue nil | |
| customer_name = customer.respond_to?(:name) ? customer.name : nil rescue nil | |
| # Customer balance in Stripe: negative = credit, positive = owes money | |
| # Extract balance if customer object is available | |
| customer_balance = customer.respond_to?(:balance) ? customer.balance : 0 rescue 0 | |
| # credit_balance is the positive amount of credit available (for display) | |
| credit_balance = customer_balance < 0 ? -customer_balance : 0 | |
| # Fetch upcoming invoice for accurate billing amount (includes discounts, credits) | |
| # Only for active/past_due subscriptions that will actually renew | |
| upcoming_invoice = nil | |
| if %w[active past_due].include?(sub.status) && !sub.cancel_at_period_end | |
| begin | |
| upcoming_invoice = Stripe::Invoice.upcoming( | |
| { customer: customer_id, subscription: sub.id }, | |
| { api_key: readonly_api_key } | |
| ) | |
| rescue Stripe::InvalidRequestError => e | |
| # No upcoming invoice (e.g., subscription cancelled, no future billing) | |
| Rails.logger.debug "No upcoming invoice for #{sub.id}: #{e.message}" | |
| end | |
| end | |
| # Use upcoming invoice total (after discounts, before credits) if available, | |
| # fall back to price unit_amount. We use 'total' not 'amount_due' because | |
| # amount_due has credits already applied, and we show credits separately. | |
| next_invoice_amount = upcoming_invoice&.total || sub.items.data.first&.price&.unit_amount | |
| subscribers << { | |
| customer_id: customer_id, | |
| email: customer_email, | |
| name: customer_name, | |
| subscription_id: sub.id, | |
| status: sub.status, | |
| created: sub.created, | |
| current_period_start: sub.current_period_start, | |
| current_period_end: sub.current_period_end, | |
| cancel_at_period_end: sub.cancel_at_period_end, | |
| canceled_at: sub.canceled_at, | |
| last_invoice_date: latest_invoice&.created, | |
| last_invoice_amount: latest_invoice&.amount_paid, | |
| last_invoice_status: latest_invoice&.status, | |
| next_invoice_date: sub.current_period_end, | |
| next_invoice_amount: next_invoice_amount, | |
| interval: sub.items.data.first&.price&.recurring&.interval, | |
| product_name: fetch_product(sub.items.data.first&.price&.product)&.name, | |
| customer_balance: customer_balance, | |
| credit_balance: credit_balance | |
| } | |
| end | |
| break unless all_subs.has_more | |
| cursor = all_subs.data.last&.id | |
| end | |
| write_cache(cache_key, subscribers) | |
| subscribers | |
| end | |
| # List all BAM subscriptions (paginated). | |
| # Options: | |
| # status: filter by status (active, past_due, canceled, etc.) | |
| # limit: max records per page (default 100) | |
| # starting_after: cursor for pagination | |
| def list_bam_subscriptions(status: nil, limit: 100, starting_after: nil) | |
| params = { limit: limit } | |
| params[:status] = status if status | |
| params[:starting_after] = starting_after if starting_after | |
| all_subs = Stripe::Subscription.list(params, { api_key: readonly_api_key }) | |
| bam_subs = all_subs.data.select { |sub| bam_subscription?(sub) } | |
| { | |
| subscriptions: bam_subs.map { |s| subscription_details(s) }, | |
| has_more: all_subs.has_more, | |
| last_id: all_subs.data.last&.id | |
| } | |
| end | |
| # Count subscribers by status. | |
| def subscription_stats | |
| stats = Hash.new(0) | |
| cursor = nil | |
| loop do | |
| result = list_bam_subscriptions(starting_after: cursor) | |
| result[:subscriptions].each { |s| stats[s[:status]] += 1 } | |
| break unless result[:has_more] | |
| cursor = result[:last_id] | |
| end | |
| stats | |
| end | |
| # Find subscriptions expiring within N days (for renewal reminders). | |
| def expiring_within(days:) | |
| cutoff = Time.now + (days * 24 * 60 * 60) | |
| cursor = nil | |
| expiring = [] | |
| loop do | |
| result = list_bam_subscriptions(status: "active", starting_after: cursor) | |
| result[:subscriptions].each do |sub| | |
| if sub[:cancel_at_period_end] && sub[:current_period_end] <= cutoff | |
| expiring << sub | |
| end | |
| end | |
| break unless result[:has_more] | |
| cursor = result[:last_id] | |
| end | |
| expiring | |
| end | |
| # =========================================== | |
| # Payment failure detection | |
| # =========================================== | |
| # Find BAM subscriptions with recent payment failures that haven't recovered. | |
| # Options: | |
| # days: Look back this many days for payment failures (default 30) | |
| # include_already_contacted: Include subscribers we've already emailed about (default false) | |
| # | |
| # Returns subscriptions where: | |
| # - Status is past_due or unpaid (payment failed, Stripe may be retrying) | |
| # - Not intentionally canceled (by user or admin) | |
| # - Have a failed invoice in the lookback period | |
| # | |
| # Excludes: | |
| # - Subscriptions in canceled/incomplete/incomplete_expired status | |
| # - Subscriptions where cancel_at_period_end is true (user chose to leave) | |
| # - Subscriptions we've already contacted about this failure (unless include_already_contacted) | |
| def subscribers_with_payment_failures(days: 30, include_already_contacted: false, force_refresh: false) | |
| cutoff = Time.now - (days * 24 * 60 * 60) | |
| all_subs = all_subscribers(force_refresh: force_refresh) | |
| eligible = all_subs.select do |sub| | |
| # Must have email | |
| next false unless sub[:email].present? | |
| # Must be in a failed payment state (past_due = retrying, unpaid = exhausted retries) | |
| next false unless %w[past_due unpaid].include?(sub[:status]) | |
| # Exclude intentionally canceled subscriptions | |
| next false if sub[:cancel_at_period_end] | |
| # Must have a recent invoice failure (last_invoice_status indicates the state) | |
| # past_due status alone means there's a failed invoice | |
| next false unless sub[:last_invoice_date] | |
| invoice_date = Time.at(sub[:last_invoice_date]) | |
| next false unless invoice_date >= cutoff | |
| # Skip if we've already contacted about this failure (unless explicitly including) | |
| unless include_already_contacted | |
| next false if payment_failure_already_contacted?(sub[:subscription_id], sub[:last_invoice_date]) | |
| end | |
| true | |
| end | |
| # Sort by invoice date (most recent first) | |
| eligible.sort_by { |s| -(s[:last_invoice_date] || 0) } | |
| end | |
| # Fetch detailed invoice data for a subscription to understand the failure. | |
| # Returns the most recent invoice with failure details. | |
| def fetch_recent_invoices(subscription_id, limit: 5) | |
| invoices = Stripe::Invoice.list( | |
| { subscription: subscription_id, limit: limit }, | |
| { api_key: readonly_api_key } | |
| ) | |
| invoices.data.map do |inv| | |
| { | |
| id: inv.id, | |
| status: inv.status, | |
| amount_due: inv.amount_due, | |
| amount_paid: inv.amount_paid, | |
| attempted: inv.attempted, | |
| attempt_count: inv.attempt_count, | |
| next_payment_attempt: inv.next_payment_attempt ? Time.at(inv.next_payment_attempt) : nil, | |
| created: Time.at(inv.created), | |
| hosted_invoice_url: inv.hosted_invoice_url, | |
| # Payment intent can tell us why it failed | |
| payment_intent_status: inv.payment_intent.is_a?(String) ? nil : inv.payment_intent&.status, | |
| last_payment_error: extract_payment_error(inv) | |
| } | |
| end | |
| end | |
| # Extract human-readable payment error from invoice. | |
| def extract_payment_error(invoice) | |
| return nil unless invoice.payment_intent | |
| pi = if invoice.payment_intent.is_a?(String) | |
| begin | |
| Stripe::PaymentIntent.retrieve(invoice.payment_intent, { api_key: readonly_api_key }) | |
| rescue | |
| nil | |
| end | |
| else | |
| invoice.payment_intent | |
| end | |
| return nil unless pi&.last_payment_error | |
| error = pi.last_payment_error | |
| case error.code | |
| when 'card_declined' | |
| decline_code = error.decline_code || 'unknown' | |
| case decline_code | |
| when 'insufficient_funds' then 'Card declined: insufficient funds' | |
| when 'lost_card' then 'Card declined: card reported lost' | |
| when 'stolen_card' then 'Card declined: card reported stolen' | |
| when 'expired_card' then 'Card declined: card expired' | |
| when 'incorrect_cvc' then 'Card declined: incorrect CVC' | |
| when 'processing_error' then 'Card declined: processing error' | |
| when 'do_not_honor' then 'Card declined: bank declined' | |
| else "Card declined: #{decline_code}" | |
| end | |
| when 'expired_card' then 'Card expired' | |
| when 'incorrect_cvc' then 'Incorrect CVC' | |
| when 'processing_error' then 'Processing error' | |
| when 'authentication_required' then 'Card requires authentication (3D Secure)' | |
| else | |
| error.message || "Payment failed: #{error.code}" | |
| end | |
| end | |
| # Get enriched payment failure data for dashboard display. | |
| # Fetches additional invoice details for each failed subscription. | |
| # Options: | |
| # include_ghost_urls: Generate Ghost signin URLs for each subscriber (slower, makes API calls) | |
| # include_recovered: Include subscriptions we contacted that have since recovered (default true) | |
| def payment_failures_with_details(days: 30, include_already_contacted: false, force_refresh: false, include_ghost_urls: false, include_recovered: true) | |
| failures = subscribers_with_payment_failures( | |
| days: days, | |
| include_already_contacted: include_already_contacted, | |
| force_refresh: force_refresh | |
| ) | |
| # Build set of subscription IDs already in failures list | |
| failure_sub_ids = failures.map { |f| f[:subscription_id] }.to_set | |
| # Include recovered subscriptions (contacted but now active) | |
| if include_recovered && include_already_contacted | |
| contacted = contacted_subscriptions_with_current_status(days: days, force_refresh: force_refresh) | |
| recovered = contacted.select { |s| s[:recovered] && !failure_sub_ids.include?(s[:subscription_id]) } | |
| failures = failures + recovered | |
| end | |
| failures.map do |sub| | |
| invoices = fetch_recent_invoices(sub[:subscription_id], limit: 3) | |
| failed_invoice = invoices.find { |i| %w[open past_due].include?(i[:status]) } | |
| enriched = sub.merge( | |
| invoices: invoices, | |
| failed_invoice: failed_invoice, | |
| payment_error: failed_invoice&.dig(:last_payment_error), | |
| next_retry: failed_invoice&.dig(:next_payment_attempt), | |
| invoice_url: failed_invoice&.dig(:hosted_invoice_url) | |
| ) | |
| # Optionally add Ghost signin URL for frictionless member portal access | |
| if include_ghost_urls && sub[:email].present? | |
| enriched[:ghost_signin_url] = generate_ghost_signin_url(sub[:email]) | |
| end | |
| enriched | |
| end | |
| end | |
| # Generate a Ghost signin URL for a subscriber. | |
| # Returns nil if Ghost API is not configured or member not found. | |
| def generate_ghost_signin_url(email) | |
| require_dependency 'ghost_api' | |
| return nil unless GhostApi.configured? | |
| # Generate URL that redirects to account settings after login | |
| GhostApi.account_settings_url_for_email(email) | |
| rescue => e | |
| Rails.logger.warn "Failed to generate Ghost signin URL for #{email}: #{e.message}" | |
| nil | |
| end | |
| # =========================================== | |
| # Payment failure email tracking | |
| # =========================================== | |
| # Check if we've already contacted about this specific payment failure. | |
| def payment_failure_already_contacted?(subscription_id, invoice_date) | |
| sent_key = "#{subscription_id}_#{invoice_date}" | |
| File.exist?(payment_failure_email_path(sent_key)) | |
| end | |
| # Record that we contacted about this payment failure. | |
| def record_payment_failure_contacted!(subscription_id, invoice_date, email:) | |
| FileUtils.mkdir_p(PAYMENT_FAILURE_EMAILS_DIR) | |
| sent_key = "#{subscription_id}_#{invoice_date}" | |
| data = { | |
| subscription_id: subscription_id, | |
| invoice_date: invoice_date, | |
| email: email, | |
| sent_at: Time.now.iso8601 | |
| } | |
| File.write(payment_failure_email_path(sent_key), data.to_json) | |
| end | |
| def payment_failure_email_path(key) | |
| PAYMENT_FAILURE_EMAILS_DIR.join("#{key}.json") | |
| end | |
| def all_payment_failure_emails_sent | |
| return [] unless Dir.exist?(PAYMENT_FAILURE_EMAILS_DIR) | |
| Dir.glob(PAYMENT_FAILURE_EMAILS_DIR.join("*.json")).map do |path| | |
| JSON.parse(File.read(path), symbolize_names: true) | |
| end | |
| end | |
| # Fetch current status of subscriptions we've contacted about payment failures. | |
| # This allows us to show "recovered" subscribers who fixed their payment. | |
| # Returns array of subscription data with :recovered flag. | |
| def contacted_subscriptions_with_current_status(days: 30, force_refresh: false) | |
| cutoff = Time.now - (days * 24 * 60 * 60) | |
| # Get all sent tracking files within the lookback window | |
| sent_records = all_payment_failure_emails_sent.select do |record| | |
| sent_at = Time.parse(record[:sent_at]) rescue nil | |
| sent_at && sent_at >= cutoff | |
| end | |
| return [] if sent_records.empty? | |
| # Get unique subscription IDs we've contacted | |
| contacted_sub_ids = sent_records.map { |r| r[:subscription_id] }.uniq | |
| # Get all subscribers (cached) and find those we contacted | |
| all_subs = all_subscribers(force_refresh: force_refresh) | |
| sub_by_id = all_subs.index_by { |s| s[:subscription_id] } | |
| contacted_sub_ids.map do |sub_id| | |
| sub = sub_by_id[sub_id] | |
| next nil unless sub | |
| # Find the tracking record for this subscription | |
| record = sent_records.find { |r| r[:subscription_id] == sub_id } | |
| sub.merge( | |
| contacted_at: record[:sent_at], | |
| contacted_invoice_date: record[:invoice_date], | |
| recovered: sub[:status] == 'active' | |
| ) | |
| end.compact | |
| end | |
| # Check Postmark for recent payment failure emails to this address. | |
| def recently_sent_payment_failure_email?(email, days: 30) | |
| from_date = (Time.now - (days * 24 * 60 * 60)).strftime('%Y-%m-%d') | |
| query = "count=10&offset=0&recipient=#{CGI.escape(email)}&tag=#{PAYMENT_FAILURE_TAG}&fromdate=#{from_date}" | |
| uri = URI("https://api.postmarkapp.com/messages/outbound?#{query}") | |
| http = Net::HTTP.new(uri.host, uri.port) | |
| http.use_ssl = true | |
| request = Net::HTTP::Get.new(uri.request_uri) | |
| request['Accept'] = 'application/json' | |
| request['X-Postmark-Server-Token'] = POSTMARK_API_KEY | |
| response = http.request(request) | |
| if response.code == '200' | |
| data = JSON.parse(response.body) | |
| if data['TotalCount'] > 0 | |
| last_message = data['Messages'].first | |
| last_sent_at = Time.parse(last_message['ReceivedAt']) rescue nil | |
| { sent: true, last_sent_at: last_sent_at, count: data['TotalCount'] } | |
| else | |
| { sent: false, last_sent_at: nil, count: 0 } | |
| end | |
| else | |
| Rails.logger.warn "Postmark API error checking payment failure status for #{email}: #{response.code} #{response.body}" | |
| { sent: false, last_sent_at: nil, count: 0, error: "API error: #{response.code}" } | |
| end | |
| rescue => e | |
| Rails.logger.warn "Postmark API exception checking payment failure status for #{email}: #{e.message}" | |
| { sent: false, last_sent_at: nil, count: 0, error: e.message } | |
| end | |
| # Check if we can send a payment failure email to this address. | |
| def can_send_payment_failure_email?(email, days: 14) | |
| result = recently_sent_payment_failure_email?(email, days: days) | |
| if result[:error] | |
| Rails.logger.warn "Could not verify Postmark history for #{email}, allowing send" | |
| { ok: true, warning: result[:error] } | |
| elsif result[:sent] | |
| { ok: false, reason: "Already sent #{result[:count]} payment failure email(s) in last #{days} days (last: #{result[:last_sent_at]})" } | |
| else | |
| { ok: true } | |
| end | |
| end | |
| # =========================================== | |
| # Renewal reminder email functionality | |
| # =========================================== | |
| # Find subscribers due for renewal reminder emails. | |
| # Returns subscribers with billing date within `days` from now. | |
| # Includes: | |
| # - active subscriptions (will auto-renew) | |
| # - past_due subscriptions (grace period, may renew if payment succeeds) | |
| # Excludes: | |
| # - subscriptions already sent a reminder (tracked by subscription_id + period_end) | |
| # - canceled subscriptions | |
| # - subscriptions set to cancel_at_period_end (they know they're leaving) | |
| def subscribers_due_for_reminder(days:, include_already_sent: false) | |
| now = Time.now | |
| cutoff = now + (days * 24 * 60 * 60) | |
| all_subs = all_subscribers(force_refresh: false) | |
| eligible = all_subs.select do |sub| | |
| # Must have email | |
| next false unless sub[:email].present? | |
| # Must be active or past_due (grace period) | |
| next false unless %w[active past_due].include?(sub[:status]) | |
| # Skip if set to cancel at period end (they already know) | |
| next false if sub[:cancel_at_period_end] | |
| # Must have renewal date within the window | |
| next false unless sub[:current_period_end] | |
| renewal_date = Time.at(sub[:current_period_end]) | |
| next false unless renewal_date >= now && renewal_date <= cutoff | |
| # Skip if already sent (unless explicitly including) | |
| unless include_already_sent | |
| next false if reminder_already_sent?(sub[:subscription_id], sub[:current_period_end]) | |
| end | |
| true | |
| end | |
| eligible | |
| end | |
| # Check if a reminder was already sent for this subscription period. | |
| def reminder_already_sent?(subscription_id, period_end) | |
| sent_key = "#{subscription_id}_#{period_end}" | |
| File.exist?(sent_email_path(sent_key)) | |
| end | |
| # Record that a reminder was sent for this subscription period. | |
| def record_reminder_sent!(subscription_id, period_end, email:) | |
| FileUtils.mkdir_p(SENT_EMAILS_DIR) | |
| sent_key = "#{subscription_id}_#{period_end}" | |
| data = { | |
| subscription_id: subscription_id, | |
| period_end: period_end, | |
| email: email, | |
| sent_at: Time.now.iso8601 | |
| } | |
| File.write(sent_email_path(sent_key), data.to_json) | |
| end | |
| # Get path for sent email tracking file. | |
| def sent_email_path(key) | |
| SENT_EMAILS_DIR.join("#{key}.json") | |
| end | |
| # List all recorded sent reminders. | |
| def all_sent_reminders | |
| return [] unless Dir.exist?(SENT_EMAILS_DIR) | |
| Dir.glob(SENT_EMAILS_DIR.join("*.json")).map do |path| | |
| JSON.parse(File.read(path), symbolize_names: true) | |
| end | |
| end | |
| # Clear sent reminder records (use with caution). | |
| def clear_sent_reminders! | |
| FileUtils.rm_rf(SENT_EMAILS_DIR) | |
| end | |
| # Preview who would receive reminders without sending. | |
| def preview_reminder_recipients(days:) | |
| subscribers = subscribers_due_for_reminder(days: days) | |
| subscribers.map do |sub| | |
| { | |
| email: sub[:email], | |
| name: sub[:name], | |
| subscription_id: sub[:subscription_id], | |
| status: sub[:status], | |
| renewal_date: sub[:current_period_end] ? Time.at(sub[:current_period_end]) : nil, | |
| amount: sub[:next_invoice_amount], | |
| interval: sub[:interval] | |
| } | |
| end | |
| end | |
| # =========================================== | |
| # Postmark-based duplicate detection | |
| # =========================================== | |
| # Check Postmark API for whether we've sent a reminder to this email recently. | |
| # Returns hash with :sent (boolean) and :last_sent_at (Time or nil) | |
| def recently_sent_reminder_via_postmark?(email, days: 30) | |
| from_date = (Time.now - (days * 24 * 60 * 60)).strftime('%Y-%m-%d') | |
| query = "count=10&offset=0&recipient=#{CGI.escape(email)}&tag=#{REMINDER_TAG}&fromdate=#{from_date}" | |
| uri = URI("https://api.postmarkapp.com/messages/outbound?#{query}") | |
| http = Net::HTTP.new(uri.host, uri.port) | |
| http.use_ssl = true | |
| request = Net::HTTP::Get.new(uri.request_uri) | |
| request['Accept'] = 'application/json' | |
| request['X-Postmark-Server-Token'] = POSTMARK_API_KEY | |
| response = http.request(request) | |
| if response.code == '200' | |
| data = JSON.parse(response.body) | |
| if data['TotalCount'] > 0 | |
| last_message = data['Messages'].first | |
| last_sent_at = Time.parse(last_message['ReceivedAt']) rescue nil | |
| { sent: true, last_sent_at: last_sent_at, count: data['TotalCount'] } | |
| else | |
| { sent: false, last_sent_at: nil, count: 0 } | |
| end | |
| else | |
| # On API error, log and return false to avoid blocking sends | |
| Rails.logger.warn "Postmark API error checking sent status for #{email}: #{response.code} #{response.body}" | |
| { sent: false, last_sent_at: nil, count: 0, error: "API error: #{response.code}" } | |
| end | |
| rescue => e | |
| Rails.logger.warn "Postmark API exception checking sent status for #{email}: #{e.message}" | |
| { sent: false, last_sent_at: nil, count: 0, error: e.message } | |
| end | |
| # Check if we should send a reminder to this email. | |
| # Returns { ok: true } or { ok: false, reason: "..." } | |
| def can_send_reminder?(email, days: 30) | |
| result = recently_sent_reminder_via_postmark?(email, days: days) | |
| if result[:error] | |
| # On error, allow send but log warning | |
| Rails.logger.warn "Could not verify Postmark history for #{email}, allowing send" | |
| { ok: true, warning: result[:error] } | |
| elsif result[:sent] | |
| { ok: false, reason: "Already sent #{result[:count]} reminder(s) in last #{days} days (last: #{result[:last_sent_at]})" } | |
| else | |
| { ok: true } | |
| end | |
| end | |
| end | |
| end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Hi @patio11 - is there supposed to be an accompanied video to the podcast where you do a screenshare?
Or is it just audio? What is the best way to consume this episode?