Money Forward Developers Blog

株式会社マネーフォワード公式開発者向けブログです。技術や開発手法、イベント登壇などを発信します。サービスに関するご質問は、各サービス窓口までご連絡ください。

20230215130734

RubyKaigi 2023のマネーフォワードブースで行った、コードレビュー企画の概要と実際コード・コメントを紹介します

こんにちは、マネーフォワードのあちゃです。 今年の RubyKaigi 2023のマネーフォワードのブースでは、消費税計算、ポイント計算、キャッシュレス対応を行うマイクロサービスのサンプルコードをみなさんにレビューしてもらう企画を実施しました!

今回はブースで掲載したコードとレビューコメントの一部を紹介します。ぜひ現地参加できなかった方もレビューしてみてくれると嬉しいです!

1日目:消費税の実装をしました!

オレンジレジをコンビニに導入することになりました。 あなたのチームは購入データからレシートを印刷するのに必要な出力データのマイクロサービスを開発します。取扱商品は食品(消費税・軽減税率)、切手(非課税)、新聞(軽減税率)の 3つです。 アイテムの JANコードは予め登録されているものを使ってください。コンビニではイートインや持ち帰りによって消費税の税率が変わります。切手は非課税で計算しなくてはいけません。支払い手段は現金のみです。 明日リリースのオレンジレジ ver1.0.0のコードレビューをお願いされています。 おかしなところがないかレビューをお願いします!

input => JANコード

出力結果のサンプル (key => value型 )

{
  items: [
    { item: hoge, amount: 123, JAN_code: “” },
    { item: fuga, amount: 456, },
  ],
  tax: 456,
  total_amount: 579,
}

サンプルコードとコメント

サンプルコード

# consumption_tax.rb
# frozen_string_literal: true

# Calculate consumption tax.
module ConsumptionTax
  # 標準税率
  # Standard Consumption Tax = 10%
  TAX_RATE = 0.1
  # 軽減税率
  # Reduced Consumption Tax  =  8%
  REDUCED_TAX_RATE = 0.08

  module_function

  # 消費税(標準税率)込の金額を返す
  # Return the price including consumption tax of standard tax rate
  # @param [Integer] price tax-excluded price
  # @return [Integer] price including consumption tax
  def price_with_tax(price)
    Tax.price_with_tax(price, TAX_RATE)
  end

  # 消費税(軽減税率)込の金額を返す
  # Return the amount including consumption tax of reduced tax rate
  # @param [Integer] price
  # @return [Integer] price including consumption tax
  def price_with_reduced_tax(price)
    Tax.price_with_tax(price, REDUCED_TAX_RATE)
  end
end
# item.rb
# Items for sale
class Item < ActiveRecord::Base
  # Japanese EAN Code
  validates :jan, presence: true, length: { maximum: 13 }
  validates :name, presence: true, length: { maximum: 255 }

  # consumption tax rule
  enum tax_rule: {
    standard_tax_rate: 0, # 10%
    reduced_tax_rate: 1,  #  8%
    non_taxable: 2        #  0%
  }
end
# tax.rb
# frozen_string_literal: true

# Calculate tax.
module Tax
  module_function

  # @param [Integer] price 金額
  # @param [Float] tax_rate 税率
  # @return [Integer] 税込金額
  def price_with_tax(price, tax_rate)
    # tax rounding up
    tax = (price * tax_rate).ceil

    price + tax
  end
end
# receipt.rb
# Receipt
#  output the following
# - Item
# - Consumption tax
# - total price
class Receipt
  attr_reader :items

  # @param [Array<Item>] items
  def initialize(items)
    @items = items
  end

  # @return [Hash] Return the contents of the receipt
  def to_h
    {
      items: items.map do |item|
        { name: item.name, price: item.price, jan: item.jan, tax_rule: item.tax_rule }
      end,
      tax:, total_price_with_tax:
    }
  end

  private

  # amount of consumption tax
  def tax
    total_price_with_tax - total_price
  end

  # total price without tax
  def total_price
    items.inject(0) do |total_price, item|
      total_price + item.price
    end
  end

  # total price with consumption tax
  def total_price_with_tax
    items.inject(0) do |total_price, item|
      total_price +  case item.tax_rule
                     when 'standard_tax_rate'
                       ConsumptionTax.price_with_tax(item.price)
                     when 'reduced_tax_rate'
                       ConsumptionTax.price_with_reduced_tax(item.price)
                     when 'non_taxable'
                       item.price
                     end
    end
  end
