こんにちは、マネーフォワードのあちゃです。 今年の 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同士の交流が生まれるきっかけになれば嬉しいです。