示例#1
0
    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
示例#2
0
文件: plan.py 项目: JeffersonK/billy
    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
示例#3
0
    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
示例#4
0
    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
示例#5
0
    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
示例#6
0
    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
示例#7
0
    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
示例#8
0
    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()
示例#9
0
    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()
示例#10
0
    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()
示例#11
0
    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()
示例#12
0
    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()
示例#13
0
    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
示例#14
0
文件: company.py 项目: mjallday/billy
    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()
示例#15
0
    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
示例#16
0
    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()
示例#17
0
文件: company.py 项目: mjallday/billy
    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
示例#18
0
文件: plan.py 项目: JeffersonK/billy
    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()
示例#19
0
    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()
示例#20
0
    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()
示例#21
0
    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()
示例#22
0
    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()
示例#23
0
    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
示例#24
0
    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
示例#25
0
    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
示例#26
0
    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
示例#27
0
    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()
示例#28
0
    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
示例#29
0
    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
示例#30
0
    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
示例#31
0
    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 
示例#32
0
    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
示例#33
0
    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
示例#34
0
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)
示例#35
0
    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