end

レビューコメントへのお返事

こんにちは、1日目の問題制作者の ichikawa です。 いくつか頂いたレビューを紹介します。

【1】 What if the tax rate change in future?

これは正しくその通りで、消費税率は将来代わりうるものです。 これを解決するために、マネーフォワードは moneyforward/jct という gem を公開しています。 この gem は消費税の計算を行うためのライブラリで、日付を渡すことで、その当時の税率の消費税を計算することも可能です。 ぜひ、「消費税計算しないといけない!」といった場面に遭遇したらご活用ください!

【2】 誤差が怖いので BigDecimal を使いたい

これは本当に怖いですね…… コメントの通り、BigDecimal や Rational を使った計算を行う必要があります。 マネーフォワードは普段、1円の誤差も許されないお金を扱うシステムを作っているので、この周辺については最大限に注意を払う必要がありますね。

私達の現場でも意識するべき実務的なコメントもたくさん頂くことができ、個人的にも勉強になりました。 コメントしていただいた皆様、ブースに来てくださった皆様、ありがとうございました!

2日目:ポイントの実装をしました!

オレンジポイントをコンビニに導入することになりました。あなたのチームは購入データからポイント計算をするマイクロサービスを 開発をするチームです。 ポイント付与は 100円で 1ポイント付与されます。初回ポイント付与時のみ +500ポイント付与されます。RubyKaigi2023の期間 (5/11~5/13)はポイント 10倍キャンペーンです。ポイントの有効期限は付与された日時から 1年間有効です。ポイントの有効期限は今回の仕様では変更されません。

明日リリースのオレンジポイントver1.0.0のコードレビューをお願いされています。 おかしなところがないかレビューをお願いします!

input => 合計金額 + ポイントカード番号(nullable)

出力結果のサンプル (key => value型 )

{
  :total_point=>3,
  :points=>[
    {:point=>1, :expires_at=>Fri, 26 Apr 2024 23:59:59 +0000}
    {:point=>2, :expires_at=>Fri, 27 Apr 2024 23:59:59 +0000}
  ]
}

サンプルコードとコメント

サンプルコード

# point_calculator.rb
ActiveRecord::Schema.define do
  create_table :points, force: true do |t|
    t.references :point_card, foreign_key: :true
    t.integer :amount, null: false, default: 0
    t.datetime :expires_at, null: false
  end

  create_table :point_cards, force: true do |t|
  end
end

class Point < ActiveRecord::Base
  belongs_to :point_card

  EXPIRE_DAYS = 365

  after_initialize do |point|
    expires_on = Date.today + EXPIRE_DAYS
    point.expires_at = DateTime.new(expires_on.year, expires_on.month, expires_on.day, 23, 59, 59)
  end
end

class PointCard < ActiveRecord::Base
  has_many :points, dependent: :destroy
  has_many :available_points, -> { where('? <= expires_at', DateTime.now) }, class_name: 'Point'
end

class PointCalculator
  RUBYKAIGI_BEGIN_AT = DateTime.new(2023, 5, 11, 0, 0, 0)
  RUBYKAIGI_END_AT = DateTime.new(2023, 5, 13, 23, 59, 59)

  #
  # @param purchase_amount [Integer] A purchase amount without taxes.
  # @param point_card_number [Integer | nil] A customer's point card number.
  #
  def initialize(purchase_amount, point_card_number)
    @amount = 0
    @purchase_amount = purchase_amount
    @point_card_number = point_card_number
  end

  def run
    point_card = PointCard.find_or_initialize_by(id: @point_card_number)

    amount = if DateTime.now >= RUBYKAIGI_BEGIN_AT && DateTime.now <= RUBYKAIGI_END_AT
               @purchase_amount / 10
             else
               @purchase_amount / 100
             end

    amount += 500 if point_card.new_record?

    point_card.points.build(amount: amount)

    point_card.save

    {
      total_point: point_card.available_points.sum(amount),
      points: point_card.available_points.map { |p| { point: p.amount, expires_at: p.expires_at } }
    }
  end
end

レビューコメントへのお返事

こんにちは、2日目の問題制作者の いっせい です。

いくつかコメントを紹介させていただきます。 EXPIRE_DAYS = 365 は閏年の考慮についての指摘を想定していたので、拾ってくださって嬉しかったです。 来年がちょうど2024年で閏年ということに気づき、あえて365日に設定してみました。

