Skip to content

Commit 9e19bb0

Browse files
authored
Merge pull request #2333 from openstax/bookstore-purchases
generate payment codes for students
2 parents c42e7bb + 024b481 commit 9e19bb0

File tree

15 files changed

+349
-1
lines changed

15 files changed

+349
-1
lines changed

app/controllers/admin/payments_controller.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,27 @@ def extend_payment_due_at
1616
redirect_to admin_payments_path, notice: "Extended payment due dates"
1717
end
1818

19+
def generate_payment_codes
20+
params.require(:prefix)
21+
params.require(:amount)
22+
23+
generator = GeneratePaymentCodes.call(
24+
prefix: params[:prefix],
25+
amount: params[:amount].to_i,
26+
generate_csv: true
27+
).outputs
28+
29+
if generator.errors.any?
30+
flash[:error] = generator.errors
31+
redirect_to admin_payments_path
32+
else
33+
send_data generator.csv,
34+
filename: "payment-codes-#{SecureRandom.uuid}.csv"
35+
end
36+
end
37+
38+
def download_payment_code_report
39+
send_data GeneratePaymentCodeReport.call.outputs.csv,
40+
filename: "payment-code-report-#{SecureRandom.uuid}.csv"
41+
end
1942
end

app/models/payment_code.rb

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
class PaymentCode < IndestructibleRecord
2+
CONFUSED_CHARS = %w(B 8 G 6 I 1 O 0 S 5 Z 2)
3+
4+
validates_presence_of :code
5+
validates_uniqueness_of :code
6+
7+
before_validation :set_code, on: :create
8+
after_rollback :handle_collision
9+
10+
before_update :preserve_persisted_code
11+
12+
belongs_to :student, subsystem: :course_membership, inverse_of: :payment_codes, optional: true
13+
14+
attr_accessor :prefix
15+
16+
private
17+
18+
def preserve_persisted_code
19+
throw_persisted_error if code_changed?
20+
end
21+
22+
def code=(value)
23+
throw_persisted_error if persisted?
24+
super
25+
end
26+
27+
def generate_code
28+
unless prefix.present?
29+
errors.add :prefix, :blank
30+
raise ActiveRecord::RecordInvalid
31+
end
32+
33+
base = [*'0'..'9', *'A'..'Z'] - CONFUSED_CHARS
34+
post = Array.new(10) { base.sample }.join
35+
"#{prefix.parameterize.upcase}-#{post}"
36+
end
37+
38+
def set_code
39+
throw_persisted_error if persisted?
40+
self.code = generate_code
41+
end
42+
43+
def handle_collision
44+
if errors.types[:code]&.include?(:taken)
45+
set_code
46+
save
47+
end
48+
end
49+
50+
def throw_persisted_error
51+
throw :cannot_change_persisted_code
52+
end
53+
end
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
class GeneratePaymentCodeReport
2+
lev_routine express_output: :csv
3+
4+
def exec(since: 1.year.ago)
5+
outputs.csv = CSV.generate(headers: true) do |csv|
6+
csv << [
7+
'Code',
8+
'Redeemed At',
9+
'Course UUID',
10+
'Student Tutor ID',
11+
'Student Identifier'
12+
]
13+
14+
range = since.midnight..DateTime::Infinity.new
15+
16+
PaymentCode.where(created_at: range).in_batches.each_record do |pc|
17+
csv << [
18+
pc.code,
19+
pc.redeemed_at,
20+
pc&.student&.course&.id,
21+
pc&.student&.id,
22+
pc&.student&.student_identifier
23+
]
24+
end
25+
end
26+
end
27+
end
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
class GeneratePaymentCodes
2+
lev_routine express_output: :codes, transaction: :read_committed
3+
4+
protected
5+
6+
def exec(prefix:, amount: 1, generate_csv: false)
7+
codes = []
8+
errors = []
9+
total_retries = 0
10+
11+
unless amount.is_a?(Integer) && amount > 0 && amount < 1000
12+
outputs.errors = ['Amount must be a whole number between 1 and 999']
13+
return
14+
end
15+
16+
amount.times do
17+
retries = 0
18+
pc = PaymentCode.new(prefix: prefix)
19+
20+
begin
21+
pc.save!
22+
codes << pc.code
23+
rescue ActiveRecord::RecordInvalid
24+
if retries < 3
25+
retries += 1
26+
total_retries += 1
27+
retry
28+
end
29+
30+
errors << pc.errors
31+
end
32+
end
33+
34+
outputs.retries = total_retries
35+
outputs.codes = codes
36+
outputs.errors = errors.map(&:full_messages).uniq
37+
outputs.csv = generate_csv(codes) if generate_csv && errors.empty?
38+
end
39+
40+
def generate_csv(codes)
41+
CSV.generate(headers: true) do |csv|
42+
csv << ['Code']
43+
codes.map {|c| csv << [c] }
44+
end
45+
end
46+
end

