def create( self, company, plan_type, amount, frequency, interval=1, external_id=None, name=None, description=None, ): """Create a plan and return its ID """ if interval < 1: raise ValueError('Interval can only be >= 1') now = tables.now_func() plan = tables.Plan( guid='PL' + make_guid(), company=company, plan_type=plan_type, amount=amount, frequency=frequency, interval=interval, external_id=external_id, name=name, description=description, updated_at=now, created_at=now, ) self.session.add(plan) self.session.flush() return plan
def create( self, 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 add_event(self, transaction, status, processor_id, occurred_at): """Add a status updating event of transaction from callback """ now = tables.now_func() # the latest event of this transaction last_event = transaction.events.first() event = tables.TransactionEvent( guid="TE" + make_guid(), transaction=transaction, processor_id=processor_id, occurred_at=occurred_at, status=status, created_at=now, ) self.session.add(event) # ensure won't duplicate try: self.session.flush() except IntegrityError: self.session.rollback() raise DuplicateEventError("Event {} already exists for {}".format(processor_id, transaction.guid)) # Notice: we only want to update transaction status if this event # is the latest one we had seen in Billy system. Why we are doing # here is because I think of some scenarios like # # 1. Balanced cannot reach Billy for a short while, and retry later # 2. Attacker want to fool us with old events # # These will lead the status of invoice to be updated incorrectly. # For case 1, events send to Billy like this: # # succeeded (failed to send to Billy, retry 1 minute later) # failed # succeeded (retry) # # See? The final status should be `failed`, but as the `succeeded` # was resent later, so it became `succeded` eventually. Similarly, # attackers can send us an old `succeeded` event to make the invoice # settled. This is why we need to ensure only the latest event can # affect status of invoice. if last_event is not None and occurred_at <= last_event.occurred_at: return old_status = transaction.status transaction.updated_at = now transaction.status = status # update invoice status invoice_model = self.factory.create_invoice_model() invoice_model.transaction_status_update( invoice=transaction.invoice, transaction=transaction, original_status=old_status ) self.session.flush()
def 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 create(self, transaction, error_message, error_code=None, error_number=None): """Create a failure for and return """ failure = self.TABLE( guid="TF" + make_guid(), transaction=transaction, error_message=error_message, error_code=error_code, error_number=error_number, ) self.session.add(failure) self.session.flush() return failure
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, 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 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 create( self, invoice, amount, transaction_type=None, funding_instrument_uri=None, reference_to=None, appears_on_statement_as=None, ): """Create a transaction and return """ if transaction_type is None: transaction_type = invoice.transaction_type if reference_to is not None: if transaction_type not in [self.types.REFUND, self.types.REVERSE]: raise ValueError('reference_to can only be set to a refund ' 'transaction') if funding_instrument_uri is not None: raise ValueError( 'funding_instrument_uri cannot be set to a refund/reverse ' 'transaction') if (reference_to.transaction_type not in [self.types.DEBIT, self.types.CREDIT]): raise ValueError( 'Only charge/payout transaction can be refunded/reversed') now = tables.now_func() transaction = tables.Transaction( guid='TX' + make_guid(), transaction_type=transaction_type, amount=amount, funding_instrument_uri=funding_instrument_uri, appears_on_statement_as=appears_on_statement_as, submit_status=self.submit_statuses.STAGED, reference_to=reference_to, created_at=now, updated_at=now, invoice=invoice, ) self.session.add(transaction) self.session.flush() return transaction
def create( self, company_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, transaction, error_message, error_code=None, error_number=None, ): """Create a failure for and return """ failure = self.TABLE( guid='TF' + make_guid(), transaction=transaction, error_message=error_message, error_code=error_code, error_number=error_number, ) self.session.add(failure) self.session.flush() return failure
def create( self, invoice, amount, transaction_type=None, funding_instrument_uri=None, reference_to=None, appears_on_statement_as=None, ): """Create a transaction and return """ if transaction_type is None: transaction_type = invoice.transaction_type if reference_to is not None: if transaction_type not in [self.types.REFUND, self.types.REVERSE]: raise ValueError("reference_to can only be set to a refund " "transaction") if funding_instrument_uri is not None: raise ValueError("funding_instrument_uri cannot be set to a refund/reverse " "transaction") if reference_to.transaction_type not in [self.types.DEBIT, self.types.CREDIT]: raise ValueError("Only charge/payout transaction can be refunded/reversed") now = tables.now_func() transaction = tables.Transaction( guid="TX" + make_guid(), transaction_type=transaction_type, amount=amount, funding_instrument_uri=funding_instrument_uri, appears_on_statement_as=appears_on_statement_as, submit_status=self.submit_statuses.STAGED, reference_to=reference_to, created_at=now, updated_at=now, invoice=invoice, ) self.session.add(transaction) self.session.flush() return transaction
def create(self, company, plan_type, amount, frequency, interval=1, external_id=None, name=None, description=None): """Create a plan and return its ID """ if interval < 1: raise ValueError("Interval can only be >= 1") now = tables.now_func() plan = tables.Plan( guid="PL" + make_guid(), company=company, plan_type=plan_type, amount=amount, frequency=frequency, interval=interval, external_id=external_id, name=name, description=description, updated_at=now, created_at=now, ) self.session.add(plan) self.session.flush() return plan
def create( self, customer, plan, funding_instrument_uri=None, started_at=None, external_id=None, appears_on_statement_as=None, amount=None, ): """Create a subscription and return its id """ if amount is not None and amount <= 0: raise ValueError('Amount should be a non-zero postive integer') now = tables.now_func() if started_at is None: started_at = now elif started_at < now: raise ValueError('Past started_at time is not allowed') subscription = tables.Subscription( guid='SU' + make_guid(), customer=customer, plan=plan, amount=amount, funding_instrument_uri=funding_instrument_uri, external_id=external_id, appears_on_statement_as=appears_on_statement_as, started_at=started_at, next_invoice_at=started_at, created_at=now, updated_at=now, ) self.session.add(subscription) self.session.flush() self.yield_invoices([subscription]) return subscription
def 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, 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, processor_key, name=None, make_callback_url=None): """Create a company and return """ now = tables.now_func() company = tables.Company( guid='CP' + make_guid(), processor_key=processor_key, api_key=make_api_key(), callback_key=make_api_key(), name=name, created_at=now, updated_at=now, ) self.session.add(company) self.session.flush() if make_callback_url is not None: url = make_callback_url(company) processor = self.factory.create_processor() processor.configure_api_key(company.processor_key) processor.register_callback(company, url) return company
def add_event(self, transaction, status, processor_id, occurred_at): """Add a status updating event of transaction from callback """ now = tables.now_func() # the latest event of this transaction last_event = transaction.events.first() event = tables.TransactionEvent( guid='TE' + make_guid(), transaction=transaction, processor_id=processor_id, occurred_at=occurred_at, status=status, created_at=now, ) self.session.add(event) # ensure won't duplicate try: self.session.flush() except IntegrityError: self.session.rollback() raise DuplicateEventError( 'Event {} already exists for {}'.format( processor_id, transaction.guid, ), ) # Notice: we only want to update transaction status if this event # is the latest one we had seen in Billy system. Why we are doing # here is because I think of some scenarios like # # 1. Balanced cannot reach Billy for a short while, and retry later # 2. Attacker want to fool us with old events # # These will lead the status of invoice to be updated incorrectly. # For case 1, events send to Billy like this: # # succeeded (failed to send to Billy, retry 1 minute later) # failed # succeeded (retry) # # See? The final status should be `failed`, but as the `succeeded` # was resent later, so it became `succeded` eventually. Similarly, # attackers can send us an old `succeeded` event to make the invoice # settled. This is why we need to ensure only the latest event can # affect status of invoice. if last_event is not None and occurred_at <= last_event.occurred_at: return old_status = transaction.status transaction.updated_at = now transaction.status = status # update invoice status invoice_model = self.factory.create_invoice_model() invoice_model.transaction_status_update( invoice=transaction.invoice, transaction=transaction, original_status=old_status, ) self.session.flush()
def test_make_guid(self): from billy.utils.generic import make_guid # just make sure it is random guids = [make_guid() for _ in range(100)] self.assertEqual(len(set(guids)), 100)
def test_make_guid(self): # just make sure it is random guids = [make_guid() for _ in range(100)] self.assertEqual(len(set(guids)), 100)
def create( self, amount, funding_instrument_uri=None, customer=None, subscription=None, title=None, items=None, adjustments=None, external_id=None, appears_on_statement_as=None, scheduled_at=None, ): """Create a invoice and return its id """ from sqlalchemy.exc import IntegrityError if customer is not None and subscription is not None: raise ValueError('You can only set either customer or subscription') if customer is not None: invoice_type = self.types.CUSTOMER invoice_cls = tables.CustomerInvoice # we only support charge type for customer invoice now transaction_type = self.transaction_types.DEBIT extra_kwargs = dict( customer=customer, external_id=external_id, ) elif subscription is not None: if scheduled_at is None: raise ValueError('scheduled_at cannot be None') invoice_type = self.types.SUBSCRIPTION invoice_cls = tables.SubscriptionInvoice plan_type = subscription.plan.plan_type if plan_type == PlanModel.types.DEBIT: transaction_type = self.transaction_types.DEBIT elif plan_type == PlanModel.types.CREDIT: transaction_type = self.transaction_types.CREDIT else: raise ValueError('Invalid plan_type {}'.format(plan_type)) extra_kwargs = dict( subscription=subscription, scheduled_at=scheduled_at, ) else: raise ValueError('You have to set either customer or subscription') if amount < 0: raise ValueError('Negative amount {} is not allowed'.format(amount)) now = tables.now_func() invoice = invoice_cls( guid='IV' + make_guid(), invoice_type=invoice_type, transaction_type=transaction_type, status=self.statuses.STAGED, amount=amount, funding_instrument_uri=funding_instrument_uri, title=title, created_at=now, updated_at=now, appears_on_statement_as=appears_on_statement_as, **extra_kwargs ) self.session.add(invoice) # ensure (customer_guid, external_id) is unique try: self.session.flush() except IntegrityError: self.session.rollback() raise DuplicateExternalIDError( 'Invoice {} with external_id {} already exists' .format(customer.guid, external_id) ) if items: for item in items: record = tables.Item( invoice=invoice, name=item['name'], amount=item['amount'], type=item.get('type'), quantity=item.get('quantity'), unit=item.get('unit'), volume=item.get('volume'), ) self.session.add(record) self.session.flush() # TODO: what about an invalid adjust? say, it makes the total of invoice # a negative value? I think we should not allow user to create such # invalid invoice if adjustments: for adjustment in adjustments: record = tables.Adjustment( invoice=invoice, amount=adjustment['amount'], reason=adjustment.get('reason'), ) self.session.add(record) self.session.flush() # as if we set the funding_instrument_uri at very first, we want to charge it # immediately, so we create a transaction right away, also set the # status to PROCESSING if funding_instrument_uri is not None and invoice.amount > 0: invoice.status = self.statuses.PROCESSING self._create_transaction(invoice) # it is zero amount, nothing to charge, just switch to # SETTLED status elif invoice.amount == 0: invoice.status = self.statuses.SETTLED self.session.flush() return invoice