Skip to content

Instantly share code, notes, and snippets.

@gravis
Created August 6, 2012 08:06
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save gravis/3272178 to your computer and use it in GitHub Desktop.
Save gravis/3272178 to your computer and use it in GitHub Desktop.
A secure event tracking system for online betting in France
# Copyright © 2010-2011 Tech-Angels. All Rights Reserved.
# CollectorTransaction will be created each time the Collector needs
# to trace an activity.
#
# Attributes:
# * id [integer, primary, not null] - primary key
# * before_tr [binary] - associated model serialized before transaction
# * created_at [datetime] - creation time
# * model_id [integer] - belongs_to Model (polymorphic)
# * model_type [text] - belongs_to Model (polymorphic)
# * response_code [integer] - HTTP response code from the vault
# * status [text, not null] - state machine status (see below)
# * trace_type [text, not null] - type of trace that was sent to the vault
# (ie PAHIMISE)
# * updated_at [datetime] - last update time
# * xml_trace [xml] - full XML that was sent to the vault
#
class CollectorTransaction < ActiveRecord::Base
MAX_ATTEMPTS = 5
acts_as_archive :indexes => [ [:model_id, :model_type], :response_code, :trace_type ], :quick => true
belongs_to :model, :polymorphic => true, :autosave => false
after_initialize :after_initialize_method
attr_accessible :model_id, :model_type, :trace_type
# +attempt+ is used to retry failed transactions
attr_accessor_with_default :attempt, 1
attr_accessor :prev_model
# Temporary variables
# block : block code to execute during transaction
# request : The request used to vault
attr_accessor :block, :on_complete, :dont_enqueue, :extra_params, :request
attr_readonly :before_tr, :trace_type
validates :trace_type, :inclusion => {:in =>
%(OUVINFOPERSO PREFCPTE OKCONDGENE OUVOKCONFIRME ACCESREFUSE
MODIFINFOPERSO AUTOINTERDICTION CLOTUREDEM
CPTEALIM CPTEABOND CPTERETRAIT CPTEALIMOPE
LOTNATURE
PAHIMISE PAHIGAIN PAHIANNUL)}
before_create :serialize_model
scope :stuck, lambda {{:conditions => ["created_at < ? and status = 'pending'", 2.minutes.ago]}}
state_machine :status, :initial => :pending do
event :send_to_vault do
transition :pending => :sent
end
event :cancel do
transition :pending => :cancelled
end
event :rollback do
transition :pending => :rollbacked
end
before_transition :on => :rollback, :do => :restore_model
after_transition :on => :rollback, :do => :cancel_dependent_transactions
before_transition :on => :send_to_vault, :do => :save_model_id
after_transition :on => :send_to_vault, :do => :fire_model_callbacks
state :sent do
validates :response_code, :inclusion => {:in => [200]}
end
state :rollbacked do
validates :response_code, :exclusion => {:in => [200]} # 0 = could not connect, 409 = no IDE header returned by vault
end
end
def max_attempts_tripped?
self.attempt >= MAX_ATTEMPTS
end
protected
# Save a serialized blob based on +model+ attributes.
# Only if the blob is present yet
# (prevent overriding original values when changing state)
#
def serialize_model
self.before_tr = model.class.find(model.id).attributes_before_type_cast.to_msgpack if (before_tr.blank? && model && !model.new_record?)
end
# Restore model attributes from the serialized blob (before_tr)
#
def restore_model
self.prev_model = self.model
if before_tr.blank?
# new record was created, destroy it
self.model.destroy if self.model
self.model = nil
else
self.transaction do
model.reload
MessagePack.unpack(before_tr).each do |attribute_name, value|
model.send(attribute_name.to_s + '=', value)
end
model.save
end
end
true
end
def after_initialize_method
self.on_complete ||= []
end
def cancel_dependent_transactions
@stack = []
dfs(self.on_complete)
@stack.each(&:cancel)
true
end
private
def fire_model_callbacks
if model && model.class.try(:after_vault_blocks)
blocks = model.class.after_vault_blocks
blocks += model.class.after_vault_blocks(before_tr.blank? ? :create : :update)
blocks.each do |blk|
blk.call(model)
end
end
end
def save_model_id
self.model_id = self.model.id if self.model
end
def dfs(ctransactions)
ctransactions.each do |ct|
@stack << ct
dfs(ct.on_complete)
end
end
end
# Copyright © 2010 Tech-Angels. All Rights Reserved.
require 'md5'
module Bettogo
module Collector
# French ARJEL (Autorité de Régulation des Jeux En Ligne) wants some
# data to be collected before they get processed by the
# application.
# The collector is in charge of collecting data, prepare xml documents,
# and send them to a safe, locked, place. This module
# *must* be in place in France to run the application.
class Handler
attr_reader :queue
# Create a new collector. This should be called once per http request, when a transaction is needed.
#
def initialize(rails_request)
@queue = []
@rails_request = rails_request # save request params
@hydra = Typhoeus::Hydra.new(:max_concurrency => 20) # Hydra allows to run concurrent requests
end
# Enqueue a new transaction in the Collector queue
#
# Options:
# * +trace_type+ : A valid trace type (:OUVINFOPERSO, :PAHIMISE, etc.)
# * +options+ : A Hash of options, including:
# ** +:model+ : An existing model the transaction if refering to (used for rollback).
# ** +:after+ : Don't enqueue the request for parallel processing. The current transaction
# will be executed if the +:after: transaction is a success.
# ** +:extra_params+ : A Hash of extra parameters that suits the needs of some traces
# * +&block+ : The block to be executed when the collector will try to vault the whole transaction.
# If block fails, the transaction is aborted.
#
# Example:
#
# collector = Bettogo::Collector::Handler.new(request)
# collector.enqueue("OUVINFOPERSO", current_user.account) do
# account = current_user.account.update_attributes!(params[:account])
# end
#
# _Remember_ : set the model in the transaction before changing the model (if model exists)
#
def enqueue(trace_type, options={}, &block)
ct = CollectorTransaction.new do |ctransaction|
ctransaction.trace_type = trace_type.to_s
ctransaction.model = options[:model] if options[:model]
ctransaction.extra_params = options[:extra_params].nil? ? {} : options[:extra_params]
end
ct.block = block
@queue << ct
if options[:after]
options[:after].on_complete << ct
ct.dont_enqueue = true
end
ct
end
# Fire the vaulting of all queued translations.
# As the bang (!) let think, the vault! action should
# be surrounded with a begin/rescue block.
#
# *Note:* All enqueued transactions are fired in a single
# DB transaction. Therefore, if one transaction fails, the whole
# queue is rollbacked. This is the intended behaviour, failures
# mean something has behaved in an unexpected way.
# On the other hand, each failure in the trace vaulting will
# be treated independently, and the corresponding transaction
# rollbacked.
#
# Example:
#
# collector = Bettogo::Collector::Handler.new(request)
# collector.enqueue("OKCONDGENE") do
# ...
# end
# begin
# collector.vault!
# rescue Exception
# puts "FAILURE"
# else
# puts "SUCCESS"
# end
#
def vault!
raise "Empty queue" if @queue.empty?
# Run all transactions at once, for performance purpose
ActiveRecord::Base.transaction do
@queue.each do |ctransaction|
ctransaction.save
ctransaction.block.call(ctransaction)
ctransaction.xml_trace = "::Bettogo::Collector::#{ctransaction.trace_type}Builder".constantize.new(@rails_request, ctransaction).build.to_xml(:indent => 0)
ctransaction.request = create_request(ctransaction)
ctransaction.request.on_complete do |response|
if response.success? and response.headers['IDE']
ctransaction.response_code = response.code
ctransaction.send_to_vault
ctransaction.on_complete.each do |dependent_transaction|
@hydra.queue dependent_transaction.request
end
Rails.logger.info "Vaulting successfull for transaction ##{ctransaction.id} (#{ctransaction.trace_type})"
@queue.delete ctransaction if ctransaction.sent? # transactions left in the queue are failures
else
if ctransaction.max_attempts_tripped? # we have tried enough, it's time to give up
ctransaction.response_code = (response.code == 200 and !response.headers['IDE']) ? 409 : response.code # response can be success and thus don't have the required header(s).
Rails.logger.info "Vaulting failed for transaction ##{ctransaction.id} (#{ctransaction.trace_type}) with code=#{ctransaction.response_code}"
ctransaction.rollback # Rollback the transaction, and cancel dependent ones (cf. CollectorTransaction model)
else # let's retry!
ctransaction.attempt += 1 # one more try
Rails.logger.info "Retry to vault ##{ctransaction.id} (attempt=#{ctransaction.attempt})"
@hydra.queue ctransaction.request
end
end
end
@hydra.queue ctransaction.request unless ctransaction.dont_enqueue
end
end
Rails.logger.info "Sending traces to vault."
@hydra.run
Rails.logger.info "Vault performed."
@queue # return failures (left in the queue)
end
protected
# Construct and return a vault request based on a collector transaction
#
# Options:
# * +ctransaction+ : a CollectorTransaction
#
def create_request(ctransaction)
Typhoeus::Request.new(
VAULT_CONFIG['endpoint'], # VAULT_CONFIG is initialized in config/initializers/bettogo.rb
:method => :post, # Make sure the request is a POST
:user_agent => 'BettogoCollector',
:headers => {
'IDC' => "BettogoCollector-#{Rails.env}-#{Bettogo::Version}", # Collector ID
'Content-Type' => 'application/x-www-form-urlencoded;charset=UTF-8',
'OPE' => VAULT_CONFIG['idoper']
}.reverse_merge(VAULT_CONFIG['headers']),
:timeout => VAULT_CONFIG['timeout'],
:params => {
:TRN => ctransaction.xml_trace,
:DTE => ctransaction.created_at.to_s(:number),
:IDE => ctransaction.id.to_s,
:EMP => MD5.new(ActiveSupport::Base64.encode64(ctransaction.xml_trace)).to_s,
:UTI => VAULT_CONFIG['login'],
:MDP => VAULT_CONFIG['password'] },
# SSL config
:ssl_cacert => VAULT_CONFIG['ssl_cacert'],
:ssl_cert => VAULT_CONFIG['ssl_cert'],
:ssl_cert_type => VAULT_CONFIG['ssl_cert_type'],
:ssl_key => VAULT_CONFIG['ssl_key'],
:ssl_key_password => VAULT_CONFIG['ssl_key_password'],
:ssl_key_type => VAULT_CONFIG['ssl_key_type'])
end
end
end
end
# POST /checkout
# POST /checkout.xml
def create
some_successful = false # At least one bet was successfuly created (and vaulted)
@order = current_user.orders.new
bet_types = Hash.new { |h,bet_type_name| h[bet_type_name] = BetType.find_by_name(bet_type_name)}
if current_user.can_bet?
begin
collector = Bettogo::Collector::Handler.new(request)
number_of_bets = 0
@order.save!
params[:bets].each do |bet_params|
combination, stake, bet_type_name = bet_params.split(',')
bet_type = bet_types[bet_type_name]
ensure_bet_type_is_enabled(bet_type)
bet = Bet.new(:combination => combination, :bet_type => bet_type, :stake => stake.to_i, :order_id => @order.id, :race_id => @race.id)
collector.enqueue(:PAHIMISE,
:model => bet,
:extra_params => {
:race_name => [@race.to_s, @race.name, @race.place.name].join(' - '),
:balance_before_checkout => current_user.balance,
:balance_after_checkout => current_user.balance - bet.stake}) do
bet.save!
end
number_of_bets += 1
end
failures = collector.vault!
rescue ActiveRecord::RecordInvalid => e
notify_hoptoad e
log_exception e
Rails.logger.error @order.errors.full_messages unless @order.valid?
if defined?(bet)
Rails.logger.error bet.errors.full_messages
if !bet.errors[:bet_type].empty?
flash[:alert] = t(:bet_type_max_stake_tripped, :scope => "activerecord.errors.models.bet.attributes.bet_type")
end
end
@order.destroy
rescue Exception => error
log_exception error
notify_hoptoad error
@order.destroy
else
some_successful = true unless number_of_bets == failures.size
Resque.enqueue(ExpireFragmentCache, "pending_bets/#{current_user.id}")
Resque.enqueue(UpdateAccountCanBet, 'account', current_user.account.id)
end
else
flash[:alert] = t(current_user.cant_bet_reason, :scope => 'account.cant_bet_reason')
redirect_to race_path(@race)
return
end
respond_to do |format|
format.html do
if some_successful
if current_user.prefers?(:new_order_notification)
Resque.enqueue(NotifyOrderConfirmation, @order.id)
end
if !failures.empty?
# Only some bets were placed correctly, let user retry bad ones,
# ond display successful part of order
@failed_bets = failures.map(&:prev_model)
flash.now[:alert] = t(:some_of_your_bets_were_not_placed, :scope => 'orders.flashes')
render :action => 'retry'
else
# Order successfuly placed, display it
flash[:notice] = t(:your_bets_have_been_placed, :scope => 'orders.flashes')
session[:current_user_last_order_id] = @order.id
redirect_to race_path(@race)
end
else
# No bet was placed. Display info and redirect user back to cart if passible
flash[:alert] ||= t(:vaulting_error, :scope => 'commons.flashes')
redirect_to(params[:after_login_user_url] || race_path(@race))
end
end
format.xml do
if some_successful
render :xml => @order, :status => :created, :location => @order
else
render :xml => @order.errors, :status => :unprocessable_entity
end
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment