goodmail 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,518 @@
1
+ # frozen_string_literal: true
2
+
3
+ # PayGoodmailer - Production-ready Pay gem + Goodmail integration
4
+ #
5
+ # This mailer provides beautiful, i18n-ready transactional emails for all
6
+ # Pay gem notifications with a centralized approach and comprehensive features.
7
+ #
8
+ # FEATURES:
9
+ # - All 7 Pay notification types (receipt, refund, subscriptions, etc.)
10
+ # - Full i18n support with sensible English defaults
11
+ # - Centralized email rendering logic
12
+ # - Automatic List-Unsubscribe header handling
13
+ # - Receipt PDF attachment support
14
+ # - Extra billing info support
15
+ # - URL helper integration
16
+ #
17
+ # SETUP INSTRUCTIONS:
18
+ #
19
+ # 1. Copy this file to your Rails app:
20
+ # app/mailers/pay_goodmailer.rb
21
+ #
22
+ # 2. Configure Pay to use this mailer in config/initializers/pay.rb:
23
+ #
24
+ # Pay.setup do |config|
25
+ # config.parent_mailer = "ApplicationMailer"
26
+ # config.mailer = "PayGoodmailer"
27
+ # # ... other Pay configuration
28
+ # end
29
+ #
30
+ # 3. Ensure Goodmail is configured in config/initializers/goodmail.rb
31
+ #
32
+ # 4. (Optional) Add i18n translations to config/locales/pay.en.yml
33
+ #
34
+ # 5. Customize URL helpers and email content to match your app
35
+ #
36
+ # SOURCES:
37
+ # - Pay::UserMailer: https://github.com/pay-rails/pay/blob/main/app/mailers/pay/user_mailer.rb
38
+ # - Charge webhooks: https://github.com/pay-rails/pay/blob/main/lib/pay/stripe/webhooks/charge_succeeded.rb
39
+ # - Subscription webhooks: https://github.com/pay-rails/pay/tree/main/lib/pay/stripe/webhooks
40
+ # - Pay::Charge model: https://github.com/pay-rails/pay/blob/main/app/models/pay/charge.rb
41
+ # - Pay configuration: https://github.com/pay-rails/pay/blob/main/docs/2_configuration.md
42
+ #
43
+ class PayGoodmailer < Pay.parent_mailer.constantize
44
+ include Rails.application.routes.url_helpers
45
+
46
+ # Sends a payment receipt email
47
+ #
48
+ # Triggered by: charge.succeeded webhook
49
+ # Params: params[:pay_customer], params[:pay_charge]
50
+ def receipt
51
+ pay_charge = params[:pay_charge]
52
+
53
+ formatted_date = localize_date(pay_charge.created_at)
54
+
55
+ # Capture URLs before the block
56
+ # receipt_link = receipt_url(pay_charge) # Uncomment and customize
57
+
58
+ send_pay_goodmail(:receipt) do
59
+ # Add a friendly GIF (customize the URL to your own!)
60
+ image("https://example.com/mailers/ok.gif", "Payment confirmed!", width: 250)
61
+
62
+ h1 t('pay.mailer.receipt.title', default: 'Payment Received!')
63
+
64
+ text t('pay.mailer.receipt.message',
65
+ application_name: app_name,
66
+ default: "Thanks for your payment! We received your #{app_name} subscription payment. We appreciate your business!"
67
+ )
68
+
69
+ space
70
+ h3 t('pay.mailer.receipt.details_title', default: 'Payment Details')
71
+ price_row t('pay.mailer.receipt.amount', default: 'Amount'), pay_charge.amount_with_currency
72
+ price_row t('pay.mailer.receipt.charged_to', default: 'Charged to'), pay_charge.charged_to
73
+ price_row t('pay.mailer.receipt.date', default: 'Date'), formatted_date
74
+
75
+ # Optional: Add extra billing info if your users have it
76
+ if pay_charge.customer.owner.respond_to?(:extra_billing_info?) && pay_charge.customer.owner.extra_billing_info?
77
+ space
78
+ text pay_charge.customer.owner.extra_billing_info
79
+ end
80
+
81
+ space
82
+ text t('pay.mailer.receipt.reference_info',
83
+ transaction_id: pay_charge.id,
84
+ formatted_date: formatted_date,
85
+ default: "For your records: this payment was processed on #{formatted_date} (Transaction ID: #{pay_charge.id})."
86
+ )
87
+
88
+ # Optional: Add a button to view receipt
89
+ # space
90
+ # button t('pay.mailer.receipt.view_button', default: 'View Receipt'), receipt_link
91
+
92
+ space
93
+ text t('pay.mailer.receipt.questions', default: 'Questions? Just reply to this email — we\'re here to help!')
94
+
95
+ sign
96
+ end
97
+ end
98
+
99
+ # Sends a refund notification email
100
+ #
101
+ # Triggered by: charge.refunded webhook
102
+ # Params: params[:pay_customer], params[:pay_charge]
103
+ def refund
104
+ pay_charge = params[:pay_charge]
105
+
106
+ formatted_date = localize_date(pay_charge.created_at)
107
+
108
+ send_pay_goodmail(:refund) do
109
+ # Add a friendly GIF (customize the URL to your own!)
110
+ image("https://example.com/mailers/ok.gif", "Refund confirmed!", width: 250)
111
+
112
+ h1 t('pay.mailer.refund.title', default: 'Refund Processed')
113
+
114
+ text t('pay.mailer.refund.message',
115
+ application_name: app_name,
116
+ default: "We've processed your refund. The money should be back in your account soon!"
117
+ )
118
+
119
+ space
120
+ h3 t('pay.mailer.refund.details_title', default: 'Refund Details')
121
+ price_row t('pay.mailer.refund.amount', default: 'Refund Amount'), pay_charge.amount_refunded_with_currency
122
+ price_row t('pay.mailer.refund.original_charge', default: 'Original Charge'), pay_charge.amount_with_currency
123
+ price_row t('pay.mailer.refund.date', default: 'Date'), formatted_date
124
+
125
+ space
126
+ text t('pay.mailer.refund.reference_info',
127
+ transaction_id: pay_charge.id,
128
+ default: "Transaction ID: #{pay_charge.id}"
129
+ )
130
+
131
+ space
132
+ text t('pay.mailer.refund.processing_time',
133
+ default: 'The refund will show up on your statement within 5-10 business days, depending on your bank.'
134
+ )
135
+ text t('pay.mailer.refund.questions', default: 'Questions? Just reply to this email!')
136
+
137
+ sign
138
+ end
139
+ end
140
+
141
+ # Sends a subscription renewal reminder
142
+ # (Used for annual subscriptions that will renew soon)
143
+ #
144
+ # Triggered by: invoice.upcoming webhook (for annual subscriptions)
145
+ # Params: params[:pay_customer], params[:pay_subscription], params[:date]
146
+ def subscription_renewing
147
+ pay_subscription = params[:pay_subscription]
148
+ renewal_date = params[:date]
149
+
150
+ formatted_renewal_date = renewal_date ? localize_date(renewal_date) : nil
151
+ days_until_renewal = renewal_date ? (renewal_date.to_date - Date.current).to_i : nil
152
+
153
+ # Capture URLs before the block
154
+ billing_link = billing_url
155
+
156
+ send_pay_goodmail(:subscription_renewing) do
157
+ h1 t('pay.mailer.subscription_renewing.title', default: 'Your Subscription Renews Soon')
158
+
159
+ text t('pay.mailer.subscription_renewing.message',
160
+ application_name: app_name,
161
+ days: days_until_renewal,
162
+ default: "Just a heads up — your #{app_name} subscription will renew in #{days_until_renewal} days."
163
+ )
164
+
165
+ space
166
+ h3 t('pay.mailer.subscription_renewing.details_title', default: 'Subscription Details')
167
+ price_row t('pay.mailer.subscription_renewing.plan', default: 'Plan'), pay_subscription.name
168
+ price_row t('pay.mailer.subscription_renewing.status', default: 'Status'), pay_subscription.status.humanize
169
+
170
+ if formatted_renewal_date
171
+ price_row t('pay.mailer.subscription_renewing.next_billing', default: 'Next Billing Date'), formatted_renewal_date
172
+ end
173
+
174
+ space
175
+ text t('pay.mailer.subscription_renewing.auto_renewal',
176
+ default: 'No action needed — everything will renew automatically. We\'ll charge your payment method on file.'
177
+ )
178
+
179
+ # Optional: Add manage subscription button
180
+ # space
181
+ # button t('pay.mailer.subscription_renewing.manage_button', default: 'Manage Subscription'), billing_link
182
+
183
+ space
184
+ text t('pay.mailer.subscription_renewing.questions', default: 'Questions? Just reply to this email!')
185
+
186
+ sign
187
+ end
188
+ end
189
+
190
+ # Sends a notification when payment action is required
191
+ # (e.g., 3D Secure authentication, expired card)
192
+ #
193
+ # Triggered by: invoice.payment_action_required webhook
194
+ # Params: params[:pay_customer], params[:pay_subscription]
195
+ def payment_action_required
196
+ pay_subscription = params[:pay_subscription]
197
+
198
+ # Capture URLs before the block
199
+ billing_link = billing_url
200
+
201
+ send_pay_goodmail(:payment_action_required) do
202
+ h1 t('pay.mailer.payment_action_required.title', default: 'Quick Action Needed')
203
+
204
+ text t('pay.mailer.payment_action_required.message',
205
+ subscription_name: pay_subscription.name,
206
+ default: "We need your help! Your payment for #{pay_subscription.name} needs additional verification."
207
+ )
208
+
209
+ space
210
+ text t('pay.mailer.payment_action_required.description',
211
+ default: 'This is a security thing (it happens!) — just verify your payment to keep everything running smoothly.'
212
+ )
213
+
214
+ space
215
+ button t('pay.mailer.payment_action_required.action_button', default: 'Complete Payment'), billing_link
216
+
217
+ space
218
+ text t('pay.mailer.payment_action_required.urgency',
219
+ default: 'Try to do this within the next few days so your service doesn\'t get interrupted.'
220
+ )
221
+ text t('pay.mailer.payment_action_required.questions', default: 'Need help? Just reply to this email!')
222
+
223
+ sign
224
+ end
225
+ end
226
+
227
+ # Sends a reminder that the trial period is ending soon
228
+ #
229
+ # Triggered by: customer.subscription.trial_will_end webhook (3 days before trial ends)
230
+ # Params: params[:pay_customer], params[:pay_subscription]
231
+ def subscription_trial_will_end
232
+ pay_subscription = params[:pay_subscription]
233
+
234
+ formatted_trial_end = pay_subscription.trial_ends_at ? localize_date(pay_subscription.trial_ends_at) : nil
235
+ days_remaining = pay_subscription.trial_ends_at ? (pay_subscription.trial_ends_at.to_date - Date.current).to_i : nil
236
+
237
+ # Capture URLs before the block
238
+ billing_link = billing_url
239
+
240
+ send_pay_goodmail(:subscription_trial_will_end) do
241
+ h1 t('pay.mailer.subscription_trial_will_end.title', default: 'Your Trial Ends Soon')
242
+
243
+ text t('pay.mailer.subscription_trial_will_end.message',
244
+ application_name: app_name,
245
+ days: days_remaining,
246
+ default: "Quick reminder — your #{app_name} trial wraps up in #{days_remaining} days."
247
+ )
248
+
249
+ space
250
+ h3 t('pay.mailer.subscription_trial_will_end.details_title', default: 'Trial Details')
251
+ price_row t('pay.mailer.subscription_trial_will_end.plan', default: 'Plan'), pay_subscription.name
252
+
253
+ if formatted_trial_end
254
+ price_row t('pay.mailer.subscription_trial_will_end.trial_ends', default: 'Trial Ends'), formatted_trial_end
255
+ end
256
+
257
+ space
258
+ text t('pay.mailer.subscription_trial_will_end.continue_message',
259
+ default: 'After your trial, your subscription will continue automatically. Just make sure your payment info is current!'
260
+ )
261
+
262
+ # Optional: Add manage subscription button
263
+ # space
264
+ # button t('pay.mailer.subscription_trial_will_end.manage_button', default: 'Manage My Subscription'), billing_link
265
+
266
+ space
267
+ text t('pay.mailer.subscription_trial_will_end.questions', default: 'Questions? Just reply!')
268
+
269
+ sign
270
+ end
271
+ end
272
+
273
+ # Sends a notification that the trial period has ended
274
+ #
275
+ # Triggered by: When trial_ends_at passes (triggered by trial_will_end webhook if trial already ended)
276
+ # Params: params[:pay_customer], params[:pay_subscription]
277
+ def subscription_trial_ended
278
+ pay_subscription = params[:pay_subscription]
279
+
280
+ # Capture URLs before the block
281
+ billing_link = billing_url
282
+ # dashboard_link = dashboard_url
283
+
284
+ send_pay_goodmail(:subscription_trial_ended) do
285
+ h1 t('pay.mailer.subscription_trial_ended.title', default: 'Your Trial Just Ended')
286
+
287
+ text t('pay.mailer.subscription_trial_ended.message',
288
+ application_name: app_name,
289
+ default: "Your #{app_name} trial period is now complete."
290
+ )
291
+
292
+ space
293
+ h3 t('pay.mailer.subscription_trial_ended.details_title', default: 'Subscription Details')
294
+ price_row t('pay.mailer.subscription_trial_ended.plan', default: 'Plan'), pay_subscription.name
295
+ price_row t('pay.mailer.subscription_trial_ended.status', default: 'Status'), pay_subscription.status.humanize
296
+
297
+ space
298
+
299
+ if subscription_continuing_after_trial?(pay_subscription)
300
+ text t('pay.mailer.subscription_trial_ended.continue_message',
301
+ default: 'Thanks for sticking with us! Your subscription is now active and billing normally.'
302
+ )
303
+
304
+ # Optional: Add dashboard button
305
+ # space
306
+ # button t('pay.mailer.subscription_trial_ended.dashboard_button', default: 'Go to My Dashboard'), dashboard_link
307
+ else
308
+ text t('pay.mailer.subscription_trial_ended.inactive_message',
309
+ default: 'Looks like we couldn\'t activate your subscription. Update your payment method to keep going!'
310
+ )
311
+
312
+ space
313
+ button t('pay.mailer.subscription_trial_ended.update_button', default: 'Update Payment Info'), billing_link
314
+ end
315
+
316
+ space
317
+ text t('pay.mailer.subscription_trial_ended.questions', default: 'Need help? Just reply to this email!')
318
+
319
+ sign
320
+ end
321
+ end
322
+
323
+ # Sends a notification when a payment has failed
324
+ #
325
+ # Triggered by: invoice.payment_failed webhook
326
+ # Params: params[:pay_customer], params[:pay_subscription]
327
+ def payment_failed
328
+ pay_subscription = params[:pay_subscription]
329
+
330
+ # Capture URLs before the block
331
+ billing_link = billing_url
332
+
333
+ send_pay_goodmail(:payment_failed) do
334
+ # Add a friendly "uh-oh" GIF (customize the URL to your own!)
335
+ image("https://example.com/mailers/uh-oh.gif", "Uh-oh!", width: 150)
336
+
337
+ h1 t('pay.mailer.payment_failed.title', default: 'Uh-oh! Payment Issue')
338
+
339
+ text t('pay.mailer.payment_failed.message',
340
+ application_name: app_name,
341
+ subscription_name: pay_subscription.name,
342
+ default: "We tried to charge your payment method for #{pay_subscription.name}, but it didn't go through."
343
+ )
344
+
345
+ space
346
+ h3 t('pay.mailer.payment_failed.details_title', default: 'Subscription Details')
347
+ price_row t('pay.mailer.payment_failed.subscription', default: 'Subscription'), pay_subscription.name
348
+
349
+ space
350
+ text t('pay.mailer.payment_failed.reasons_title', default: 'This usually happens because:')
351
+ text t('pay.mailer.payment_failed.reasons',
352
+ default: "• Not enough funds in the account\n• Expired card\n• Your bank declined it\n• Wrong billing address"
353
+ )
354
+
355
+ space
356
+ text t('pay.mailer.payment_failed.action_message',
357
+ default: 'No worries — just update your payment info and you\'re good to go!'
358
+ )
359
+
360
+ space
361
+ button t('pay.mailer.payment_failed.update_button', default: 'Update Payment Info'), billing_link
362
+
363
+ space
364
+ text t('pay.mailer.payment_failed.urgency',
365
+ default: 'Please update it in the next few days so we don\'t have to pause your subscription.'
366
+ )
367
+ text t('pay.mailer.payment_failed.questions', default: 'Need help? Just reply to this email!')
368
+
369
+ sign
370
+ end
371
+ end
372
+
373
+ private
374
+
375
+ # Centralized method to send Pay emails with Goodmail
376
+ # This method:
377
+ # - Gets mail arguments from Pay's configuration
378
+ # - Sets up i18n subject and preheader
379
+ # - Lets Goodmail render the DSL and call Action Mailer's `mail`
380
+ # - Adds List-Unsubscribe / RFC 8058 one-click headers if configured
381
+ # - Attaches receipts for receipt emails
382
+ # - Sends the email via Action Mailer
383
+ def send_pay_goodmail(action_sym, &dsl_block)
384
+ # Ensure pay_customer is set in params (get from subscription if needed)
385
+ # This is necessary because Pay.mail_arguments expects params[:pay_customer] to exist
386
+ if params[:pay_customer].nil? && params[:pay_subscription].present?
387
+ params[:pay_customer] = params[:pay_subscription].customer
388
+ end
389
+
390
+ # Get the mail arguments from Pay's configuration (same as Pay::UserMailer does)
391
+ pay_mail_arguments = instance_exec(&Pay.mail_arguments)
392
+
393
+ # Construct subject with i18n support
394
+ custom_subject = t(
395
+ "pay.mailer.#{action_sym}.subject",
396
+ application_name: app_name,
397
+ default: pay_mail_arguments[:subject] || default_subject_for(action_sym)
398
+ )
399
+
400
+ # Update subject in mail arguments
401
+ pay_mail_arguments[:subject] = custom_subject
402
+
403
+ preheader = t(
404
+ "pay.mailer.#{action_sym}.preheader",
405
+ application_name: app_name,
406
+ default: custom_subject
407
+ )
408
+
409
+ # Pay's optional receipt helper exposes `receipt` plus
410
+ # `receipt_filename` / `filename`; attach only when that helper is
411
+ # actually mixed into the charge object.
412
+ # Source: https://github.com/pay-rails/pay/blob/v11.4.3/lib/pay/receipts.rb#L3-L8
413
+ if action_sym == :receipt && params[:pay_charge]&.respond_to?(:receipt)
414
+ filename =
415
+ if params[:pay_charge].respond_to?(:receipt_filename)
416
+ params[:pay_charge].receipt_filename
417
+ else
418
+ params[:pay_charge].filename
419
+ end
420
+
421
+ attachments[filename] = params[:pay_charge].receipt
422
+ end
423
+
424
+ # Preserve Pay.mail_arguments as the envelope/header source of truth, just
425
+ # as Pay::UserMailer does, and let Goodmail own the Goodmail-specific
426
+ # render keys, multipart body, attachments, and unsubscribe headers.
427
+ #
428
+ # Sources:
429
+ # - Pay::UserMailer calls `mail mail_arguments`:
430
+ # https://github.com/pay-rails/pay/blob/v11.4.3/app/mailers/pay/user_mailer.rb#L2-L38
431
+ # - Pay.mail_arguments default:
432
+ # https://github.com/pay-rails/pay/blob/v11.4.3/lib/pay.rb#L93-L101
433
+ goodmail_mail(pay_mail_arguments, preheader: preheader, &dsl_block)
434
+ end
435
+
436
+ # Avoid depending on Pay's instance predicate here; older Pay versions have
437
+ # had status predicate differences across loaded model code. The email only
438
+ # needs to choose active-vs-inactive copy, so persisted status fields are the
439
+ # stable source of truth.
440
+ # Source: https://github.com/pay-rails/pay/blob/v11.4.3/app/models/pay/subscription.rb#L97-L102
441
+ def subscription_continuing_after_trial?(pay_subscription)
442
+ return false unless %w[active trialing].include?(pay_subscription.status.to_s)
443
+ return true unless pay_subscription.respond_to?(:ends_at) && pay_subscription.ends_at.present?
444
+
445
+ pay_subscription.ends_at.future?
446
+ end
447
+
448
+ # Localize date using i18n
449
+ def localize_date(date)
450
+ I18n.l(date, format: :long)
451
+ rescue
452
+ date.strftime("%B %d, %Y at %I:%M %p")
453
+ end
454
+
455
+ # Get application name from Goodmail config
456
+ def app_name
457
+ Goodmail.config.company_name
458
+ end
459
+
460
+ # i18n helper (delegate to I18n.t)
461
+ def t(key, **options)
462
+ I18n.t(key, **options)
463
+ end
464
+
465
+ # Default subjects for each email type
466
+ def default_subject_for(action_sym)
467
+ {
468
+ receipt: "Receipt for your payment",
469
+ refund: "Refund processed",
470
+ subscription_renewing: "Your subscription will renew soon",
471
+ payment_action_required: "Action required for your payment",
472
+ subscription_trial_will_end: "Your trial is ending soon",
473
+ subscription_trial_ended: "Your trial has ended",
474
+ payment_failed: "Payment failed"
475
+ }[action_sym] || "Notification from #{app_name}"
476
+ end
477
+
478
+ # ============================================================================
479
+ # URL HELPERS - CUSTOMIZE THESE TO MATCH YOUR APP
480
+ # ============================================================================
481
+
482
+ # Generates URL for billing/payment method management
483
+ # CUSTOMIZE THIS to match your app's routing
484
+ def billing_url
485
+ # Example implementations:
486
+ # - billing_url (if you have a billing route)
487
+ # - account_billing_url
488
+ # - edit_user_registration_url(anchor: 'billing')
489
+ # - "https://yourdomain.com/billing"
490
+
491
+ # Default placeholder:
492
+ root_url
493
+ end
494
+
495
+ # Generates URL for dashboard
496
+ # CUSTOMIZE THIS to match your app's routing
497
+ def dashboard_url
498
+ # Example implementations:
499
+ # - dashboard_url
500
+ # - root_url
501
+ # - "https://yourdomain.com/dashboard"
502
+
503
+ # Default placeholder:
504
+ root_url
505
+ end
506
+
507
+ # Generates URL for viewing a receipt
508
+ # CUSTOMIZE THIS to match your app's routing
509
+ def receipt_url(pay_charge)
510
+ # Example implementations:
511
+ # - receipt_url(pay_charge)
512
+ # - charge_url(pay_charge)
513
+ # - "https://yourdomain.com/receipts/#{pay_charge.id}"
514
+
515
+ # Default placeholder:
516
+ root_url
517
+ end
518
+ end