From 157bd20b26f5233c3b5a38ee66e4aa34827f9ca7 Mon Sep 17 00:00:00 2001 From: Eric Schultz Date: Thu, 21 Jan 2021 13:18:39 -0600 Subject: [PATCH] Recurrence#to_builder works and unit tested --- app/models/recurrence.rb | 132 ++++++++++++++++++ app/models/recurring_donation.rb | 2 + .../20210602220525_create_recurrences.rb | 11 ++ db/schema.rb | 23 ++- .../Nonprofit/Transaction/Donation.ts | 8 +- .../Nonprofit/Transaction/index.ts | 3 + docs/event_definitions/Recurrence.ts | 35 +++++ docs/event_definitions/common.ts | 2 +- spec/factories/donations.rb | 18 ++- spec/factories/recurrences.rb | 17 +++ spec/factories/recurring_donations.rb | 6 + spec/factories/supporters.rb | 5 + spec/models/recurrence_spec.rb | 102 ++++++++++++++ spec/support/expect.rb | 4 + 14 files changed, 364 insertions(+), 4 deletions(-) create mode 100644 app/models/recurrence.rb create mode 100644 db/migrate/20210602220525_create_recurrences.rb create mode 100644 docs/event_definitions/Recurrence.ts create mode 100644 spec/factories/recurrences.rb create mode 100644 spec/models/recurrence_spec.rb diff --git a/app/models/recurrence.rb b/app/models/recurrence.rb new file mode 100644 index 00000000..bc15623a --- /dev/null +++ b/app/models/recurrence.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +# License: AGPL-3.0-or-later WITH WTO-AP-3.0-or-later +# Full license explanation at https://github.com/houdiniproject/houdini/blob/master/LICENSE +class Recurrence < ApplicationRecord + + include Model::Houidable + include Model::Jbuilder + include Model::Eventable + + after_initialize :set_start_date_if_needed + + belongs_to :recurring_donation + belongs_to :supporter + + has_one :nonprofit, through: :supporter + + delegate :currency, to: :nonprofit + + delegate :designation, :dedication, to: :recurring_donation + + validates :recurrences, presence: true + + def trx_assignments + [{ + assignment_object: 'donation', + amount: amount || 0, + dedication: dedication, + designation: designation + }.with_indifferent_access] + end + + def recurrences + [ + { + interval: recurring_donation.interval, + type: from_recurring_time_unit_to_recurrence(recurring_donation.time_unit) + } + ] + end + + def invoice_template + { + amount: amount || 0, + trx_assignments: trx_assignments, + supporter: supporter, + payment_method: {type: 'stripe'} + } + end + + concerning :JBuilder do + included do + setup_houid :recur + end + + def to_builder(*expand) + init_builder(*expand) do |json| + json.start_date start_date.to_i + + json.add_builder_expansion :nonprofit, :supporter + + json.start_date start_date + + json.recurrences recurrences do |rec| + json.(rec, :interval, :type) + end + + json.invoice_template do + json.amount do + json.cents amount || 0 + json.currency currency + end + + json.supporter supporter.id + + json.payment_method do + json.type 'stripe' + end + + json.trx_assignments trx_assignments do |assign| + json.assignment_object assign[:assignment_object] + dedication = assign[:dedication] + + json.dedication do + json.type dedication['type'] + json.name dedication['name'] + contact = dedication['contact'] + json.note dedication['note'] + json.contact do + json.email contact['email'] if contact['email'] + json.address contact['address'] if contact['address'] + json.phone contact['phone'] if contact['phone'] + end if contact + end if dedication + + json.designation assign[:designation] + + json.amount do + json.cents assign[:amount] || 0 + json.currency currency + end + end + end + + end + end + + + def publish_created + Houdini.event_publisher.announce( + :recurrence_created, + to_event('recurrence.created', :nonprofit, :trx, :supporter).attributes! + ) + end + end + + private + + def set_start_date_if_needed + self[:start_date] = Time.current unless self[:start_date] + end + + private + + def from_recurring_time_unit_to_recurrence(time_unit) + { + 'month' => 'monthly', + 'year' => 'yearly' + }[time_unit] + end +end + diff --git a/app/models/recurring_donation.rb b/app/models/recurring_donation.rb index 807811dc..1d676594 100644 --- a/app/models/recurring_donation.rb +++ b/app/models/recurring_donation.rb @@ -39,6 +39,8 @@ class RecurringDonation < ApplicationRecord validates :time_unit, presence: true, inclusion: { in: Timespan::Units } validates_associated :donation + delegate :designation, :dedication, to: :donation + def most_recent_charge charges&.max_by(&:created_at) end diff --git a/db/migrate/20210602220525_create_recurrences.rb b/db/migrate/20210602220525_create_recurrences.rb new file mode 100644 index 00000000..62fa0ccc --- /dev/null +++ b/db/migrate/20210602220525_create_recurrences.rb @@ -0,0 +1,11 @@ +class CreateRecurrences < ActiveRecord::Migration[6.1] + def change + create_table :recurrences, id: :string do |t| + t.integer "amount", null: false + t.references :recurring_donation, foreign_key: true, null: false + t.references :supporter, foreign_key: true, null: false, id: :string + t.datetime "start_date", comment: 'the moment that the recurrence should start. Could be earlier than created_at if this was imported.' + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 37cf24e2..b7ed6b11 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_05_06_202607) do +ActiveRecord::Schema.define(version: 2021_06_02_220525) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" @@ -707,6 +707,17 @@ ActiveRecord::Schema.define(version: 2021_05_06_202607) do t.string "country", limit: 255, default: "US" end + create_table "recurrences", id: :string, force: :cascade do |t| + t.integer "amount", null: false + t.bigint "recurring_donation_id", null: false + t.bigint "supporter_id", null: false + t.datetime "start_date", comment: "the moment that the recurrence should start. Could be earlier than created_at if this was imported." + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["recurring_donation_id"], name: "index_recurrences_on_recurring_donation_id" + t.index ["supporter_id"], name: "index_recurrences_on_supporter_id" + end + create_table "recurring_donations", id: :serial, force: :cascade do |t| t.boolean "active" t.integer "paydate" @@ -789,6 +800,13 @@ ActiveRecord::Schema.define(version: 2021_05_06_202607) do t.index ["payment_id"], name: "index_stripe_charges_on_payment_id" end + create_table "stripe_refunds", id: :string, force: :cascade do |t| + t.bigint "payment_id" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["payment_id"], name: "index_stripe_refunds_on_payment_id" + end + create_table "stripe_transactions", id: :string, force: :cascade do |t| t.integer "amount", null: false t.datetime "created_at", precision: 6, null: false @@ -1021,7 +1039,10 @@ ActiveRecord::Schema.define(version: 2021_05_06_202607) do add_foreign_key "modern_campaign_gifts", "campaign_gifts" add_foreign_key "object_event_hook_configs", "nonprofits" add_foreign_key "offline_transaction_charges", "payments" + add_foreign_key "recurrences", "recurring_donations" + add_foreign_key "recurrences", "supporters" add_foreign_key "stripe_charges", "payments" + add_foreign_key "stripe_refunds", "payments" add_foreign_key "subtransaction_payments", "subtransactions" add_foreign_key "subtransactions", "transactions" add_foreign_key "ticket_purchases", "event_discounts" diff --git a/docs/event_definitions/Nonprofit/Transaction/Donation.ts b/docs/event_definitions/Nonprofit/Transaction/Donation.ts index a8265732..3e88c6b7 100644 --- a/docs/event_definitions/Nonprofit/Transaction/Donation.ts +++ b/docs/event_definitions/Nonprofit/Transaction/Donation.ts @@ -1,5 +1,5 @@ // License: LGPL-3.0-or-later -import type { HoudiniEvent } from "../../common"; +import type { Amount, HoudiniEvent } from "../../common"; import type { TrxAssignment } from './'; interface Dedication { @@ -19,6 +19,12 @@ export interface Donation extends TrxAssignment { object: 'donation'; } +export interface CreateDonation { + amount?: Amount; + dedication?: Dedication | null; + designation?: string | null; +} + export type DonationCreated = HoudiniEvent<'donation.created', Donation>; export type DonationUpdated = HoudiniEvent<'donation.updated', Donation>; export type DonationDeleted = HoudiniEvent<'donation.deleted', Donation>; diff --git a/docs/event_definitions/Nonprofit/Transaction/index.ts b/docs/event_definitions/Nonprofit/Transaction/index.ts index 82d9ec2c..d9c72962 100644 --- a/docs/event_definitions/Nonprofit/Transaction/index.ts +++ b/docs/event_definitions/Nonprofit/Transaction/index.ts @@ -4,6 +4,7 @@ import type Nonprofit from '../'; import type Supporter from "../Supporter"; import type { Payment, PaymentAsId } from "./Payment"; import type { SubtransactionAsId, Subtransaction } from "./Subtransaction"; +import type { CreateDonation } from "./Donation"; /** * Every descendent of a Transaction object will have the following three fields @@ -56,4 +57,6 @@ export * from './Payment'; export * from './Donation'; export * from './Subtransaction'; export * as OfflineTransactionTypes from './OfflineTransaction'; + +export type CreateTrxAssignment = {assignment_object: string} & CreateDonation; export {default as OfflineTransaction} from './OfflineTransaction'; diff --git a/docs/event_definitions/Recurrence.ts b/docs/event_definitions/Recurrence.ts new file mode 100644 index 00000000..c6bb9a18 --- /dev/null +++ b/docs/event_definitions/Recurrence.ts @@ -0,0 +1,35 @@ +// License: LGPL-3.0-or-later +import type { Amount, HoudiniEvent, HoudiniObject, HouID, IDType } from "./common"; +import type Nonprofit from './Nonprofit'; +import type { RecurrenceRule } from "./common"; +import type Supporter from "./Nonprofit/Supporter"; +import type { CreateTrxAssignment } from "./Nonprofit/Transaction"; + +export interface InvoiceTemplate { + amount: Amount; + payment_method: { + /** will be added in future but not yet. */ + id: never; + type: 'stripe'; + }; + supporter: IDType; + + /** + * The assignments created if the invoice succeeds. For now, we can only create new donations. + */ + trx_assignments: CreateTrxAssignment[]; +} + + +export interface Recurrence extends HoudiniObject { + nonprofit: IDType | Nonprofit; + object: 'recurrence'; + recurrences: RecurrenceRule[]; + start_date: number; + supporter: IDType | Supporter; + template: InvoiceTemplate; +} + +export type RecurrenceCreated = HoudiniEvent<'recurrence.created', Recurrence>; +export type RecurrenceUpdated = HoudiniEvent<'recurrence.updated', Recurrence>; +export type RecurrenceDeleted = HoudiniEvent<'recurrence.deleted', Recurrence>; diff --git a/docs/event_definitions/common.ts b/docs/event_definitions/common.ts index 1dfe9209..1fcd5b1f 100644 --- a/docs/event_definitions/common.ts +++ b/docs/event_definitions/common.ts @@ -38,7 +38,7 @@ export type FlexibleAmount = Amount | string | number; */ export type RecurrenceRule = { /** - * The number of times we should run the recurrence + * The number of times we should run the recurrence. */ count?: number; /** diff --git a/spec/factories/donations.rb b/spec/factories/donations.rb index 8ba012c2..f386bac3 100644 --- a/spec/factories/donations.rb +++ b/spec/factories/donations.rb @@ -4,5 +4,21 @@ # Full license explanation at https://github.com/houdiniproject/houdini/blob/master/LICENSE FactoryBot.define do factory :donation do - end + factory :donation_with_dedication_designation do + dedication { { + contact: { + email: 'email@ema.com' + }, + name: 'our loved one', + note: "we miss them dearly", + type: 'memory' + } } + designation { 'designated for soup kitchen'} + + nonprofit {association :fv_poverty} + + supporter { association :supporter} + amount {500} + end + end end diff --git a/spec/factories/recurrences.rb b/spec/factories/recurrences.rb new file mode 100644 index 00000000..7e3e34fd --- /dev/null +++ b/spec/factories/recurrences.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# License: AGPL-3.0-or-later WITH WTO-AP-3.0-or-later +# Full license explanation at https://github.com/houdiniproject/houdini/blob/master/LICENSE +FactoryBot.define do + factory :recurrence do + supporter { association :supporter_with_fv_poverty} + recurring_donation do + association( :rd_with_dedication_designation, + nonprofit: supporter.nonprofit, + supporter: supporter, + donation: association(:donation_with_dedication_designation, nonprofit: supporter.nonprofit, supporter: supporter) + ) + end + amount { 500 } + end +end diff --git a/spec/factories/recurring_donations.rb b/spec/factories/recurring_donations.rb index f7e5b098..7d227e71 100644 --- a/spec/factories/recurring_donations.rb +++ b/spec/factories/recurring_donations.rb @@ -4,5 +4,11 @@ # Full license explanation at https://github.com/houdiniproject/houdini/blob/master/LICENSE FactoryBot.define do factory :recurring_donation do + factory :rd_with_dedication_designation do + nonprofit {association :fv_poverty} + start_date { Time.current} + interval { 1} + time_unit {"month"} + end end end diff --git a/spec/factories/supporters.rb b/spec/factories/supporters.rb index bf3bec90..a4e2f17b 100644 --- a/spec/factories/supporters.rb +++ b/spec/factories/supporters.rb @@ -12,4 +12,9 @@ FactoryBot.define do end end end + + factory :supporter_with_fv_poverty, class: 'Supporter' do + name { 'Fake Supporter Name' } + nonprofit { association :fv_poverty} + end end diff --git a/spec/models/recurrence_spec.rb b/spec/models/recurrence_spec.rb new file mode 100644 index 00000000..e78b10ef --- /dev/null +++ b/spec/models/recurrence_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +# License: AGPL-3.0-or-later WITH WTO-AP-3.0-or-later +# Full license explanation at https://github.com/houdiniproject/houdini/blob/master/LICENSE +require 'rails_helper' + +RSpec.describe Recurrence, type: :model do + around(:each) do |example| + Timecop.freeze(2020,5,4) do + example.run + end + end + + describe 'recurrence with donations, designation and dedication' do + subject { create(:recurrence) } + + def trx_assignment_match + { + assignment_object: 'donation', + amount: 500, + dedication: { + contact: { + email: 'email@ema.com', + }, + name: 'our loved one', + note: "we miss them dearly", + type: 'memory' + }, + designation: 'designated for soup kitchen' + } + end + + def trx_assignment_json + trx_assignment_match.merge({amount: {cents: 500, currency: 'usd'}}) + end + + def invoice_template_match + { + + amount: 500, + supporter: an_instance_of(Supporter), + payment_method: { + type: 'stripe' + }, + trx_assignments: [trx_assignment_match] + } + end + + def invoice_template_json + invoice_template_match.merge(trx_assignments: [trx_assignment_json]).deep_stringify_keys + end + + it {is_expected.to have_attributes( + supporter: an_instance_of(Supporter), + nonprofit: an_instance_of(Nonprofit), + start_date: Time.current, + id: match_houid('recur') + )} + + it {is_expected.to have_attributes( recurrences: contain_exactly( + { + interval: 1, + type: 'monthly' + } + ) + )} + + it {is_expected.to be_persisted} + + it do + is_expected.to have_attributes( + invoice_template: invoice_template_match + ) + end + + describe '.to_builder' do + let(:recurrence) { create(:recurrence)} + subject { JSON.parse(recurrence.to_builder.target!)} + + let(:invoice_template) { subject['invoice_template']} + + it do is_expected.to match_json({ + object: 'recurrence', + nonprofit: kind_of(Numeric), + supporter: kind_of(Numeric), + id: match_houid('recur'), + start_date: Time.current, + recurrences: [ + { + interval: 1, + type: 'monthly' + }], + invoice_template: { supporter: kind_of(Numeric), + amount: {'cents' => 500, 'currency' => 'usd'}, + payment_method: {'type' => 'stripe'}, + trx_assignments: [trx_assignment_json] + } + }) + end + end + end +end diff --git a/spec/support/expect.rb b/spec/support/expect.rb index b9f6feb3..f080a20c 100644 --- a/spec/support/expect.rb +++ b/spec/support/expect.rb @@ -22,4 +22,8 @@ module Expect def match_houid(prefix) match(/#{prefix}_[a-zA-Z0-9]{22}/) end + + def match_json(**args) + match(args.deep_stringify_keys) + end end