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 create( self, company_guid, plan_type, amount, frequency, interval=1, external_id=None, name=None, description=None ): """Create a plan and return its ID """ if plan_type not in self.TYPE_ALL: raise ValueError("Invalid plan_type {}".format(plan_type)) if frequency not in self.FREQ_ALL: raise ValueError("Invalid frequency {}".format(frequency)) if interval < 1: raise ValueError("Interval can only be >= 1") now = tables.now_func() plan = tables.Plan( guid="PL" + make_guid(), company_guid=company_guid, 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.guid
def create( self, customer_guid, plan_guid, payment_uri=None, started_at=None, external_id=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 float number') 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_guid=customer_guid, plan_guid=plan_guid, amount=amount, payment_uri=payment_uri, external_id=external_id, started_at=started_at, next_transaction_at=started_at, created_at=now, updated_at=now, ) self.session.add(subscription) self.session.flush() return subscription.guid
def process_one(self, processor, transaction, maximum_retry=DEFAULT_MAXIMUM_RETRY): """Process one transaction """ if transaction.status == self.STATUS_DONE: raise ValueError('Cannot process a finished transaction {}'.format( transaction.guid)) self.logger.debug('Processing transaction %s', transaction.guid) now = tables.now_func() customer = transaction.subscription.customer try: # create customer record in balanced if customer.external_id is None: customer_id = processor.create_customer(customer) customer.external_id = customer_id self.session.add(customer) self.session.flush() self.logger.info('External customer %s', customer.external_id) # prepare customer (add bank account or credit card) processor.prepare_customer(customer, transaction.payment_uri) if transaction.transaction_type == self.TYPE_CHARGE: method = processor.charge elif transaction.transaction_type == self.TYPE_PAYOUT: method = processor.payout elif transaction.transaction_type == self.TYPE_REFUND: method = processor.refund transaction_id = method(transaction) # TODO: generate an invoice here? except (SystemExit, KeyboardInterrupt): raise except Exception, e: transaction.status = self.STATUS_RETRYING # TODO: provide more expressive error message? transaction.error_message = unicode(e) transaction.failure_count += 1 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 > maximum_retry: self.logger.error( 'Exceed maximum retry limitation %s, ' 'transaction %s failed', maximum_retry, transaction.guid) transaction.status = self.STATUS_FAILED transaction.updated_at = now self.session.add(transaction) self.session.flush() return
def process_one( self, processor, transaction, maximum_retry=DEFAULT_MAXIMUM_RETRY ): """Process one transaction """ if transaction.status == self.STATUS_DONE: raise ValueError('Cannot process a finished transaction {}' .format(transaction.guid)) self.logger.debug('Processing transaction %s', transaction.guid) now = tables.now_func() customer = transaction.subscription.customer try: # create customer record in balanced if customer.external_id is None: customer_id = processor.create_customer(customer) customer.external_id = customer_id self.session.add(customer) self.session.flush() self.logger.info('External customer %s', customer.external_id) # prepare customer (add bank account or credit card) processor.prepare_customer(customer, transaction.payment_uri) if transaction.transaction_type == self.TYPE_CHARGE: method = processor.charge elif transaction.transaction_type == self.TYPE_PAYOUT: method = processor.payout elif transaction.transaction_type == self.TYPE_REFUND: method = processor.refund transaction_id = method(transaction) # TODO: generate an invoice here? except (SystemExit, KeyboardInterrupt): raise except Exception, e: transaction.status = self.STATUS_RETRYING # TODO: provide more expressive error message? transaction.error_message = unicode(e) transaction.failure_count += 1 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 > maximum_retry: self.logger.error('Exceed maximum retry limitation %s, ' 'transaction %s failed', maximum_retry, transaction.guid) transaction.status = self.STATUS_FAILED transaction.updated_at = now self.session.add(transaction) self.session.flush() return
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 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 transaction_type not in self.TYPE_ALL: raise ValueError('Invalid transaction_type {}' .format(transaction_type)) if reference_to is not None: if transaction_type not in [self.TYPE_REFUND, self.TYPE_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.TYPE_CHARGE, self.TYPE_PAYOUT] ): 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, status=self.STATUS_INIT, reference_to=reference_to, created_at=now, updated_at=now, invoice=invoice, ) self.session.add(transaction) self.session.flush() return transaction
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, transaction, **kwargs): """Update a transaction """ now = tables.now_func() transaction.updated_at = now if 'status' in kwargs: status = kwargs.pop('status') if status not in self.STATUS_ALL: raise ValueError('Invalid status {}'.format(status)) transaction.status = status 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 create(self, company_guid, external_id=None): """Create a customer and return its id """ now = tables.now_func() customer = tables.Customer( guid='CU' + make_guid(), company_guid=company_guid, external_id=external_id, created_at=now, updated_at=now, ) self.session.add(customer) self.session.flush() return customer.guid
def update(self, guid, **kwargs): """Update a company """ company = self.get(guid, raise_error=True) 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.add(company) self.session.flush()
def create(self, processor_key, name=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(), name=name, created_at=now, updated_at=now, ) self.session.add(company) self.session.flush() return company
def update(self, guid, **kwargs): """Update a customer """ customer = self.get(guid, raise_error=True) now = tables.now_func() customer.updated_at = now for key in ['external_id']: 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.add(customer) self.session.flush()
def create(self, processor_key, name=None): """Create a company and return its id """ now = tables.now_func() company = tables.Company( guid='CP' + make_guid(), processor_key=processor_key, api_key=make_api_key(), name=name, created_at=now, updated_at=now, ) self.session.add(company) self.session.flush() return company.guid
def update(self, guid, **kwargs): """Update a plan """ plan = self.get(guid, raise_error=True) 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.add(plan) self.session.flush()
def update(self, guid, **kwargs): """Update a transaction """ transaction = self.get(guid, raise_error=True) now = tables.now_func() transaction.updated_at = now if 'status' in kwargs: status = kwargs.pop('status') if status not in self.STATUS_ALL: raise ValueError('Invalid status {}'.format(status)) transaction.status = status if kwargs: raise TypeError('Unknown attributes {} to update'.format( tuple(kwargs.keys()))) self.session.add(transaction) self.session.flush()
def update(self, guid, **kwargs): """Update a plan """ plan = self.get(guid, raise_error=True) 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.add(plan) self.session.flush()
def update(self, guid, **kwargs): """Update a customer """ customer = self.get(guid, raise_error=True) now = tables.now_func() customer.updated_at = now for key in ['external_id']: 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.add(customer) self.session.flush()
def update(self, guid, **kwargs): """Update a subscription :param guid: the guid of subscription to update :param external_id: external_id to update """ subscription = self.get(guid, raise_error=True) 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.add(subscription) self.session.flush()
def create( self, company_guid, external_id=None ): """Create a customer and return its id """ now = tables.now_func() customer = tables.Customer( guid='CU' + make_guid(), company_guid=company_guid, external_id=external_id, created_at=now, updated_at=now, ) self.session.add(customer) self.session.flush() return customer.guid
def create( self, subscription_guid, transaction_type, amount, scheduled_at, payment_uri=None, refund_to_guid=None, ): """Create a transaction and return its ID """ if transaction_type not in self.TYPE_ALL: raise ValueError( 'Invalid transaction_type {}'.format(transaction_type)) if refund_to_guid is not None: if transaction_type != self.TYPE_REFUND: raise ValueError('refund_to_guid can only be set to a refund ' 'transaction') if payment_uri is not None: raise ValueError('payment_uri cannot be set to a refund ' 'transaction') refund_transaction = self.get(refund_to_guid, raise_error=True) if refund_transaction.transaction_type != self.TYPE_CHARGE: raise ValueError('Only charge transaction can be refunded') now = tables.now_func() transaction = tables.Transaction( guid='TX' + make_guid(), subscription_guid=subscription_guid, transaction_type=transaction_type, amount=amount, payment_uri=payment_uri, status=self.STATUS_INIT, scheduled_at=scheduled_at, refund_to_guid=refund_to_guid, created_at=now, updated_at=now, ) self.session.add(transaction) self.session.flush() return transaction.guid
def create( self, subscription_guid, transaction_type, amount, scheduled_at, payment_uri=None, refund_to_guid=None, ): """Create a transaction and return its ID """ if transaction_type not in self.TYPE_ALL: raise ValueError('Invalid transaction_type {}' .format(transaction_type)) if refund_to_guid is not None: if transaction_type != self.TYPE_REFUND: raise ValueError('refund_to_guid can only be set to a refund ' 'transaction') if payment_uri is not None: raise ValueError('payment_uri cannot be set to a refund ' 'transaction') refund_transaction = self.get(refund_to_guid, raise_error=True) if refund_transaction.transaction_type != self.TYPE_CHARGE: raise ValueError('Only charge transaction can be refunded') now = tables.now_func() transaction = tables.Transaction( guid='TX' + make_guid(), subscription_guid=subscription_guid, transaction_type=transaction_type, amount=amount, payment_uri=payment_uri, status=self.STATUS_INIT, scheduled_at=scheduled_at, refund_to_guid=refund_to_guid, created_at=now, updated_at=now, ) self.session.add(transaction) self.session.flush() return transaction.guid
def create( self, company_guid, plan_type, amount, frequency, interval=1, external_id=None, name=None, description=None, ): """Create a plan and return its ID """ if plan_type not in self.TYPE_ALL: raise ValueError('Invalid plan_type {}'.format(plan_type)) if frequency not in self.FREQ_ALL: raise ValueError('Invalid frequency {}'.format(frequency)) if interval < 1: raise ValueError('Interval can only be >= 1') now = tables.now_func() plan = tables.Plan( guid='PL' + make_guid(), company_guid=company_guid, 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.guid
def cancel(self, invoice): """Cancel an invoice """ Transaction = tables.Transaction now = tables.now_func() if invoice.status not in [ self.STATUS_INIT, self.STATUS_PROCESSING, self.STATUS_PROCESS_FAILED, ]: raise InvalidOperationError( 'An invoice can only be canceled when its status is one of ' 'INIT, PROCESSING and PROCESS_FAILED' ) self.get(invoice.guid, with_lockmode='update') invoice.status = self.STATUS_CANCELED # those transactions which are still running running_transactions = ( self.session.query(Transaction) .filter( Transaction.transaction_type != TransactionModel.TYPE_REFUND, Transaction.status.in_([ TransactionModel.STATUS_INIT, TransactionModel.STATUS_RETRYING, ]) ) ) # cancel them running_transactions.update(dict( status=TransactionModel.STATUS_CANCELED, updated_at=now, ), synchronize_session='fetch') self.session.flush()
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 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.TYPE_CUSTOMER invoice_cls = tables.CustomerInvoice # we only support charge type for customer invoice now transaction_type = TransactionModel.TYPE_CHARGE 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.TYPE_SUBSCRIPTION invoice_cls = tables.SubscriptionInvoice plan_type = subscription.plan.plan_type if plan_type == PlanModel.TYPE_CHARGE: transaction_type = TransactionModel.TYPE_CHARGE elif plan_type == PlanModel.TYPE_PAYOUT: transaction_type = TransactionModel.TYPE_PAYOUT 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.STATUS_INIT, 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.STATUS_PROCESSING self._create_transaction(invoice) # it is zero amount, nothing to charge, just switch to # SETTLED status elif invoice.amount == 0: invoice.status = self.STATUS_SETTLED self.session.flush() return invoice
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: if subscription.plan.plan_type == PlanModel.TYPE_CHARGE: transaction_type = "charge" elif subscription.plan.plan_type == PlanModel.TYPE_PAYOUT: transaction_type = "payout" else: raise ValueError("Unknown plan type {} to process".format(subscription.plan.plan_type)) 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, " "transaction_type=%s, funding_instrument_uri=%s, " "amount=%s, scheduled_at=%s, period=%s", subscription.guid, invoice.guid, transaction_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
def cancel(self, guid, prorated_refund=False, refund_amount=None): """Cancel a subscription :param guid: the guid of subscription to cancel :param prorated_refund: Should we generate a prorated refund transaction according to remaining time of subscription period? :param refund_amount: if refund_amount is given, it will be used to refund customer, you cannot set prorated_refund with refund_amount :return: if prorated_refund is True, and the subscription is refundable, the refund transaction guid will be returned """ if prorated_refund and refund_amount is not None: raise ValueError('You cannot set refund_amount when ' 'prorated_refund is True') tx_model = TransactionModel(self.session) subscription = self.get(guid, raise_error=True) if subscription.canceled: raise SubscriptionCanceledError('Subscription {} is already ' 'canceled'.format(guid)) now = tables.now_func() subscription.canceled = True subscription.canceled_at = now tx_guid = None # should we do refund do_refund = False # we want to do a prorated refund here, however, if there is no any # issued transaction, then no need to do a refund, just skip if ( (prorated_refund or refund_amount is not None) and subscription.period ): previous_transaction = ( self.session.query(tables.Transaction) .filter_by(subscription_guid=subscription.guid) .order_by(tables.Transaction.scheduled_at.desc()) .first() ) # it is possible the previous transaction is failed or retrying, # so that we should only refund finished transaction if previous_transaction.status == TransactionModel.STATUS_DONE: do_refund = True if do_refund: if prorated_refund: previous_datetime = previous_transaction.scheduled_at # the total time delta in the period total_delta = ( subscription.next_transaction_at - previous_datetime ) total_seconds = decimal.Decimal(total_delta.total_seconds()) # the passed time so far since last transaction elapsed_delta = now - previous_datetime elapsed_seconds = decimal.Decimal(elapsed_delta.total_seconds()) # TODO: what about calculate in different granularity here? # such as day or hour granularity? rate = 1 - (elapsed_seconds / total_seconds) amount = previous_transaction.amount * rate amount = round_down_cent(amount) else: amount = round_down_cent(decimal.Decimal(refund_amount)) if amount > previous_transaction.amount: raise ValueError('refund_amount cannot be grather than ' 'subscription amount {}' .format(previous_transaction.amount)) # make sure we will not refund zero dollar # TODO: or... should we? if amount: tx_guid = tx_model.create( subscription_guid=subscription.guid, amount=amount, transaction_type=tx_model.TYPE_REFUND, scheduled_at=subscription.next_transaction_at, refund_to_guid=previous_transaction.guid, ) # cancel not done transactions (exclude refund transaction) Transaction = tables.Transaction not_done_transactions = ( self.session.query(Transaction) .filter_by(subscription_guid=guid) .filter(Transaction.transaction_type != TransactionModel.TYPE_REFUND) .filter(Transaction.status.in_([ tx_model.STATUS_INIT, tx_model.STATUS_RETRYING, ])) ) not_done_transactions.update(dict( status=tx_model.STATUS_CANCELED, updated_at=now, ), synchronize_session='fetch') self.session.add(subscription) self.session.flush() return tx_guid
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.STATUS_INIT: transaction = self._create_transaction(invoice) transactions.append(transaction) # we are already processing, cancel current transaction and create # a new one elif invoice.status == self.STATUS_PROCESSING: # find the running transaction and cancel it last_transaction = ( self.session .query(Transaction) .filter( Transaction.invoice == invoice, Transaction.transaction_type.in_([ TransactionModel.TYPE_CHARGE, TransactionModel.TYPE_PAYOUT, ]), Transaction.status.in_([ TransactionModel.STATUS_INIT, TransactionModel.STATUS_RETRYING, ]) ) ).one() last_transaction.status = tx_model.STATUS_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.STATUS_PROCESS_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 INIT, PROCESSING and ' 'PROCESS_FAILED' ) invoice.status = self.STATUS_PROCESSING self.session.flush() return transactions
def yield_transactions(self, subscription_guids=None, now=None): """Generate new necessary transactions according to subscriptions we had return guid list :param subscription_guids: A list subscription guid to yield transaction_type 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 """ from sqlalchemy.sql.expression import not_ if now is None: now = tables.now_func() tx_model = TransactionModel(self.session) Subscription = tables.Subscription transaction_guids = [] # as we may have multiple new transactions for one subscription to # process, 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 transactions query = ( self.session.query(Subscription) .filter(Subscription.next_transaction_at <= now) .filter(not_(Subscription.canceled)) ) if subscription_guids is not None: query = query.filter(Subscription.guid.in_(subscription_guids)) subscriptions = query.all() # okay, we have no more transaction to process, just break if not subscriptions: self.logger.info('No more subscriptions to process') break for subscription in subscriptions: if subscription.plan.plan_type == PlanModel.TYPE_CHARGE: transaction_type = tx_model.TYPE_CHARGE elif subscription.plan.plan_type == PlanModel.TYPE_PAYOUT: transaction_type = tx_model.TYPE_PAYOUT else: raise ValueError('Unknown plan type {} to process' .format(subscription.plan.plan_type)) # when amount of subscription is given, we should use it # instead the one from plan if subscription.amount is None: amount = subscription.plan.amount else: amount = subscription.amount type_map = { tx_model.TYPE_CHARGE: 'charge', tx_model.TYPE_PAYOUT: 'payout', } self.logger.debug( 'Creating transaction for %s, transaction_type=%s, ' 'payment_uri=%s, amount=%s, scheduled_at=%s, period=%s', subscription.guid, type_map[transaction_type], subscription.payment_uri, amount, subscription.next_transaction_at, subscription.period, ) # create the new transaction for this subscription guid = tx_model.create( subscription_guid=subscription.guid, payment_uri=subscription.payment_uri, amount=amount, transaction_type=transaction_type, scheduled_at=subscription.next_transaction_at, ) self.logger.info( 'Created transaction for %s, guid=%s, transaction_type=%s, ' 'payment_uri=%s, amount=%s, scheduled_at=%s, period=%s', subscription.guid, guid, type_map[transaction_type], subscription.payment_uri, amount, subscription.next_transaction_at, subscription.period, ) # advance the next transaction time subscription.period += 1 subscription.next_transaction_at = next_transaction_datetime( started_at=subscription.started_at, frequency=subscription.plan.frequency, period=subscription.period, interval=subscription.plan.interval, ) self.session.add(subscription) self.session.flush() transaction_guids.append(guid) self.session.flush() return transaction_guids
class TransactionModel(BaseTableModel): TABLE = tables.Transaction #: the default maximum retry count DEFAULT_MAXIMUM_RETRY = 10 #: charge type transaction TYPE_CHARGE = 0 #: refund type transaction TYPE_REFUND = 1 #: Paying out type transaction TYPE_PAYOUT = 2 TYPE_ALL = [ TYPE_CHARGE, TYPE_REFUND, TYPE_PAYOUT, ] #: initialized status STATUS_INIT = 0 #: we are retrying this transaction STATUS_RETRYING = 1 #: this transaction is done STATUS_DONE = 2 #: this transaction is failed STATUS_FAILED = 3 #: this transaction is canceled STATUS_CANCELED = 4 STATUS_ALL = [ STATUS_INIT, STATUS_RETRYING, STATUS_DONE, STATUS_FAILED, STATUS_CANCELED, ] @decorate_offset_limit def list_by_company_guid(self, company_guid): """Get transactions of a company by given guid """ Transaction = tables.Transaction Subscription = tables.Subscription Plan = tables.Plan query = (self.session.query(Transaction).join( (Subscription, Subscription.guid == Transaction.subscription_guid)).join( (Plan, Plan.guid == Subscription.plan_guid)).filter( Plan.company_guid == company_guid).order_by( Transaction.created_at.desc())) return query @decorate_offset_limit def list_by_subscription_guid(self, subscription_guid): """Get transactions of a subscription by given guid """ Transaction = tables.Transaction query = (self.session.query(Transaction).filter( Transaction.subscription_guid == subscription_guid).order_by( Transaction.created_at.desc())) return query def create( self, subscription_guid, transaction_type, amount, scheduled_at, payment_uri=None, refund_to_guid=None, ): """Create a transaction and return its ID """ if transaction_type not in self.TYPE_ALL: raise ValueError( 'Invalid transaction_type {}'.format(transaction_type)) if refund_to_guid is not None: if transaction_type != self.TYPE_REFUND: raise ValueError('refund_to_guid can only be set to a refund ' 'transaction') if payment_uri is not None: raise ValueError('payment_uri cannot be set to a refund ' 'transaction') refund_transaction = self.get(refund_to_guid, raise_error=True) if refund_transaction.transaction_type != self.TYPE_CHARGE: raise ValueError('Only charge transaction can be refunded') now = tables.now_func() transaction = tables.Transaction( guid='TX' + make_guid(), subscription_guid=subscription_guid, transaction_type=transaction_type, amount=amount, payment_uri=payment_uri, status=self.STATUS_INIT, scheduled_at=scheduled_at, refund_to_guid=refund_to_guid, created_at=now, updated_at=now, ) self.session.add(transaction) self.session.flush() return transaction.guid def update(self, guid, **kwargs): """Update a transaction """ transaction = self.get(guid, raise_error=True) now = tables.now_func() transaction.updated_at = now if 'status' in kwargs: status = kwargs.pop('status') if status not in self.STATUS_ALL: raise ValueError('Invalid status {}'.format(status)) transaction.status = status if kwargs: raise TypeError('Unknown attributes {} to update'.format( tuple(kwargs.keys()))) self.session.add(transaction) self.session.flush() def process_one(self, processor, transaction, maximum_retry=DEFAULT_MAXIMUM_RETRY): """Process one transaction """ if transaction.status == self.STATUS_DONE: raise ValueError('Cannot process a finished transaction {}'.format( transaction.guid)) self.logger.debug('Processing transaction %s', transaction.guid) now = tables.now_func() customer = transaction.subscription.customer try: # create customer record in balanced if customer.external_id is None: customer_id = processor.create_customer(customer) customer.external_id = customer_id self.session.add(customer) self.session.flush() self.logger.info('External customer %s', customer.external_id) # prepare customer (add bank account or credit card) processor.prepare_customer(customer, transaction.payment_uri) if transaction.transaction_type == self.TYPE_CHARGE: method = processor.charge elif transaction.transaction_type == self.TYPE_PAYOUT: method = processor.payout elif transaction.transaction_type == self.TYPE_REFUND: method = processor.refund transaction_id = method(transaction) # TODO: generate an invoice here? except (SystemExit, KeyboardInterrupt): raise except Exception, e: transaction.status = self.STATUS_RETRYING # TODO: provide more expressive error message? transaction.error_message = unicode(e) transaction.failure_count += 1 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 > maximum_retry: self.logger.error( 'Exceed maximum retry limitation %s, ' 'transaction %s failed', maximum_retry, transaction.guid) transaction.status = self.STATUS_FAILED transaction.updated_at = now self.session.add(transaction) self.session.flush() return transaction.external_id = transaction_id transaction.status = self.STATUS_DONE transaction.updated_at = tables.now_func() self.session.add(transaction) self.session.flush() self.logger.info('Processed transaction %s, status=%s, external_id=%s', transaction.guid, transaction.status, transaction.external_id)
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.status == self.STATUS_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.TYPE_SUBSCRIPTION: customer = transaction.invoice.subscription.customer else: customer = transaction.invoice.customer processor = self.factory.create_processor() method = { self.TYPE_CHARGE: processor.charge, self.TYPE_PAYOUT: processor.payout, self.TYPE_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 transaction_id = method(transaction) except (SystemExit, KeyboardInterrupt): raise except Exception, e: transaction.status = self.STATUS_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.status = self.STATUS_FAILED # the transaction is failed, update invoice status if transaction.transaction_type in [ self.TYPE_CHARGE, self.TYPE_PAYOUT, ]: transaction.invoice.status = invoice_model.STATUS_PROCESS_FAILED transaction.updated_at = now self.session.flush() return