app/subsystems/course_membership/models/student.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ class CourseMembership::Models::Student < ApplicationRecord
1919
has_many :research_cohorts, through: :research_cohort_members, source: :cohort,
2020
subsystem: :research, class_name: 'Research::Models::Cohort'
2121

22+
has_many :payment_codes, inverse_of: :student, class_name: 'PaymentCode'
23+
2224
before_validation :init_first_paid_at
2325

2426
validates :role, uniqueness: true

app/views/admin/payments/index.html.erb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,30 @@ time zone).</p>
1212
class: 'btn btn-primary',
1313
data: { confirm: "Are you sure you want to extend a whole bunch of due dates?"} %>
1414
<% end %>
15+
16+
<h3>Bookstore Payment Codes</h3>
17+
18+
<%= form_tag(generate_payment_codes_admin_payments_path, method: :post) do %>
19+
<div class="row">
20+
<div class="col-xs-12">
21+
<div class="form-group">
22+
<label for="prefix">Prefix</label>
23+
<%= text_field_tag 'prefix', nil, class: 'form-control', required: 'required' %>
24+
</div>
25+
</div>
26+
<div class="col-xs-12">
27+
<div class="form-group">
28+
<label for="amount">Amount</label>
29+
<%= number_field_tag 'amount', nil, min: 1, max: 999, class: 'form-control' %>
30+
</div>
31+
</div>
32+
</div>
33+
<%= submit_tag 'Generate Codes', class: 'btn btn-primary' %>
34+
<% end %>
35+
36+
<hr>
37+
38+
<%= button_to 'Download Usage Report',
39+
download_payment_code_report_admin_payments_path,
40+
method: 'get',
41+
class: 'btn btn-primary' %>

config/routes.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,8 @@
322322
resources :payments, only: :index do
323323
collection do
324324
put :extend_payment_due_at
325+
post :generate_payment_codes
326+
get :download_payment_code_report
325327
end
326328
end
327329

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
class CreatePaymentCodes < ActiveRecord::Migration[5.2]
2+
def change
3+
create_table :payment_codes do |t|
4+
t.string :code
5+
t.datetime :redeemed_at
6+
t.references :course_membership_student
7+
8+
t.timestamps
9+
t.index :code, unique: true
10+
end
11+
end
12+
end

db/schema.rb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
#
1111
# It's strongly recommended that you check this file into your version control system.
1212

13-
ActiveRecord::Schema.define(version: 2021_06_22_152149) do
13+
ActiveRecord::Schema.define(version: 2021_06_23_225308) do
1414

1515
create_sequence "active_storage_attachments_id_seq"
1616
create_sequence "active_storage_blobs_id_seq"
@@ -54,6 +54,7 @@
5454
create_sequence "oauth_applications_id_seq"
5555
create_sequence "openstax_accounts_accounts_id_seq"
5656
create_sequence "openstax_salesforce_users_id_seq"
57+
create_sequence "payment_codes_id_seq"
5758
create_sequence "ratings_exercise_group_book_parts_id_seq"
5859
create_sequence "ratings_period_book_parts_id_seq"
5960
create_sequence "ratings_role_book_parts_id_seq"
@@ -713,6 +714,16 @@
713714
t.datetime "updated_at"
714715
end
715716

717+
create_table "payment_codes", force: :cascade do |t|
718+
t.string "code"
719+
t.datetime "redeemed_at"
720+
t.bigint "course_membership_student_id"
721+
t.datetime "created_at", null: false
722+
t.datetime "updated_at", null: false
723+
t.index ["code"], name: "index_payment_codes_on_code", unique: true
724+
t.index ["course_membership_student_id"], name: "index_payment_codes_on_course_membership_student_id"
725+
end
726+
716727
create_table "ratings_exercise_group_book_parts", force: :cascade do |t|
717728
t.uuid "exercise_group_uuid", null: false
718729
t.uuid "book_part_uuid", null: false

spec/factories/payment_codes.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
FactoryBot.define do
2+
factory :payment_code, class: 'PaymentCode' do
3+
prefix { 'ABC' }
4+
association :student, factory: :course_membership_student
5+
end
6+
end

spec/models/payment_code_spec.rb

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
require 'rails_helper'
2+
3+
RSpec.describe PaymentCode, type: :model do
4+
subject(:pc) { FactoryBot.create :payment_code }
5+
6+
context 'with a new record' do
7+
it 'generates codes' do
8+
new_pc = described_class.new(prefix: 'NEW')
9+
new_pc.save
10+
expect(new_pc.code).to match(/^NEW\-[a-zA-Z0-9]{10}$/)
11+
end
12+
13+
it 'requires a prefix' do
14+
new_pc = described_class.new(prefix: '')
15+
expect { new_pc.save }.to raise_error(ActiveRecord::RecordInvalid)
16+
expect(new_pc.errors.types).to include(:prefix)
17+
end
18+
19+
it 'regenerates a code when a collision occurs' do
20+
new_pc = described_class.new
21+
allow(new_pc).to receive(:generate_code).and_return(pc.code, pc.code, 'new-code')
22+
new_pc.save
23+
expect(new_pc).to have_received(:generate_code).at_least(:thrice)
24+
expect(new_pc.code).not_to eq(pc.code)
25+
end
26+
end
27+
28+
context 'with a persisted record' do
29+
it 'cannot change a code once persisted' do
30+
expect { pc.send(:code=, 'test') }.to throw_symbol(:cannot_change_persisted_code)
31+
32+
expect {
33+
pc.redeemed_at = 1.day.ago
34+
pc.save
35+
}.not_to change{ pc.code }.from(pc.code)
36+
37+
expect {
38+
pc.write_attribute(:code, 'changed')
39+
pc.save
40+
}.to throw_symbol(:cannot_change_persisted_code)
41+
42+
expect { pc.send(:set_code) }.to throw_symbol(:cannot_change_persisted_code)
43+
end
44+
end
45+
end

spec/requests/admin/payments_controller_spec.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,24 @@
3434
end
3535
end
3636
end
37+
38+
context 'generating payment codes' do
39+
it 'downloads a CSV file' do
40+
expect {
41+
post generate_payment_codes_admin_payments_path, params: { prefix: 'abc', amount: 20 }
42+
}.to change { PaymentCode.count }.by 20
43+
44+
expect(response.body).to match('Code')
45+
end
46+
47+
it 'downloads a report' do
48+
get download_payment_code_report_admin_payments_path
49+
expect(response.body).to match('Code')
50+
end
51+
52+
it 'returns errors' do
53+
post generate_payment_codes_admin_payments_path, params: { prefix: 'ABC', amount: 'A' }
54+
expect(flash[:error]).to eq(["Amount must be a whole number between 1 and 999"])
55+
end
56+
end
3757
end
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
require 'rails_helper'
2+
3+
RSpec.describe GeneratePaymentCodeReport, type: :routine do
4+
context 'when generating a report on code use' do
5+
it 'generates an inline csv with codes and other data' do
6+
date = 1.day.ago
7+
pc = FactoryBot.create(:payment_code, redeemed_at: date)
8+
rows = CSV.parse(described_class.call.outputs.csv)
9+
expect(rows[0]).to eq(['Code',
10+
'Redeemed At',
11+
'Course UUID',
12+
'Student Tutor ID',
13+
'Student Identifier'])
14+
expect(rows[1]).to eq ([pc.code,
15+
date.to_s,
16+
pc.student.course.id.to_s,
17+
pc.student.id.to_s,
18+
pc.student.student_identifier])
19+
end
20+
21+
it 'can filter by a beginning date' do
22+
older_code = FactoryBot.create(:payment_code, created_at: 3.days.ago).code
23+
newer_code = FactoryBot.create(:payment_code, created_at: 1.day.ago).code
24+
rows = CSV.parse(described_class.call(since: 2.days.ago).outputs.csv)
25+
expect(rows.length).to eq(2)
26+
expect(rows[1][0]).to eq(newer_code)
27+
28+
rows = CSV.parse(described_class.call(since: 4.days.ago).outputs.csv)
29+
expect(rows.length).to eq(3)
30+
expect(rows[1][0]).to eq(older_code)
31+
expect(rows[2][0]).to eq(newer_code)
32+
end
33+
end
34+
end

0 commit comments

Comments
 (0)