def create( self, company, processor_uri=None, ): """Create a customer and return its id """ now = tables.now_func() customer = tables.Customer( guid='CU' + make_guid(), company=company, processor_uri=processor_uri, created_at=now, updated_at=now, ) self.session.add(customer) self.session.flush() processor = self.factory.create_processor() processor.configure_api_key(customer.company.processor_key) # create customer if customer.processor_uri is None: customer.processor_uri = processor.create_customer(customer) # validate the customer processor URI else: processor.validate_customer(customer.processor_uri) self.session.flush() return customer
def transaction_status_update(self, invoice, transaction, original_status): """Called to handle transaction status update """ # we don't have to deal with refund/reversal status change if transaction.transaction_type not in [ TransactionModel.types.DEBIT, TransactionModel.types.CREDIT, ]: return def succeeded(): invoice.status = self.statuses.SETTLED def processing(): invoice.status = self.statuses.PROCESSING def failed(): invoice.status = self.statuses.FAILED status_handlers = { # succeeded status TransactionModel.statuses.SUCCEEDED: succeeded, # processing TransactionModel.statuses.PENDING: processing, # failed TransactionModel.statuses.FAILED: failed, } new_status = transaction.status status_handlers[new_status]() invoice.updated_at = tables.now_func() self.session.flush()
def create( self, company, plan_type, amount, frequency, interval=1, external_id=None, name=None, description=None, ): """Create a plan and return its ID """ if interval < 1: raise ValueError('Interval can only be >= 1') now = tables.now_func() plan = tables.Plan( guid='PL' + make_guid(), company=company, plan_type=plan_type, amount=amount, frequency=frequency, interval=interval, external_id=external_id, name=name, description=description, updated_at=now, created_at=now, ) self.session.add(plan) self.session.flush() return plan
def update(self, transaction, **kwargs): """Update a transaction """ now = tables.now_func() transaction.updated_at = now if kwargs: raise TypeError("Unknown attributes {} to update".format(tuple(kwargs.keys()))) self.session.flush()
def add_event(self, transaction, status, processor_id, occurred_at): """Add a status updating event of transaction from callback """ now = tables.now_func() # the latest event of this transaction last_event = transaction.events.first() event = tables.TransactionEvent( guid="TE" + make_guid(), transaction=transaction, processor_id=processor_id, occurred_at=occurred_at, status=status, created_at=now, ) self.session.add(event) # ensure won't duplicate try: self.session.flush() except IntegrityError: self.session.rollback() raise DuplicateEventError("Event {} already exists for {}".format(processor_id, transaction.guid)) # Notice: we only want to update transaction status if this event # is the latest one we had seen in Billy system. Why we are doing # here is because I think of some scenarios like # # 1. Balanced cannot reach Billy for a short while, and retry later # 2. Attacker want to fool us with old events # # These will lead the status of invoice to be updated incorrectly. # For case 1, events send to Billy like this: # # succeeded (failed to send to Billy, retry 1 minute later) # failed # succeeded (retry) # # See? The final status should be `failed`, but as the `succeeded` # was resent later, so it became `succeded` eventually. Similarly, # attackers can send us an old `succeeded` event to make the invoice # settled. This is why we need to ensure only the latest event can # affect status of invoice. if last_event is not None and occurred_at <= last_event.occurred_at: return old_status = transaction.status transaction.updated_at = now transaction.status = status # update invoice status invoice_model = self.factory.create_invoice_model() invoice_model.transaction_status_update( invoice=transaction.invoice, transaction=transaction, original_status=old_status ) self.session.flush()
def update(self, transaction, **kwargs): """Update a transaction """ now = tables.now_func() transaction.updated_at = now if kwargs: raise TypeError('Unknown attributes {} to update'.format( tuple(kwargs.keys()))) self.session.flush()
def cancel(self, subscription): """Cancel a subscription :param subscription: the subscription to cancel """ if subscription.canceled: raise SubscriptionCanceledError( 'Subscription {} is already canceled'.format(subscription.guid) ) now = tables.now_func() subscription.canceled = True subscription.canceled_at = now
def update(self, company, **kwargs): """Update a company """ now = tables.now_func() company.updated_at = now for key in ['name', 'processor_key', 'api_key']: if key not in kwargs: continue value = kwargs.pop(key) setattr(company, key, value) if kwargs: raise TypeError('Unknown attributes {} to update'.format(tuple(kwargs.keys()))) self.session.flush()
def update(self, plan, **kwargs): """Update a plan """ now = tables.now_func() plan.updated_at = now for key in ["name", "external_id", "description"]: if key not in kwargs: continue value = kwargs.pop(key) setattr(plan, key, value) if kwargs: raise TypeError("Unknown attributes {} to update".format(tuple(kwargs.keys()))) self.session.flush()
def update(self, customer, **kwargs): """Update a customer """ now = tables.now_func() customer.updated_at = now for key in ['processor_uri']: if key not in kwargs: continue value = kwargs.pop(key) setattr(customer, key, value) if kwargs: raise TypeError('Unknown attributes {} to update'.format(tuple(kwargs.keys()))) self.session.flush()
def update(self, plan, **kwargs): """Update a plan """ now = tables.now_func() plan.updated_at = now for key in ['name', 'external_id', 'description']: if key not in kwargs: continue value = kwargs.pop(key) setattr(plan, key, value) if kwargs: raise TypeError('Unknown attributes {} to update'.format(tuple(kwargs.keys()))) self.session.flush()
def update(self, subscription, **kwargs): """Update a subscription :param external_id: external_id to update """ now = tables.now_func() subscription.updated_at = now for key in ['external_id']: if key not in kwargs: continue value = kwargs.pop(key) setattr(subscription, key, value) if kwargs: raise TypeError('Unknown attributes {} to update'.format(tuple(kwargs.keys()))) self.session.flush()
def update(self, customer, **kwargs): """Update a customer """ now = tables.now_func() customer.updated_at = now for key in ['processor_uri']: if key not in kwargs: continue value = kwargs.pop(key) setattr(customer, key, value) if kwargs: raise TypeError('Unknown attributes {} to update'.format( tuple(kwargs.keys()))) self.session.flush()
def create( self, invoice, amount, transaction_type=None, funding_instrument_uri=None, reference_to=None, appears_on_statement_as=None, ): """Create a transaction and return """ if transaction_type is None: transaction_type = invoice.transaction_type if reference_to is not None: if transaction_type not in [self.types.REFUND, self.types.REVERSE]: raise ValueError('reference_to can only be set to a refund ' 'transaction') if funding_instrument_uri is not None: raise ValueError( 'funding_instrument_uri cannot be set to a refund/reverse ' 'transaction') if (reference_to.transaction_type not in [self.types.DEBIT, self.types.CREDIT]): raise ValueError( 'Only charge/payout transaction can be refunded/reversed') now = tables.now_func() transaction = tables.Transaction( guid='TX' + make_guid(), transaction_type=transaction_type, amount=amount, funding_instrument_uri=funding_instrument_uri, appears_on_statement_as=appears_on_statement_as, submit_status=self.submit_statuses.STAGED, reference_to=reference_to, created_at=now, updated_at=now, invoice=invoice, ) self.session.add(transaction) self.session.flush() return transaction
def create( self, invoice, amount, transaction_type=None, funding_instrument_uri=None, reference_to=None, appears_on_statement_as=None, ): """Create a transaction and return """ if transaction_type is None: transaction_type = invoice.transaction_type if reference_to is not None: if transaction_type not in [self.types.REFUND, self.types.REVERSE]: raise ValueError("reference_to can only be set to a refund " "transaction") if funding_instrument_uri is not None: raise ValueError("funding_instrument_uri cannot be set to a refund/reverse " "transaction") if reference_to.transaction_type not in [self.types.DEBIT, self.types.CREDIT]: raise ValueError("Only charge/payout transaction can be refunded/reversed") now = tables.now_func() transaction = tables.Transaction( guid="TX" + make_guid(), transaction_type=transaction_type, amount=amount, funding_instrument_uri=funding_instrument_uri, appears_on_statement_as=appears_on_statement_as, submit_status=self.submit_statuses.STAGED, reference_to=reference_to, created_at=now, updated_at=now, invoice=invoice, ) self.session.add(transaction) self.session.flush() return transaction
def create(self, company, plan_type, amount, frequency, interval=1, external_id=None, name=None, description=None): """Create a plan and return its ID """ if interval < 1: raise ValueError("Interval can only be >= 1") now = tables.now_func() plan = tables.Plan( guid="PL" + make_guid(), company=company, plan_type=plan_type, amount=amount, frequency=frequency, interval=interval, external_id=external_id, name=name, description=description, updated_at=now, created_at=now, ) self.session.add(plan) self.session.flush() return plan
def create( self, customer, plan, funding_instrument_uri=None, started_at=None, external_id=None, appears_on_statement_as=None, amount=None, ): """Create a subscription and return its id """ if amount is not None and amount <= 0: raise ValueError('Amount should be a non-zero postive integer') now = tables.now_func() if started_at is None: started_at = now elif started_at < now: raise ValueError('Past started_at time is not allowed') subscription = tables.Subscription( guid='SU' + make_guid(), customer=customer, plan=plan, amount=amount, funding_instrument_uri=funding_instrument_uri, external_id=external_id, appears_on_statement_as=appears_on_statement_as, started_at=started_at, next_invoice_at=started_at, created_at=now, updated_at=now, ) self.session.add(subscription) self.session.flush() self.yield_invoices([subscription]) return subscription
def cancel(self, invoice): """Cancel an invoice """ Transaction = tables.Transaction now = tables.now_func() if invoice.status not in [ self.statuses.STAGED, self.statuses.PROCESSING, self.statuses.FAILED, ]: raise InvalidOperationError( 'An invoice can only be canceled when its status is one of ' 'STAGED, PROCESSING and FAILED' ) self.get(invoice.guid, with_lockmode='update') invoice.status = self.statuses.CANCELED # those transactions which are still running running_transactions = ( self.session.query(Transaction) .filter( Transaction.transaction_type != TransactionModel.types.REFUND, Transaction.submit_status.in_([ TransactionModel.submit_statuses.STAGED, TransactionModel.submit_statuses.RETRYING, ]) ) ) # cancel them running_transactions.update(dict( submit_status=TransactionModel.submit_statuses.CANCELED, updated_at=now, ), synchronize_session='fetch') self.session.flush()
def create(self, processor_key, name=None, make_callback_url=None): """Create a company and return """ now = tables.now_func() company = tables.Company( guid='CP' + make_guid(), processor_key=processor_key, api_key=make_api_key(), callback_key=make_api_key(), name=name, created_at=now, updated_at=now, ) self.session.add(company) self.session.flush() if make_callback_url is not None: url = make_callback_url(company) processor = self.factory.create_processor() processor.configure_api_key(company.processor_key) processor.register_callback(company, url) return company
def create( self, amount, funding_instrument_uri=None, customer=None, subscription=None, title=None, items=None, adjustments=None, external_id=None, appears_on_statement_as=None, scheduled_at=None, ): """Create a invoice and return its id """ from sqlalchemy.exc import IntegrityError if customer is not None and subscription is not None: raise ValueError('You can only set either customer or subscription') if customer is not None: invoice_type = self.types.CUSTOMER invoice_cls = tables.CustomerInvoice # we only support charge type for customer invoice now transaction_type = self.transaction_types.DEBIT extra_kwargs = dict( customer=customer, external_id=external_id, ) elif subscription is not None: if scheduled_at is None: raise ValueError('scheduled_at cannot be None') invoice_type = self.types.SUBSCRIPTION invoice_cls = tables.SubscriptionInvoice plan_type = subscription.plan.plan_type if plan_type == PlanModel.types.DEBIT: transaction_type = self.transaction_types.DEBIT elif plan_type == PlanModel.types.CREDIT: transaction_type = self.transaction_types.CREDIT else: raise ValueError('Invalid plan_type {}'.format(plan_type)) extra_kwargs = dict( subscription=subscription, scheduled_at=scheduled_at, ) else: raise ValueError('You have to set either customer or subscription') if amount < 0: raise ValueError('Negative amount {} is not allowed'.format(amount)) now = tables.now_func() invoice = invoice_cls( guid='IV' + make_guid(), invoice_type=invoice_type, transaction_type=transaction_type, status=self.statuses.STAGED, amount=amount, funding_instrument_uri=funding_instrument_uri, title=title, created_at=now, updated_at=now, appears_on_statement_as=appears_on_statement_as, **extra_kwargs ) self.session.add(invoice) # ensure (customer_guid, external_id) is unique try: self.session.flush() except IntegrityError: self.session.rollback() raise DuplicateExternalIDError( 'Invoice {} with external_id {} already exists' .format(customer.guid, external_id) ) if items: for item in items: record = tables.Item( invoice=invoice, name=item['name'], amount=item['amount'], type=item.get('type'), quantity=item.get('quantity'), unit=item.get('unit'), volume=item.get('volume'), ) self.session.add(record) self.session.flush() # TODO: what about an invalid adjust? say, it makes the total of invoice # a negative value? I think we should not allow user to create such # invalid invoice if adjustments: for adjustment in adjustments: record = tables.Adjustment( invoice=invoice, amount=adjustment['amount'], reason=adjustment.get('reason'), ) self.session.add(record) self.session.flush() # as if we set the funding_instrument_uri at very first, we want to charge it # immediately, so we create a transaction right away, also set the # status to PROCESSING if funding_instrument_uri is not None and invoice.amount > 0: invoice.status = self.statuses.PROCESSING self._create_transaction(invoice) # it is zero amount, nothing to charge, just switch to # SETTLED status elif invoice.amount == 0: invoice.status = self.statuses.SETTLED self.session.flush() return invoice
def add_event(self, transaction, status, processor_id, occurred_at): """Add a status updating event of transaction from callback """ now = tables.now_func() # the latest event of this transaction last_event = transaction.events.first() event = tables.TransactionEvent( guid='TE' + make_guid(), transaction=transaction, processor_id=processor_id, occurred_at=occurred_at, status=status, created_at=now, ) self.session.add(event) # ensure won't duplicate try: self.session.flush() except IntegrityError: self.session.rollback() raise DuplicateEventError( 'Event {} already exists for {}'.format( processor_id, transaction.guid, ), ) # Notice: we only want to update transaction status if this event # is the latest one we had seen in Billy system. Why we are doing # here is because I think of some scenarios like # # 1. Balanced cannot reach Billy for a short while, and retry later # 2. Attacker want to fool us with old events # # These will lead the status of invoice to be updated incorrectly. # For case 1, events send to Billy like this: # # succeeded (failed to send to Billy, retry 1 minute later) # failed # succeeded (retry) # # See? The final status should be `failed`, but as the `succeeded` # was resent later, so it became `succeded` eventually. Similarly, # attackers can send us an old `succeeded` event to make the invoice # settled. This is why we need to ensure only the latest event can # affect status of invoice. if last_event is not None and occurred_at <= last_event.occurred_at: return old_status = transaction.status transaction.updated_at = now transaction.status = status # update invoice status invoice_model = self.factory.create_invoice_model() invoice_model.transaction_status_update( invoice=transaction.invoice, transaction=transaction, original_status=old_status, ) self.session.flush()
class TransactionModel(BaseTableModel): TABLE = tables.Transaction #: the default maximum retry count DEFAULT_MAXIMUM_RETRY = 10 types = tables.TransactionType submit_statuses = tables.TransactionSubmitStatus statuses = tables.TransactionStatus @property def maximum_retry(self): maximum_retry = int( self.factory.settings.get( 'billy.transaction.maximum_retry', self.DEFAULT_MAXIMUM_RETRY, )) return maximum_retry def get_last_transaction(self): """Get last transaction """ query = (self.session.query(tables.Transaction).order_by( tables.Transaction.created_at.desc())) return query.first() @decorate_offset_limit def list_by_context(self, context): """List transactions by a given context """ Company = tables.Company Customer = tables.Customer Invoice = tables.Invoice Plan = tables.Plan Subscription = tables.Subscription Transaction = tables.Transaction SubscriptionInvoice = tables.SubscriptionInvoice CustomerInvoice = tables.CustomerInvoice # joined subscription transaction query basic_query = self.session.query(Transaction) # joined subscription invoice query subscription_invoice_query = (basic_query.join( SubscriptionInvoice, SubscriptionInvoice.guid == Transaction.invoice_guid, )) # joined customer invoice query customer_invoice_query = (basic_query.join( CustomerInvoice, CustomerInvoice.guid == Transaction.invoice_guid, )) # joined subscription query subscription_query = (subscription_invoice_query.join( Subscription, Subscription.guid == SubscriptionInvoice.subscription_guid, )) # joined customer query customer_query = (customer_invoice_query.join( Customer, Customer.guid == CustomerInvoice.customer_guid, )) # joined plan query plan_query = (subscription_query.join( Plan, Plan.guid == Subscription.plan_guid, )) if isinstance(context, Invoice): query = (basic_query.filter(Transaction.invoice == context)) elif isinstance(context, Subscription): query = (subscription_invoice_query.filter( SubscriptionInvoice.subscription == context)) elif isinstance(context, Customer): query = (customer_invoice_query.filter( CustomerInvoice.customer == context)) elif isinstance(context, Plan): query = (subscription_query.filter(Subscription.plan == context)) elif isinstance(context, Company): q1 = (plan_query.filter(Plan.company == context)) q2 = (customer_query.filter(Customer.company == context)) query = q1.union(q2) else: raise ValueError('Unsupported context {}'.format(context)) query = query.order_by(Transaction.created_at.desc()) return query def create( self, invoice, amount, transaction_type=None, funding_instrument_uri=None, reference_to=None, appears_on_statement_as=None, ): """Create a transaction and return """ if transaction_type is None: transaction_type = invoice.transaction_type if reference_to is not None: if transaction_type not in [self.types.REFUND, self.types.REVERSE]: raise ValueError('reference_to can only be set to a refund ' 'transaction') if funding_instrument_uri is not None: raise ValueError( 'funding_instrument_uri cannot be set to a refund/reverse ' 'transaction') if (reference_to.transaction_type not in [self.types.DEBIT, self.types.CREDIT]): raise ValueError( 'Only charge/payout transaction can be refunded/reversed') now = tables.now_func() transaction = tables.Transaction( guid='TX' + make_guid(), transaction_type=transaction_type, amount=amount, funding_instrument_uri=funding_instrument_uri, appears_on_statement_as=appears_on_statement_as, submit_status=self.submit_statuses.STAGED, reference_to=reference_to, created_at=now, updated_at=now, invoice=invoice, ) self.session.add(transaction) self.session.flush() return transaction def update(self, transaction, **kwargs): """Update a transaction """ now = tables.now_func() transaction.updated_at = now if kwargs: raise TypeError('Unknown attributes {} to update'.format( tuple(kwargs.keys()))) self.session.flush() def add_event(self, transaction, status, processor_id, occurred_at): """Add a status updating event of transaction from callback """ now = tables.now_func() # the latest event of this transaction last_event = transaction.events.first() event = tables.TransactionEvent( guid='TE' + make_guid(), transaction=transaction, processor_id=processor_id, occurred_at=occurred_at, status=status, created_at=now, ) self.session.add(event) # ensure won't duplicate try: self.session.flush() except IntegrityError: self.session.rollback() raise DuplicateEventError( 'Event {} already exists for {}'.format( processor_id, transaction.guid, ), ) # Notice: we only want to update transaction status if this event # is the latest one we had seen in Billy system. Why we are doing # here is because I think of some scenarios like # # 1. Balanced cannot reach Billy for a short while, and retry later # 2. Attacker want to fool us with old events # # These will lead the status of invoice to be updated incorrectly. # For case 1, events send to Billy like this: # # succeeded (failed to send to Billy, retry 1 minute later) # failed # succeeded (retry) # # See? The final status should be `failed`, but as the `succeeded` # was resent later, so it became `succeded` eventually. Similarly, # attackers can send us an old `succeeded` event to make the invoice # settled. This is why we need to ensure only the latest event can # affect status of invoice. if last_event is not None and occurred_at <= last_event.occurred_at: return old_status = transaction.status transaction.updated_at = now transaction.status = status # update invoice status invoice_model = self.factory.create_invoice_model() invoice_model.transaction_status_update( invoice=transaction.invoice, transaction=transaction, original_status=old_status, ) self.session.flush() def process_one(self, transaction): """Process one transaction """ invoice_model = self.factory.create_invoice_model() # there is still chance we duplicate transaction, for example # # (Thread 1) (Thread 2) # Check existing transaction # Check existing transaction # Called to balanced # Call to balanced # # we need to lock transaction before we process it to avoid # situations like that self.get(transaction.guid, with_lockmode='update') if transaction.submit_status == self.submit_statuses.DONE: raise ValueError('Cannot process a finished transaction {}'.format( transaction.guid)) self.logger.debug('Processing transaction %s', transaction.guid) now = tables.now_func() if transaction.invoice.invoice_type == invoice_model.types.SUBSCRIPTION: customer = transaction.invoice.subscription.customer else: customer = transaction.invoice.customer processor = self.factory.create_processor() method = { self.types.DEBIT: processor.debit, self.types.CREDIT: processor.credit, self.types.REFUND: processor.refund, }[transaction.transaction_type] try: processor.configure_api_key(customer.company.processor_key) self.logger.info( 'Preparing customer %s (processor_uri=%s)', customer.guid, customer.processor_uri, ) # prepare customer (add bank account or credit card) processor.prepare_customer( customer=customer, funding_instrument_uri=transaction.funding_instrument_uri, ) # do charge/payout/refund result = method(transaction) except (SystemExit, KeyboardInterrupt): raise except Exception, e: transaction.submit_status = self.submit_statuses.RETRYING failure_model = self.factory.create_transaction_failure_model() failure_model.create( transaction=transaction, error_message=unicode(e), # TODO: error number and code? ) self.logger.error( 'Failed to process transaction %s, ' 'failure_count=%s', transaction.guid, transaction.failure_count, exc_info=True) # the failure times exceed the limitation if transaction.failure_count > self.maximum_retry: self.logger.error( 'Exceed maximum retry limitation %s, ' 'transaction %s failed', self.maximum_retry, transaction.guid) transaction.submit_status = self.submit_statuses.FAILED # the transaction is failed, update invoice status if transaction.transaction_type in [ self.types.DEBIT, self.types.CREDIT, ]: transaction.invoice.status = invoice_model.statuses.FAILED transaction.updated_at = now self.session.flush() return old_status = transaction.status transaction.processor_uri = result['processor_uri'] transaction.status = result['status'] transaction.submit_status = self.submit_statuses.DONE transaction.updated_at = tables.now_func() invoice_model.transaction_status_update( invoice=transaction.invoice, transaction=transaction, original_status=old_status, ) self.session.flush() self.logger.info( 'Processed transaction %s, submit_status=%s, ' 'result=%s', transaction.guid, transaction.submit_status, result)
def process_one(self, transaction): """Process one transaction """ invoice_model = self.factory.create_invoice_model() # there is still chance we duplicate transaction, for example # # (Thread 1) (Thread 2) # Check existing transaction # Check existing transaction # Called to balanced # Call to balanced # # we need to lock transaction before we process it to avoid # situations like that self.get(transaction.guid, with_lockmode='update') if transaction.submit_status == self.submit_statuses.DONE: raise ValueError('Cannot process a finished transaction {}'.format( transaction.guid)) self.logger.debug('Processing transaction %s', transaction.guid) now = tables.now_func() if transaction.invoice.invoice_type == invoice_model.types.SUBSCRIPTION: customer = transaction.invoice.subscription.customer else: customer = transaction.invoice.customer processor = self.factory.create_processor() method = { self.types.DEBIT: processor.debit, self.types.CREDIT: processor.credit, self.types.REFUND: processor.refund, }[transaction.transaction_type] try: processor.configure_api_key(customer.company.processor_key) self.logger.info( 'Preparing customer %s (processor_uri=%s)', customer.guid, customer.processor_uri, ) # prepare customer (add bank account or credit card) processor.prepare_customer( customer=customer, funding_instrument_uri=transaction.funding_instrument_uri, ) # do charge/payout/refund result = method(transaction) except (SystemExit, KeyboardInterrupt): raise except Exception, e: transaction.submit_status = self.submit_statuses.RETRYING failure_model = self.factory.create_transaction_failure_model() failure_model.create( transaction=transaction, error_message=unicode(e), # TODO: error number and code? ) self.logger.error( 'Failed to process transaction %s, ' 'failure_count=%s', transaction.guid, transaction.failure_count, exc_info=True) # the failure times exceed the limitation if transaction.failure_count > self.maximum_retry: self.logger.error( 'Exceed maximum retry limitation %s, ' 'transaction %s failed', self.maximum_retry, transaction.guid) transaction.submit_status = self.submit_statuses.FAILED # the transaction is failed, update invoice status if transaction.transaction_type in [ self.types.DEBIT, self.types.CREDIT, ]: transaction.invoice.status = invoice_model.statuses.FAILED transaction.updated_at = now self.session.flush() return
def process_one(self, transaction): """Process one transaction """ invoice_model = self.factory.create_invoice_model() # there is still chance we duplicate transaction, for example # # (Thread 1) (Thread 2) # Check existing transaction # Check existing transaction # Called to balanced # Call to balanced # # we need to lock transaction before we process it to avoid # situations like that self.get(transaction.guid, with_lockmode="update") if transaction.submit_status == self.submit_statuses.DONE: raise ValueError("Cannot process a finished transaction {}".format(transaction.guid)) self.logger.debug("Processing transaction %s", transaction.guid) now = tables.now_func() if transaction.invoice.invoice_type == invoice_model.types.SUBSCRIPTION: customer = transaction.invoice.subscription.customer else: customer = transaction.invoice.customer processor = self.factory.create_processor() method = { self.types.DEBIT: processor.debit, self.types.CREDIT: processor.credit, self.types.REFUND: processor.refund, }[transaction.transaction_type] try: processor.configure_api_key(customer.company.processor_key) self.logger.info("Preparing customer %s (processor_uri=%s)", customer.guid, customer.processor_uri) # prepare customer (add bank account or credit card) processor.prepare_customer(customer=customer, funding_instrument_uri=transaction.funding_instrument_uri) # do charge/payout/refund result = method(transaction) except (SystemExit, KeyboardInterrupt): raise except Exception, e: transaction.submit_status = self.submit_statuses.RETRYING failure_model = self.factory.create_transaction_failure_model() failure_model.create( transaction=transaction, error_message=unicode(e), # TODO: error number and code? ) self.logger.error( "Failed to process transaction %s, " "failure_count=%s", transaction.guid, transaction.failure_count, exc_info=True, ) # the failure times exceed the limitation if transaction.failure_count > self.maximum_retry: self.logger.error( "Exceed maximum retry limitation %s, " "transaction %s failed", self.maximum_retry, transaction.guid ) transaction.submit_status = self.submit_statuses.FAILED # the transaction is failed, update invoice status if transaction.transaction_type in [self.types.DEBIT, self.types.CREDIT]: transaction.invoice.status = invoice_model.statuses.FAILED transaction.updated_at = now self.session.flush() return
def update_funding_instrument_uri(self, invoice, funding_instrument_uri): """Update the funding_instrument_uri of an invoice, as it may yield transactions, we don't want to put this in `update` method @return: a list of yielded transaction """ Transaction = tables.Transaction tx_model = self.factory.create_transaction_model() now = tables.now_func() invoice.updated_at = now invoice.funding_instrument_uri = funding_instrument_uri transactions = [] # We have nothing to do if the amount is zero, just return if invoice.amount == 0: return transactions # think about race condition issue, what if we update the # funding_instrument_uri during processing previous transaction? say # # DB Transaction A begin # Call to Balanced API # DB Transaction B begin # Update invoice payment URI # Update last transaction to CANCELED # Create a new transaction # DB Transaction B commit # Update transaction to DONE # DB Transaction A commit # DB Transaction conflicts # DB Transaction rollback # # call to balanced API is made, but we had confliction between two # database transactions # # to solve the problem mentioned above, we acquire a lock on the # invoice at begin of transaction, in this way, there will be no # overlap between two transaction self.get(invoice.guid, with_lockmode='update') # the invoice is just created, simply create a transaction for it if invoice.status == self.statuses.STAGED: transaction = self._create_transaction(invoice) transactions.append(transaction) # we are already processing, cancel current transaction and create # a new one elif invoice.status == self.statuses.PROCESSING: # find the running transaction and cancel it last_transaction = ( self.session .query(Transaction) .filter( Transaction.invoice == invoice, Transaction.transaction_type.in_([ TransactionModel.types.DEBIT, TransactionModel.types.CREDIT, ]), Transaction.submit_status.in_([ TransactionModel.submit_statuses.STAGED, TransactionModel.submit_statuses.RETRYING, ]) ) ).one() last_transaction.submit_status = tx_model.submit_statuses.CANCELED last_transaction.canceled_at = now # create a new one transaction = self._create_transaction(invoice) transactions.append(transaction) # the previous transaction failed, just create a new one elif invoice.status == self.statuses.FAILED: transaction = self._create_transaction(invoice) transactions.append(transaction) else: raise InvalidOperationError( 'Invalid operation, you can only update funding_instrument_uri ' 'when the invoice status is one of STAGED, PROCESSING and ' 'FAILED' ) invoice.status = self.statuses.PROCESSING self.session.flush() return transactions
def yield_invoices(self, subscriptions=None, now=None): """Generate new scheduled invoices from given subscriptions :param subscriptions: A list subscription to yield invoices from, if None is given, all subscriptions in the database will be the yielding source :param now: the current date time to use, now_func() will be used by default :return: a generated transaction guid list """ if now is None: now = tables.now_func() invoice_model = self.factory.create_invoice_model() Subscription = tables.Subscription subscription_guids = [] if subscriptions is not None: subscription_guids = [ subscription.guid for subscription in subscriptions ] invoices = [] # as we may have multiple new invoices for one subscription to # yield now, for example, we didn't run this method for a long while, # in this case, we need to make sure all transactions are yielded while True: # find subscriptions which should yield new invoices query = ( self.session.query(Subscription) .filter(Subscription.next_invoice_at <= now) .filter(not_(Subscription.canceled)) ) if subscription_guids: query = query.filter(Subscription.guid.in_(subscription_guids)) query = list(query) # okay, we have no more subscription to process, just break if not query: self.logger.info('No more subscriptions to process') break for subscription in query: amount = subscription.effective_amount # create the new transaction for this subscription invoice = invoice_model.create( subscription=subscription, funding_instrument_uri=subscription.funding_instrument_uri, amount=amount, scheduled_at=subscription.next_invoice_at, appears_on_statement_as=subscription.appears_on_statement_as, ) self.logger.info( 'Created subscription invoice for %s, guid=%s, ' 'plan_type=%s, funding_instrument_uri=%s, ' 'amount=%s, scheduled_at=%s, period=%s', subscription.guid, invoice.guid, subscription.plan.plan_type, invoice.funding_instrument_uri, invoice.amount, invoice.scheduled_at, subscription.invoice_count - 1, ) # advance the next invoice time subscription.next_invoice_at = next_transaction_datetime( started_at=subscription.started_at, frequency=subscription.plan.frequency, period=subscription.invoice_count, interval=subscription.plan.interval, ) self.logger.info( 'Schedule next invoice of %s at %s (period=%s)', subscription.guid, subscription.next_invoice_at, subscription.invoice_count, ) self.session.flush() invoices.append(invoice) self.session.flush() return invoices