また「10円で1ポイント付与となっていて10倍になっていない」というコメントを頂きました。 実はテストコードを書いていたのですが、そのテストケースは見落としていたので思わず「なるほど!」と声に出してしまいました。

他にもコードではなく説明文や仕様のところにもコメント頂くなど、さまざまな視点を伺うことができました。 本当にたくさんのコメントありがとうございました!

3日目:キャッシュレスの実装しました!

コンビニにキャッシュレス決済のオレンジスマペイを導入することになりました。 あなたのチームは購入データからキャッシュレス決済の総額とポイント還元のデータを扱うマイクロサービスを開発するチームです。 キャッシュレス決済で会計をおこなうと最終的な購入金額の 5%が値引きされます。 キャッシュレス決済の残高が不足していた場合はエラーコードとエラーメッセージを返し、決済が行われません。 オートチャージ機能はこの仕様では実装しません。 出力結果に残高の表示はなくてもよいです。

明日リリースのオレンジスマペイ ver1.0.0のコードレビューをお願いされています。 おかしなところがないかレビューをお願いします!

input=>合計金額 +オレンジスマペイID

出力結果のサンプル (key => value型 )

出力結果のサンプル(key => value型)
// success
{
  total_price: 950,
  discount: { amount: 50, rate: 5% },
  error: nil,
}

// failed
{
  total_price: 950,
  discount: { amount: 50, rate: 5% },
  error: { code: “hoge”, message: “残高足りません。” },
}

サンプルコードとコメント

サンプルコード

# orange_sma_pay_account.rb
ActiveRecord::Schema.define do
  create_table :orange_sma_pay_accounts, force: true do |t|
    t.integer :balance, null: false
    t.string :user_name, null: false
  end
end

class OrangeSmaPayAccount < ActiveRecord::Base
  # Returns the discounted amount and, if necessary, an error message
  # @param [Integer] purchase_amount purchase amount
  # @return [Hash] discounted amount, error messages
  #
  # 割引後の金額と、必要に応じてエラーメッセージを返す
  # @param [Integer] purchase_amount 合計金額
  # @return [Hash] 割引後の金額、エラーメッセージ
  def settlement(purchase_amount)
    total_price = purchase_amount - purchase_amount * (5.to_f / 100)
    error = balance - total_price >= 0 ? nil : { code: 'ERR-01', message: '残高が足りません。' }

    {
      total_price: total_price,
      discount: { amount: purchase_amount * (5.to_f / 100), rate: '5%' },
      error: error
    }
  end
end

レビューコメントへのお返事

こんにちは、3日目の問題制作者の田場です。 いくつか頂いたレビューを紹介します。

【1】users テーブルの外部キーにしたいですね

そもそもこのモデルに user_name というカラムが必要なのか、Account と User は別でモデルとして存在しているものなのか等、やや前提が分かりづらいところでしたが、テーブル定義に関して沢山コメントを頂けました。コードを書くよりも前に、設計していて気づいて欲しいところではありますが、実際に Schemafile に落とし込んでみていざPRを出してみるとレビューでコメントを貰った経験がある人は多いのではないでしょうか(僕も多々もらいます... ><;)

【2】5%(割引率)を定数にする

個人的にはコメントの通り僕も定数等に書くと思います。定数でもそれ専用の Object を用意してもどちらでもいいですが、これから変わる可能性がある値は、ベタ書きするのは避けておきたいところですね。

参加者の方とお話させていただくと、そもそも、このサービスの他に必要になるモデルとの関連が見えないので、この OrangeSmaPayAccount テーブルの設計をどう評価したら良いかわからないという声もありました。後から見比べてみると、前提となるコードが1日目や2日目のコード例と比べると少なかったかもなと思ったので、ここは次回の出題に活かしたいなと思いました。

コメントしてくれた参加者の皆様、ありがとうございました!

まとめ

今回3日間を通して、100を超えるコメントをいただきました🎉何度もブースに来てくれた方も多く、本当にありがとうございました。 またコードを見ながら、国籍問わずRubyist同士の技術的な雑談や交流が生まれている様子を見て、本当に今回の企画を実施してよかったなと思っています。(コードを作ってくれた3人に大感謝です)

当初このコードを公開する予定はなかったのですが、RubyKaigiの際「社内のエンジニアともレビューをしてみたい!」という声をもらい公開しました。 ぜひさまざまな場でRubyist同士の交流が生まれるきっかけになれば嬉しいです。