Skip to content

Instantly share code, notes, and snippets.

@patio11
Created January 29, 2026 04:43
Show Gist options
  • Select an option

  • Save patio11/f4efb6a71d587d35355c2cefcf89add5 to your computer and use it in GitHub Desktop.

Select an option

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
# 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
@vab2048
Copy link

vab2048 commented Feb 3, 2026

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment