mirror of
				https://github.com/zulip/zulip.git
				synced 2025-11-04 05:53:43 +00:00 
			
		
		
		
	billing: Add backend for paying by invoice.
This commit is contained in:
		@@ -34,6 +34,9 @@ log_to_file(logging.getLogger('stripe'), BILLING_LOG_PATH)
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
CallableT = TypeVar('CallableT', bound=Callable[..., Any])
 | 
					CallableT = TypeVar('CallableT', bound=Callable[..., Any])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					MIN_INVOICED_SEAT_COUNT = 30
 | 
				
			||||||
 | 
					DEFAULT_INVOICE_DAYS_UNTIL_DUE = 30
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def get_seat_count(realm: Realm) -> int:
 | 
					def get_seat_count(realm: Realm) -> int:
 | 
				
			||||||
    return UserProfile.objects.filter(realm=realm, is_active=True, is_bot=False).count()
 | 
					    return UserProfile.objects.filter(realm=realm, is_active=True, is_bot=False).count()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -202,7 +205,7 @@ def do_replace_coupon(user: UserProfile, coupon: Coupon) -> stripe.Customer:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
@catch_stripe_errors
 | 
					@catch_stripe_errors
 | 
				
			||||||
def do_subscribe_customer_to_plan(user: UserProfile, stripe_customer: stripe.Customer, stripe_plan_id: str,
 | 
					def do_subscribe_customer_to_plan(user: UserProfile, stripe_customer: stripe.Customer, stripe_plan_id: str,
 | 
				
			||||||
                                  seat_count: int, tax_percent: float) -> None:
 | 
					                                  seat_count: int, tax_percent: float, charge_automatically: bool) -> None:
 | 
				
			||||||
    if extract_current_subscription(stripe_customer) is not None:
 | 
					    if extract_current_subscription(stripe_customer) is not None:
 | 
				
			||||||
        # Most likely due to two people in the org going to the billing page,
 | 
					        # Most likely due to two people in the org going to the billing page,
 | 
				
			||||||
        # and then both upgrading their plan. We don't send clients
 | 
					        # and then both upgrading their plan. We don't send clients
 | 
				
			||||||
@@ -212,6 +215,12 @@ def do_subscribe_customer_to_plan(user: UserProfile, stripe_customer: stripe.Cus
 | 
				
			|||||||
                             "but has an active subscription" % (stripe_customer.id, stripe_plan_id))
 | 
					                             "but has an active subscription" % (stripe_customer.id, stripe_plan_id))
 | 
				
			||||||
        raise BillingError('subscribing with existing subscription', BillingError.TRY_RELOADING)
 | 
					        raise BillingError('subscribing with existing subscription', BillingError.TRY_RELOADING)
 | 
				
			||||||
    customer = Customer.objects.get(stripe_customer_id=stripe_customer.id)
 | 
					    customer = Customer.objects.get(stripe_customer_id=stripe_customer.id)
 | 
				
			||||||
 | 
					    if charge_automatically:
 | 
				
			||||||
 | 
					        billing_method = 'charge_automatically'
 | 
				
			||||||
 | 
					        days_until_due = None
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        billing_method = 'send_invoice'
 | 
				
			||||||
 | 
					        days_until_due = DEFAULT_INVOICE_DAYS_UNTIL_DUE
 | 
				
			||||||
    # Note that there is a race condition here, where if two users upgrade at exactly the
 | 
					    # Note that there is a race condition here, where if two users upgrade at exactly the
 | 
				
			||||||
    # same time, they will have two subscriptions, and get charged twice. We could try to
 | 
					    # same time, they will have two subscriptions, and get charged twice. We could try to
 | 
				
			||||||
    # reduce the chance of it with a well-designed idempotency_key, but it's not easy since
 | 
					    # reduce the chance of it with a well-designed idempotency_key, but it's not easy since
 | 
				
			||||||
@@ -222,7 +231,8 @@ def do_subscribe_customer_to_plan(user: UserProfile, stripe_customer: stripe.Cus
 | 
				
			|||||||
    # Otherwise we should expect it to throw a stripe.error.
 | 
					    # Otherwise we should expect it to throw a stripe.error.
 | 
				
			||||||
    stripe_subscription = stripe.Subscription.create(
 | 
					    stripe_subscription = stripe.Subscription.create(
 | 
				
			||||||
        customer=stripe_customer.id,
 | 
					        customer=stripe_customer.id,
 | 
				
			||||||
        billing='charge_automatically',
 | 
					        billing=billing_method,
 | 
				
			||||||
 | 
					        days_until_due=days_until_due,
 | 
				
			||||||
        items=[{
 | 
					        items=[{
 | 
				
			||||||
            'plan': stripe_plan_id,
 | 
					            'plan': stripe_plan_id,
 | 
				
			||||||
            'quantity': seat_count,
 | 
					            'quantity': seat_count,
 | 
				
			||||||
@@ -239,7 +249,8 @@ def do_subscribe_customer_to_plan(user: UserProfile, stripe_customer: stripe.Cus
 | 
				
			|||||||
            acting_user=user,
 | 
					            acting_user=user,
 | 
				
			||||||
            event_type=RealmAuditLog.STRIPE_PLAN_CHANGED,
 | 
					            event_type=RealmAuditLog.STRIPE_PLAN_CHANGED,
 | 
				
			||||||
            event_time=timestamp_to_datetime(stripe_subscription.created),
 | 
					            event_time=timestamp_to_datetime(stripe_subscription.created),
 | 
				
			||||||
            extra_data=ujson.dumps({'plan': stripe_plan_id, 'quantity': seat_count}))
 | 
					            extra_data=ujson.dumps({'plan': stripe_plan_id, 'quantity': seat_count,
 | 
				
			||||||
 | 
					                                    'billing_method': billing_method}))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        current_seat_count = get_seat_count(customer.realm)
 | 
					        current_seat_count = get_seat_count(customer.realm)
 | 
				
			||||||
        if seat_count != current_seat_count:
 | 
					        if seat_count != current_seat_count:
 | 
				
			||||||
@@ -250,11 +261,14 @@ def do_subscribe_customer_to_plan(user: UserProfile, stripe_customer: stripe.Cus
 | 
				
			|||||||
                requires_billing_update=True,
 | 
					                requires_billing_update=True,
 | 
				
			||||||
                extra_data=ujson.dumps({'quantity': current_seat_count}))
 | 
					                extra_data=ujson.dumps({'quantity': current_seat_count}))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def process_initial_upgrade(user: UserProfile, plan: Plan, seat_count: int, stripe_token: str) -> None:
 | 
					def process_initial_upgrade(user: UserProfile, plan: Plan, seat_count: int,
 | 
				
			||||||
 | 
					                            stripe_token: Optional[str]) -> None:
 | 
				
			||||||
    customer = Customer.objects.filter(realm=user.realm).first()
 | 
					    customer = Customer.objects.filter(realm=user.realm).first()
 | 
				
			||||||
    if customer is None:
 | 
					    if customer is None:
 | 
				
			||||||
        stripe_customer = do_create_customer(user, stripe_token=stripe_token)
 | 
					        stripe_customer = do_create_customer(user, stripe_token=stripe_token)
 | 
				
			||||||
    else:
 | 
					    # elif instead of if since we want to avoid doing two round trips to
 | 
				
			||||||
 | 
					    # stripe if we can
 | 
				
			||||||
 | 
					    elif stripe_token is not None:
 | 
				
			||||||
        stripe_customer = do_replace_payment_source(user, stripe_token)
 | 
					        stripe_customer = do_replace_payment_source(user, stripe_token)
 | 
				
			||||||
    do_subscribe_customer_to_plan(
 | 
					    do_subscribe_customer_to_plan(
 | 
				
			||||||
        user=user,
 | 
					        user=user,
 | 
				
			||||||
@@ -263,7 +277,8 @@ def process_initial_upgrade(user: UserProfile, plan: Plan, seat_count: int, stri
 | 
				
			|||||||
        seat_count=seat_count,
 | 
					        seat_count=seat_count,
 | 
				
			||||||
        # TODO: billing address details are passed to us in the request;
 | 
					        # TODO: billing address details are passed to us in the request;
 | 
				
			||||||
        # use that to calculate taxes.
 | 
					        # use that to calculate taxes.
 | 
				
			||||||
        tax_percent=0)
 | 
					        tax_percent=0,
 | 
				
			||||||
 | 
					        charge_automatically=(stripe_token is not None))
 | 
				
			||||||
    do_change_plan_type(user, Realm.STANDARD)
 | 
					    do_change_plan_type(user, Realm.STANDARD)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def attach_discount_to_realm(user: UserProfile, percent_off: int) -> None:
 | 
					def attach_discount_to_realm(user: UserProfile, percent_off: int) -> None:
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,35 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "account_balance": 0,
 | 
				
			||||||
 | 
					  "created": 1542524016,
 | 
				
			||||||
 | 
					  "currency": null,
 | 
				
			||||||
 | 
					  "default_source": null,
 | 
				
			||||||
 | 
					  "delinquent": false,
 | 
				
			||||||
 | 
					  "description": "zulip (Zulip Dev)",
 | 
				
			||||||
 | 
					  "discount": null,
 | 
				
			||||||
 | 
					  "email": "hamlet@zulip.com",
 | 
				
			||||||
 | 
					  "id": "cus_NORMALIZED0001",
 | 
				
			||||||
 | 
					  "invoice_prefix": "NORMA01",
 | 
				
			||||||
 | 
					  "livemode": false,
 | 
				
			||||||
 | 
					  "metadata": {
 | 
				
			||||||
 | 
					    "realm_id": "1",
 | 
				
			||||||
 | 
					    "realm_str": "zulip"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "object": "customer",
 | 
				
			||||||
 | 
					  "shipping": null,
 | 
				
			||||||
 | 
					  "sources": {
 | 
				
			||||||
 | 
					    "data": [],
 | 
				
			||||||
 | 
					    "has_more": false,
 | 
				
			||||||
 | 
					    "object": "list",
 | 
				
			||||||
 | 
					    "total_count": 0,
 | 
				
			||||||
 | 
					    "url": "/v1/customers/cus_NORMALIZED0001/sources"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "subscriptions": {
 | 
				
			||||||
 | 
					    "data": [],
 | 
				
			||||||
 | 
					    "has_more": false,
 | 
				
			||||||
 | 
					    "object": "list",
 | 
				
			||||||
 | 
					    "total_count": 0,
 | 
				
			||||||
 | 
					    "url": "/v1/customers/cus_NORMALIZED0001/subscriptions"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "tax_info": null,
 | 
				
			||||||
 | 
					  "tax_info_verification": null
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -0,0 +1,119 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "account_balance": 0,
 | 
				
			||||||
 | 
					  "created": 1542524016,
 | 
				
			||||||
 | 
					  "currency": "usd",
 | 
				
			||||||
 | 
					  "default_source": null,
 | 
				
			||||||
 | 
					  "delinquent": false,
 | 
				
			||||||
 | 
					  "description": "zulip (Zulip Dev)",
 | 
				
			||||||
 | 
					  "discount": null,
 | 
				
			||||||
 | 
					  "email": "hamlet@zulip.com",
 | 
				
			||||||
 | 
					  "id": "cus_NORMALIZED0001",
 | 
				
			||||||
 | 
					  "invoice_prefix": "NORMA01",
 | 
				
			||||||
 | 
					  "livemode": false,
 | 
				
			||||||
 | 
					  "metadata": {
 | 
				
			||||||
 | 
					    "realm_id": "1",
 | 
				
			||||||
 | 
					    "realm_str": "zulip"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "object": "customer",
 | 
				
			||||||
 | 
					  "shipping": null,
 | 
				
			||||||
 | 
					  "sources": {
 | 
				
			||||||
 | 
					    "data": [],
 | 
				
			||||||
 | 
					    "has_more": false,
 | 
				
			||||||
 | 
					    "object": "list",
 | 
				
			||||||
 | 
					    "total_count": 0,
 | 
				
			||||||
 | 
					    "url": "/v1/customers/cus_NORMALIZED0001/sources"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "subscriptions": {
 | 
				
			||||||
 | 
					    "data": [
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "application_fee_percent": null,
 | 
				
			||||||
 | 
					        "billing": "send_invoice",
 | 
				
			||||||
 | 
					        "billing_cycle_anchor": 1542524017,
 | 
				
			||||||
 | 
					        "cancel_at_period_end": false,
 | 
				
			||||||
 | 
					        "canceled_at": null,
 | 
				
			||||||
 | 
					        "created": 1542524017,
 | 
				
			||||||
 | 
					        "current_period_end": 1574060017,
 | 
				
			||||||
 | 
					        "current_period_start": 1542524017,
 | 
				
			||||||
 | 
					        "customer": "cus_NORMALIZED0001",
 | 
				
			||||||
 | 
					        "days_until_due": 30,
 | 
				
			||||||
 | 
					        "default_source": null,
 | 
				
			||||||
 | 
					        "discount": null,
 | 
				
			||||||
 | 
					        "ended_at": null,
 | 
				
			||||||
 | 
					        "id": "sub_NORMALIZED0001",
 | 
				
			||||||
 | 
					        "items": {
 | 
				
			||||||
 | 
					          "data": [
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					              "created": 1542524017,
 | 
				
			||||||
 | 
					              "id": "si_NORMALIZED0001",
 | 
				
			||||||
 | 
					              "metadata": {},
 | 
				
			||||||
 | 
					              "object": "subscription_item",
 | 
				
			||||||
 | 
					              "plan": {
 | 
				
			||||||
 | 
					                "active": true,
 | 
				
			||||||
 | 
					                "aggregate_usage": null,
 | 
				
			||||||
 | 
					                "amount": 8000,
 | 
				
			||||||
 | 
					                "billing_scheme": "per_unit",
 | 
				
			||||||
 | 
					                "created": 1539831971,
 | 
				
			||||||
 | 
					                "currency": "usd",
 | 
				
			||||||
 | 
					                "id": "plan_Do3xCvbzO89OsR",
 | 
				
			||||||
 | 
					                "interval": "year",
 | 
				
			||||||
 | 
					                "interval_count": 1,
 | 
				
			||||||
 | 
					                "livemode": false,
 | 
				
			||||||
 | 
					                "metadata": {},
 | 
				
			||||||
 | 
					                "nickname": "annual",
 | 
				
			||||||
 | 
					                "object": "plan",
 | 
				
			||||||
 | 
					                "product": "prod_Do3x494SetTDpx",
 | 
				
			||||||
 | 
					                "tiers": null,
 | 
				
			||||||
 | 
					                "tiers_mode": null,
 | 
				
			||||||
 | 
					                "transform_usage": null,
 | 
				
			||||||
 | 
					                "trial_period_days": null,
 | 
				
			||||||
 | 
					                "usage_type": "licensed"
 | 
				
			||||||
 | 
					              },
 | 
				
			||||||
 | 
					              "quantity": 123,
 | 
				
			||||||
 | 
					              "subscription": "sub_NORMALIZED0001"
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					          "has_more": false,
 | 
				
			||||||
 | 
					          "object": "list",
 | 
				
			||||||
 | 
					          "total_count": 1,
 | 
				
			||||||
 | 
					          "url": "/v1/subscription_items?subscription=sub_NORMALIZED0001"
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "livemode": false,
 | 
				
			||||||
 | 
					        "metadata": {},
 | 
				
			||||||
 | 
					        "object": "subscription",
 | 
				
			||||||
 | 
					        "plan": {
 | 
				
			||||||
 | 
					          "active": true,
 | 
				
			||||||
 | 
					          "aggregate_usage": null,
 | 
				
			||||||
 | 
					          "amount": 8000,
 | 
				
			||||||
 | 
					          "billing_scheme": "per_unit",
 | 
				
			||||||
 | 
					          "created": 1539831971,
 | 
				
			||||||
 | 
					          "currency": "usd",
 | 
				
			||||||
 | 
					          "id": "plan_Do3xCvbzO89OsR",
 | 
				
			||||||
 | 
					          "interval": "year",
 | 
				
			||||||
 | 
					          "interval_count": 1,
 | 
				
			||||||
 | 
					          "livemode": false,
 | 
				
			||||||
 | 
					          "metadata": {},
 | 
				
			||||||
 | 
					          "nickname": "annual",
 | 
				
			||||||
 | 
					          "object": "plan",
 | 
				
			||||||
 | 
					          "product": "prod_Do3x494SetTDpx",
 | 
				
			||||||
 | 
					          "tiers": null,
 | 
				
			||||||
 | 
					          "tiers_mode": null,
 | 
				
			||||||
 | 
					          "transform_usage": null,
 | 
				
			||||||
 | 
					          "trial_period_days": null,
 | 
				
			||||||
 | 
					          "usage_type": "licensed"
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "quantity": 123,
 | 
				
			||||||
 | 
					        "start": 1542524017,
 | 
				
			||||||
 | 
					        "status": "active",
 | 
				
			||||||
 | 
					        "tax_percent": 0.0,
 | 
				
			||||||
 | 
					        "trial_end": null,
 | 
				
			||||||
 | 
					        "trial_start": null
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    "has_more": false,
 | 
				
			||||||
 | 
					    "object": "list",
 | 
				
			||||||
 | 
					    "total_count": 1,
 | 
				
			||||||
 | 
					    "url": "/v1/customers/cus_NORMALIZED0001/subscriptions"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "tax_info": null,
 | 
				
			||||||
 | 
					  "tax_info_verification": null
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -0,0 +1,209 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "account_balance": 0,
 | 
				
			||||||
 | 
					  "created": 1542524016,
 | 
				
			||||||
 | 
					  "currency": "usd",
 | 
				
			||||||
 | 
					  "default_source": {
 | 
				
			||||||
 | 
					    "ach_credit_transfer": {
 | 
				
			||||||
 | 
					      "account_number": "test_c61c954f0ca8",
 | 
				
			||||||
 | 
					      "bank_name": "TEST BANK",
 | 
				
			||||||
 | 
					      "fingerprint": "NORMALIZED000001",
 | 
				
			||||||
 | 
					      "refund_account_holder_name": null,
 | 
				
			||||||
 | 
					      "refund_account_holder_type": null,
 | 
				
			||||||
 | 
					      "refund_account_number": null,
 | 
				
			||||||
 | 
					      "refund_routing_number": null,
 | 
				
			||||||
 | 
					      "routing_number": "110000000",
 | 
				
			||||||
 | 
					      "swift_code": "TSTEZ122"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "amount": null,
 | 
				
			||||||
 | 
					    "client_secret": "src_client_secret_DzjdyQ9DrS7xsDJprt8u1VGU",
 | 
				
			||||||
 | 
					    "created": 1542524018,
 | 
				
			||||||
 | 
					    "currency": "usd",
 | 
				
			||||||
 | 
					    "customer": "cus_NORMALIZED0001",
 | 
				
			||||||
 | 
					    "flow": "receiver",
 | 
				
			||||||
 | 
					    "id": "src_1DXkA2Gh0CmXqmnwvjBfz4VX",
 | 
				
			||||||
 | 
					    "livemode": false,
 | 
				
			||||||
 | 
					    "metadata": {},
 | 
				
			||||||
 | 
					    "object": "source",
 | 
				
			||||||
 | 
					    "owner": {
 | 
				
			||||||
 | 
					      "address": null,
 | 
				
			||||||
 | 
					      "email": "amount_0@stripe.com",
 | 
				
			||||||
 | 
					      "name": null,
 | 
				
			||||||
 | 
					      "phone": null,
 | 
				
			||||||
 | 
					      "verified_address": null,
 | 
				
			||||||
 | 
					      "verified_email": null,
 | 
				
			||||||
 | 
					      "verified_name": null,
 | 
				
			||||||
 | 
					      "verified_phone": null
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "receiver": {
 | 
				
			||||||
 | 
					      "address": "110000000-test_c61c954f0ca8",
 | 
				
			||||||
 | 
					      "amount_charged": 0,
 | 
				
			||||||
 | 
					      "amount_received": 0,
 | 
				
			||||||
 | 
					      "amount_returned": 0,
 | 
				
			||||||
 | 
					      "refund_attributes_method": "email",
 | 
				
			||||||
 | 
					      "refund_attributes_status": "missing"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "statement_descriptor": null,
 | 
				
			||||||
 | 
					    "status": "pending",
 | 
				
			||||||
 | 
					    "type": "ach_credit_transfer",
 | 
				
			||||||
 | 
					    "usage": "reusable"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "delinquent": false,
 | 
				
			||||||
 | 
					  "description": "zulip (Zulip Dev)",
 | 
				
			||||||
 | 
					  "discount": null,
 | 
				
			||||||
 | 
					  "email": "hamlet@zulip.com",
 | 
				
			||||||
 | 
					  "id": "cus_NORMALIZED0001",
 | 
				
			||||||
 | 
					  "invoice_prefix": "NORMA01",
 | 
				
			||||||
 | 
					  "livemode": false,
 | 
				
			||||||
 | 
					  "metadata": {
 | 
				
			||||||
 | 
					    "realm_id": "1",
 | 
				
			||||||
 | 
					    "realm_str": "zulip"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "object": "customer",
 | 
				
			||||||
 | 
					  "shipping": null,
 | 
				
			||||||
 | 
					  "sources": {
 | 
				
			||||||
 | 
					    "data": [
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "ach_credit_transfer": {
 | 
				
			||||||
 | 
					          "account_number": "test_c61c954f0ca8",
 | 
				
			||||||
 | 
					          "bank_name": "TEST BANK",
 | 
				
			||||||
 | 
					          "fingerprint": "NORMALIZED000001",
 | 
				
			||||||
 | 
					          "refund_account_holder_name": null,
 | 
				
			||||||
 | 
					          "refund_account_holder_type": null,
 | 
				
			||||||
 | 
					          "refund_account_number": null,
 | 
				
			||||||
 | 
					          "refund_routing_number": null,
 | 
				
			||||||
 | 
					          "routing_number": "110000000",
 | 
				
			||||||
 | 
					          "swift_code": "TSTEZ122"
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "amount": null,
 | 
				
			||||||
 | 
					        "client_secret": "src_client_secret_DzjdyQ9DrS7xsDJprt8u1VGU",
 | 
				
			||||||
 | 
					        "created": 1542524018,
 | 
				
			||||||
 | 
					        "currency": "usd",
 | 
				
			||||||
 | 
					        "customer": "cus_NORMALIZED0001",
 | 
				
			||||||
 | 
					        "flow": "receiver",
 | 
				
			||||||
 | 
					        "id": "src_1DXkA2Gh0CmXqmnwvjBfz4VX",
 | 
				
			||||||
 | 
					        "livemode": false,
 | 
				
			||||||
 | 
					        "metadata": {},
 | 
				
			||||||
 | 
					        "object": "source",
 | 
				
			||||||
 | 
					        "owner": {
 | 
				
			||||||
 | 
					          "address": null,
 | 
				
			||||||
 | 
					          "email": "amount_0@stripe.com",
 | 
				
			||||||
 | 
					          "name": null,
 | 
				
			||||||
 | 
					          "phone": null,
 | 
				
			||||||
 | 
					          "verified_address": null,
 | 
				
			||||||
 | 
					          "verified_email": null,
 | 
				
			||||||
 | 
					          "verified_name": null,
 | 
				
			||||||
 | 
					          "verified_phone": null
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "receiver": {
 | 
				
			||||||
 | 
					          "address": "110000000-test_c61c954f0ca8",
 | 
				
			||||||
 | 
					          "amount_charged": 0,
 | 
				
			||||||
 | 
					          "amount_received": 0,
 | 
				
			||||||
 | 
					          "amount_returned": 0,
 | 
				
			||||||
 | 
					          "refund_attributes_method": "email",
 | 
				
			||||||
 | 
					          "refund_attributes_status": "missing"
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "statement_descriptor": null,
 | 
				
			||||||
 | 
					        "status": "pending",
 | 
				
			||||||
 | 
					        "type": "ach_credit_transfer",
 | 
				
			||||||
 | 
					        "usage": "reusable"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    "has_more": false,
 | 
				
			||||||
 | 
					    "object": "list",
 | 
				
			||||||
 | 
					    "total_count": 1,
 | 
				
			||||||
 | 
					    "url": "/v1/customers/cus_NORMALIZED0001/sources"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "subscriptions": {
 | 
				
			||||||
 | 
					    "data": [
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "application_fee_percent": null,
 | 
				
			||||||
 | 
					        "billing": "send_invoice",
 | 
				
			||||||
 | 
					        "billing_cycle_anchor": 1542524017,
 | 
				
			||||||
 | 
					        "cancel_at_period_end": false,
 | 
				
			||||||
 | 
					        "canceled_at": null,
 | 
				
			||||||
 | 
					        "created": 1542524017,
 | 
				
			||||||
 | 
					        "current_period_end": 1574060017,
 | 
				
			||||||
 | 
					        "current_period_start": 1542524017,
 | 
				
			||||||
 | 
					        "customer": "cus_NORMALIZED0001",
 | 
				
			||||||
 | 
					        "days_until_due": 30,
 | 
				
			||||||
 | 
					        "default_source": null,
 | 
				
			||||||
 | 
					        "discount": null,
 | 
				
			||||||
 | 
					        "ended_at": null,
 | 
				
			||||||
 | 
					        "id": "sub_NORMALIZED0001",
 | 
				
			||||||
 | 
					        "items": {
 | 
				
			||||||
 | 
					          "data": [
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					              "created": 1542524017,
 | 
				
			||||||
 | 
					              "id": "si_NORMALIZED0001",
 | 
				
			||||||
 | 
					              "metadata": {},
 | 
				
			||||||
 | 
					              "object": "subscription_item",
 | 
				
			||||||
 | 
					              "plan": {
 | 
				
			||||||
 | 
					                "active": true,
 | 
				
			||||||
 | 
					                "aggregate_usage": null,
 | 
				
			||||||
 | 
					                "amount": 8000,
 | 
				
			||||||
 | 
					                "billing_scheme": "per_unit",
 | 
				
			||||||
 | 
					                "created": 1539831971,
 | 
				
			||||||
 | 
					                "currency": "usd",
 | 
				
			||||||
 | 
					                "id": "plan_Do3xCvbzO89OsR",
 | 
				
			||||||
 | 
					                "interval": "year",
 | 
				
			||||||
 | 
					                "interval_count": 1,
 | 
				
			||||||
 | 
					                "livemode": false,
 | 
				
			||||||
 | 
					                "metadata": {},
 | 
				
			||||||
 | 
					                "nickname": "annual",
 | 
				
			||||||
 | 
					                "object": "plan",
 | 
				
			||||||
 | 
					                "product": "prod_Do3x494SetTDpx",
 | 
				
			||||||
 | 
					                "tiers": null,
 | 
				
			||||||
 | 
					                "tiers_mode": null,
 | 
				
			||||||
 | 
					                "transform_usage": null,
 | 
				
			||||||
 | 
					                "trial_period_days": null,
 | 
				
			||||||
 | 
					                "usage_type": "licensed"
 | 
				
			||||||
 | 
					              },
 | 
				
			||||||
 | 
					              "quantity": 8,
 | 
				
			||||||
 | 
					              "subscription": "sub_NORMALIZED0001"
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					          "has_more": false,
 | 
				
			||||||
 | 
					          "object": "list",
 | 
				
			||||||
 | 
					          "total_count": 1,
 | 
				
			||||||
 | 
					          "url": "/v1/subscription_items?subscription=sub_NORMALIZED0001"
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "livemode": false,
 | 
				
			||||||
 | 
					        "metadata": {},
 | 
				
			||||||
 | 
					        "object": "subscription",
 | 
				
			||||||
 | 
					        "plan": {
 | 
				
			||||||
 | 
					          "active": true,
 | 
				
			||||||
 | 
					          "aggregate_usage": null,
 | 
				
			||||||
 | 
					          "amount": 8000,
 | 
				
			||||||
 | 
					          "billing_scheme": "per_unit",
 | 
				
			||||||
 | 
					          "created": 1539831971,
 | 
				
			||||||
 | 
					          "currency": "usd",
 | 
				
			||||||
 | 
					          "id": "plan_Do3xCvbzO89OsR",
 | 
				
			||||||
 | 
					          "interval": "year",
 | 
				
			||||||
 | 
					          "interval_count": 1,
 | 
				
			||||||
 | 
					          "livemode": false,
 | 
				
			||||||
 | 
					          "metadata": {},
 | 
				
			||||||
 | 
					          "nickname": "annual",
 | 
				
			||||||
 | 
					          "object": "plan",
 | 
				
			||||||
 | 
					          "product": "prod_Do3x494SetTDpx",
 | 
				
			||||||
 | 
					          "tiers": null,
 | 
				
			||||||
 | 
					          "tiers_mode": null,
 | 
				
			||||||
 | 
					          "transform_usage": null,
 | 
				
			||||||
 | 
					          "trial_period_days": null,
 | 
				
			||||||
 | 
					          "usage_type": "licensed"
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "quantity": 8,
 | 
				
			||||||
 | 
					        "start": 1542524018,
 | 
				
			||||||
 | 
					        "status": "active",
 | 
				
			||||||
 | 
					        "tax_percent": 0.0,
 | 
				
			||||||
 | 
					        "trial_end": null,
 | 
				
			||||||
 | 
					        "trial_start": null
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    "has_more": false,
 | 
				
			||||||
 | 
					    "object": "list",
 | 
				
			||||||
 | 
					    "total_count": 1,
 | 
				
			||||||
 | 
					    "url": "/v1/customers/cus_NORMALIZED0001/subscriptions"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "tax_info": null,
 | 
				
			||||||
 | 
					  "tax_info_verification": null
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -0,0 +1,98 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "data": [
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      "amount_due": 984000,
 | 
				
			||||||
 | 
					      "amount_paid": 0,
 | 
				
			||||||
 | 
					      "amount_remaining": 984000,
 | 
				
			||||||
 | 
					      "application_fee": null,
 | 
				
			||||||
 | 
					      "attempt_count": 0,
 | 
				
			||||||
 | 
					      "attempted": false,
 | 
				
			||||||
 | 
					      "auto_advance": true,
 | 
				
			||||||
 | 
					      "billing": "send_invoice",
 | 
				
			||||||
 | 
					      "billing_reason": "subscription_create",
 | 
				
			||||||
 | 
					      "charge": null,
 | 
				
			||||||
 | 
					      "currency": "usd",
 | 
				
			||||||
 | 
					      "customer": "cus_NORMALIZED0001",
 | 
				
			||||||
 | 
					      "date": 1542524017,
 | 
				
			||||||
 | 
					      "default_source": null,
 | 
				
			||||||
 | 
					      "description": "",
 | 
				
			||||||
 | 
					      "discount": null,
 | 
				
			||||||
 | 
					      "due_date": 1545116017,
 | 
				
			||||||
 | 
					      "ending_balance": null,
 | 
				
			||||||
 | 
					      "finalized_at": null,
 | 
				
			||||||
 | 
					      "hosted_invoice_url": null,
 | 
				
			||||||
 | 
					      "id": "in_1DXkA1Gh0CmXqmnwNApgkOR1",
 | 
				
			||||||
 | 
					      "invoice_pdf": null,
 | 
				
			||||||
 | 
					      "lines": {
 | 
				
			||||||
 | 
					        "data": [
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            "amount": 984000,
 | 
				
			||||||
 | 
					            "currency": "usd",
 | 
				
			||||||
 | 
					            "description": "123 user \u00d7 Zulip Cloud Premium (at $80.00 / year)",
 | 
				
			||||||
 | 
					            "discountable": true,
 | 
				
			||||||
 | 
					            "id": "sli_NORMALIZED0001",
 | 
				
			||||||
 | 
					            "livemode": false,
 | 
				
			||||||
 | 
					            "metadata": {},
 | 
				
			||||||
 | 
					            "object": "line_item",
 | 
				
			||||||
 | 
					            "period": {
 | 
				
			||||||
 | 
					              "end": 1574060017,
 | 
				
			||||||
 | 
					              "start": 1542524017
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            "plan": {
 | 
				
			||||||
 | 
					              "active": true,
 | 
				
			||||||
 | 
					              "aggregate_usage": null,
 | 
				
			||||||
 | 
					              "amount": 8000,
 | 
				
			||||||
 | 
					              "billing_scheme": "per_unit",
 | 
				
			||||||
 | 
					              "created": 1539831971,
 | 
				
			||||||
 | 
					              "currency": "usd",
 | 
				
			||||||
 | 
					              "id": "plan_Do3xCvbzO89OsR",
 | 
				
			||||||
 | 
					              "interval": "year",
 | 
				
			||||||
 | 
					              "interval_count": 1,
 | 
				
			||||||
 | 
					              "livemode": false,
 | 
				
			||||||
 | 
					              "metadata": {},
 | 
				
			||||||
 | 
					              "nickname": "annual",
 | 
				
			||||||
 | 
					              "object": "plan",
 | 
				
			||||||
 | 
					              "product": "prod_Do3x494SetTDpx",
 | 
				
			||||||
 | 
					              "tiers": null,
 | 
				
			||||||
 | 
					              "tiers_mode": null,
 | 
				
			||||||
 | 
					              "transform_usage": null,
 | 
				
			||||||
 | 
					              "trial_period_days": null,
 | 
				
			||||||
 | 
					              "usage_type": "licensed"
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            "proration": false,
 | 
				
			||||||
 | 
					            "quantity": 123,
 | 
				
			||||||
 | 
					            "subscription": "sub_NORMALIZED0001",
 | 
				
			||||||
 | 
					            "subscription_item": "si_NORMALIZED0001",
 | 
				
			||||||
 | 
					            "type": "subscription"
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        "has_more": false,
 | 
				
			||||||
 | 
					        "object": "list",
 | 
				
			||||||
 | 
					        "total_count": 1,
 | 
				
			||||||
 | 
					        "url": "/v1/invoices/in_1DXkA1Gh0CmXqmnwNApgkOR1/lines"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "livemode": false,
 | 
				
			||||||
 | 
					      "metadata": {},
 | 
				
			||||||
 | 
					      "next_payment_attempt": null,
 | 
				
			||||||
 | 
					      "number": "NORMALI-0001",
 | 
				
			||||||
 | 
					      "object": "invoice",
 | 
				
			||||||
 | 
					      "paid": false,
 | 
				
			||||||
 | 
					      "payment_intent": null,
 | 
				
			||||||
 | 
					      "period_end": 1542524017,
 | 
				
			||||||
 | 
					      "period_start": 1542524017,
 | 
				
			||||||
 | 
					      "receipt_number": null,
 | 
				
			||||||
 | 
					      "starting_balance": 0,
 | 
				
			||||||
 | 
					      "statement_descriptor": null,
 | 
				
			||||||
 | 
					      "status": "draft",
 | 
				
			||||||
 | 
					      "subscription": "sub_NORMALIZED0001",
 | 
				
			||||||
 | 
					      "subtotal": 984000,
 | 
				
			||||||
 | 
					      "tax": 0,
 | 
				
			||||||
 | 
					      "tax_percent": 0.0,
 | 
				
			||||||
 | 
					      "total": 984000,
 | 
				
			||||||
 | 
					      "webhooks_delivered_at": 1542524017
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  "has_more": false,
 | 
				
			||||||
 | 
					  "object": "list",
 | 
				
			||||||
 | 
					  "url": "/v1/invoices"
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -0,0 +1,83 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "application_fee_percent": null,
 | 
				
			||||||
 | 
					  "billing": "send_invoice",
 | 
				
			||||||
 | 
					  "billing_cycle_anchor": 1542524017,
 | 
				
			||||||
 | 
					  "cancel_at_period_end": false,
 | 
				
			||||||
 | 
					  "canceled_at": null,
 | 
				
			||||||
 | 
					  "created": 1542524017,
 | 
				
			||||||
 | 
					  "current_period_end": 1574060017,
 | 
				
			||||||
 | 
					  "current_period_start": 1542524017,
 | 
				
			||||||
 | 
					  "customer": "cus_NORMALIZED0001",
 | 
				
			||||||
 | 
					  "days_until_due": 30,
 | 
				
			||||||
 | 
					  "default_source": null,
 | 
				
			||||||
 | 
					  "discount": null,
 | 
				
			||||||
 | 
					  "ended_at": null,
 | 
				
			||||||
 | 
					  "id": "sub_NORMALIZED0001",
 | 
				
			||||||
 | 
					  "items": {
 | 
				
			||||||
 | 
					    "data": [
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "created": 1542524017,
 | 
				
			||||||
 | 
					        "id": "si_NORMALIZED0001",
 | 
				
			||||||
 | 
					        "metadata": {},
 | 
				
			||||||
 | 
					        "object": "subscription_item",
 | 
				
			||||||
 | 
					        "plan": {
 | 
				
			||||||
 | 
					          "active": true,
 | 
				
			||||||
 | 
					          "aggregate_usage": null,
 | 
				
			||||||
 | 
					          "amount": 8000,
 | 
				
			||||||
 | 
					          "billing_scheme": "per_unit",
 | 
				
			||||||
 | 
					          "created": 1539831971,
 | 
				
			||||||
 | 
					          "currency": "usd",
 | 
				
			||||||
 | 
					          "id": "plan_Do3xCvbzO89OsR",
 | 
				
			||||||
 | 
					          "interval": "year",
 | 
				
			||||||
 | 
					          "interval_count": 1,
 | 
				
			||||||
 | 
					          "livemode": false,
 | 
				
			||||||
 | 
					          "metadata": {},
 | 
				
			||||||
 | 
					          "nickname": "annual",
 | 
				
			||||||
 | 
					          "object": "plan",
 | 
				
			||||||
 | 
					          "product": "prod_Do3x494SetTDpx",
 | 
				
			||||||
 | 
					          "tiers": null,
 | 
				
			||||||
 | 
					          "tiers_mode": null,
 | 
				
			||||||
 | 
					          "transform_usage": null,
 | 
				
			||||||
 | 
					          "trial_period_days": null,
 | 
				
			||||||
 | 
					          "usage_type": "licensed"
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "quantity": 123,
 | 
				
			||||||
 | 
					        "subscription": "sub_NORMALIZED0001"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    "has_more": false,
 | 
				
			||||||
 | 
					    "object": "list",
 | 
				
			||||||
 | 
					    "total_count": 1,
 | 
				
			||||||
 | 
					    "url": "/v1/subscription_items?subscription=sub_NORMALIZED0001"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "livemode": false,
 | 
				
			||||||
 | 
					  "metadata": {},
 | 
				
			||||||
 | 
					  "object": "subscription",
 | 
				
			||||||
 | 
					  "plan": {
 | 
				
			||||||
 | 
					    "active": true,
 | 
				
			||||||
 | 
					    "aggregate_usage": null,
 | 
				
			||||||
 | 
					    "amount": 8000,
 | 
				
			||||||
 | 
					    "billing_scheme": "per_unit",
 | 
				
			||||||
 | 
					    "created": 1539831971,
 | 
				
			||||||
 | 
					    "currency": "usd",
 | 
				
			||||||
 | 
					    "id": "plan_Do3xCvbzO89OsR",
 | 
				
			||||||
 | 
					    "interval": "year",
 | 
				
			||||||
 | 
					    "interval_count": 1,
 | 
				
			||||||
 | 
					    "livemode": false,
 | 
				
			||||||
 | 
					    "metadata": {},
 | 
				
			||||||
 | 
					    "nickname": "annual",
 | 
				
			||||||
 | 
					    "object": "plan",
 | 
				
			||||||
 | 
					    "product": "prod_Do3x494SetTDpx",
 | 
				
			||||||
 | 
					    "tiers": null,
 | 
				
			||||||
 | 
					    "tiers_mode": null,
 | 
				
			||||||
 | 
					    "transform_usage": null,
 | 
				
			||||||
 | 
					    "trial_period_days": null,
 | 
				
			||||||
 | 
					    "usage_type": "licensed"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "quantity": 123,
 | 
				
			||||||
 | 
					  "start": 1542524017,
 | 
				
			||||||
 | 
					  "status": "active",
 | 
				
			||||||
 | 
					  "tax_percent": 0.0,
 | 
				
			||||||
 | 
					  "trial_end": null,
 | 
				
			||||||
 | 
					  "trial_start": null
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -0,0 +1,83 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "application_fee_percent": null,
 | 
				
			||||||
 | 
					  "billing": "send_invoice",
 | 
				
			||||||
 | 
					  "billing_cycle_anchor": 1542524017,
 | 
				
			||||||
 | 
					  "cancel_at_period_end": false,
 | 
				
			||||||
 | 
					  "canceled_at": null,
 | 
				
			||||||
 | 
					  "created": 1542524017,
 | 
				
			||||||
 | 
					  "current_period_end": 1574060017,
 | 
				
			||||||
 | 
					  "current_period_start": 1542524017,
 | 
				
			||||||
 | 
					  "customer": "cus_NORMALIZED0001",
 | 
				
			||||||
 | 
					  "days_until_due": 30,
 | 
				
			||||||
 | 
					  "default_source": null,
 | 
				
			||||||
 | 
					  "discount": null,
 | 
				
			||||||
 | 
					  "ended_at": null,
 | 
				
			||||||
 | 
					  "id": "sub_NORMALIZED0001",
 | 
				
			||||||
 | 
					  "items": {
 | 
				
			||||||
 | 
					    "data": [
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "created": 1542524017,
 | 
				
			||||||
 | 
					        "id": "si_NORMALIZED0001",
 | 
				
			||||||
 | 
					        "metadata": {},
 | 
				
			||||||
 | 
					        "object": "subscription_item",
 | 
				
			||||||
 | 
					        "plan": {
 | 
				
			||||||
 | 
					          "active": true,
 | 
				
			||||||
 | 
					          "aggregate_usage": null,
 | 
				
			||||||
 | 
					          "amount": 8000,
 | 
				
			||||||
 | 
					          "billing_scheme": "per_unit",
 | 
				
			||||||
 | 
					          "created": 1539831971,
 | 
				
			||||||
 | 
					          "currency": "usd",
 | 
				
			||||||
 | 
					          "id": "plan_Do3xCvbzO89OsR",
 | 
				
			||||||
 | 
					          "interval": "year",
 | 
				
			||||||
 | 
					          "interval_count": 1,
 | 
				
			||||||
 | 
					          "livemode": false,
 | 
				
			||||||
 | 
					          "metadata": {},
 | 
				
			||||||
 | 
					          "nickname": "annual",
 | 
				
			||||||
 | 
					          "object": "plan",
 | 
				
			||||||
 | 
					          "product": "prod_Do3x494SetTDpx",
 | 
				
			||||||
 | 
					          "tiers": null,
 | 
				
			||||||
 | 
					          "tiers_mode": null,
 | 
				
			||||||
 | 
					          "transform_usage": null,
 | 
				
			||||||
 | 
					          "trial_period_days": null,
 | 
				
			||||||
 | 
					          "usage_type": "licensed"
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "quantity": 8,
 | 
				
			||||||
 | 
					        "subscription": "sub_NORMALIZED0001"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    "has_more": false,
 | 
				
			||||||
 | 
					    "object": "list",
 | 
				
			||||||
 | 
					    "total_count": 1,
 | 
				
			||||||
 | 
					    "url": "/v1/subscription_items?subscription=sub_NORMALIZED0001"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "livemode": false,
 | 
				
			||||||
 | 
					  "metadata": {},
 | 
				
			||||||
 | 
					  "object": "subscription",
 | 
				
			||||||
 | 
					  "plan": {
 | 
				
			||||||
 | 
					    "active": true,
 | 
				
			||||||
 | 
					    "aggregate_usage": null,
 | 
				
			||||||
 | 
					    "amount": 8000,
 | 
				
			||||||
 | 
					    "billing_scheme": "per_unit",
 | 
				
			||||||
 | 
					    "created": 1539831971,
 | 
				
			||||||
 | 
					    "currency": "usd",
 | 
				
			||||||
 | 
					    "id": "plan_Do3xCvbzO89OsR",
 | 
				
			||||||
 | 
					    "interval": "year",
 | 
				
			||||||
 | 
					    "interval_count": 1,
 | 
				
			||||||
 | 
					    "livemode": false,
 | 
				
			||||||
 | 
					    "metadata": {},
 | 
				
			||||||
 | 
					    "nickname": "annual",
 | 
				
			||||||
 | 
					    "object": "plan",
 | 
				
			||||||
 | 
					    "product": "prod_Do3x494SetTDpx",
 | 
				
			||||||
 | 
					    "tiers": null,
 | 
				
			||||||
 | 
					    "tiers_mode": null,
 | 
				
			||||||
 | 
					    "transform_usage": null,
 | 
				
			||||||
 | 
					    "trial_period_days": null,
 | 
				
			||||||
 | 
					    "usage_type": "licensed"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "quantity": 8,
 | 
				
			||||||
 | 
					  "start": 1542524018,
 | 
				
			||||||
 | 
					  "status": "active",
 | 
				
			||||||
 | 
					  "tax_percent": 0.0,
 | 
				
			||||||
 | 
					  "trial_end": null,
 | 
				
			||||||
 | 
					  "trial_start": null
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -26,7 +26,8 @@ from corporate.lib.stripe import catch_stripe_errors, \
 | 
				
			|||||||
    do_subscribe_customer_to_plan, attach_discount_to_realm, \
 | 
					    do_subscribe_customer_to_plan, attach_discount_to_realm, \
 | 
				
			||||||
    get_seat_count, extract_current_subscription, sign_string, unsign_string, \
 | 
					    get_seat_count, extract_current_subscription, sign_string, unsign_string, \
 | 
				
			||||||
    get_next_billing_log_entry, run_billing_processor_one_step, \
 | 
					    get_next_billing_log_entry, run_billing_processor_one_step, \
 | 
				
			||||||
    BillingError, StripeCardError, StripeConnectionError, stripe_get_customer
 | 
					    BillingError, StripeCardError, StripeConnectionError, stripe_get_customer, \
 | 
				
			||||||
 | 
					    DEFAULT_INVOICE_DAYS_UNTIL_DUE, MIN_INVOICED_SEAT_COUNT
 | 
				
			||||||
from corporate.models import Customer, Plan, Coupon, BillingProcessor
 | 
					from corporate.models import Customer, Plan, Coupon, BillingProcessor
 | 
				
			||||||
import corporate.urls
 | 
					import corporate.urls
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -206,6 +207,13 @@ class Kandra(object):
 | 
				
			|||||||
    def __eq__(self, other: Any) -> bool:
 | 
					    def __eq__(self, other: Any) -> bool:
 | 
				
			||||||
        return True
 | 
					        return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def process_all_billing_log_entries() -> None:
 | 
				
			||||||
 | 
					    assert not RealmAuditLog.objects.get(pk=1).requires_billing_update
 | 
				
			||||||
 | 
					    processor = BillingProcessor.objects.create(
 | 
				
			||||||
 | 
					        log_row=RealmAuditLog.objects.get(pk=1), realm=None, state=BillingProcessor.DONE)
 | 
				
			||||||
 | 
					    while run_billing_processor_one_step(processor):
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class StripeTest(ZulipTestCase):
 | 
					class StripeTest(ZulipTestCase):
 | 
				
			||||||
    @mock_stripe("Product.create", "Plan.create", "Coupon.create", generate=False)
 | 
					    @mock_stripe("Product.create", "Plan.create", "Coupon.create", generate=False)
 | 
				
			||||||
    def setUp(self, mock3: Mock, mock2: Mock, mock1: Mock) -> None:
 | 
					    def setUp(self, mock3: Mock, mock2: Mock, mock1: Mock) -> None:
 | 
				
			||||||
@@ -467,6 +475,30 @@ class StripeTest(ZulipTestCase):
 | 
				
			|||||||
        self.assert_in_success_response(["Upgrade to Zulip Standard"], response)
 | 
					        self.assert_in_success_response(["Upgrade to Zulip Standard"], response)
 | 
				
			||||||
        self.assertEqual(response['error_description'], 'tampered plan')
 | 
					        self.assertEqual(response['error_description'], 'tampered plan')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_upgrade_with_insufficient_invoiced_seat_count(self) -> None:
 | 
				
			||||||
 | 
					        self.login(self.example_email("hamlet"))
 | 
				
			||||||
 | 
					        # Test invoicing for less than MIN_INVOICED_SEAT_COUNT
 | 
				
			||||||
 | 
					        response = self.client_post("/upgrade/", {
 | 
				
			||||||
 | 
					            'invoiced_seat_count': self.quantity,
 | 
				
			||||||
 | 
					            'signed_seat_count': self.signed_seat_count,
 | 
				
			||||||
 | 
					            'salt': self.salt,
 | 
				
			||||||
 | 
					            'plan': Plan.CLOUD_ANNUAL
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					        self.assert_in_success_response(["Upgrade to Zulip Standard",
 | 
				
			||||||
 | 
					                                         "at least %d users" % (MIN_INVOICED_SEAT_COUNT,)], response)
 | 
				
			||||||
 | 
					        self.assertEqual(response['error_description'], 'lowball seat count')
 | 
				
			||||||
 | 
					        # Test invoicing for less than your user count
 | 
				
			||||||
 | 
					        with patch("corporate.views.MIN_INVOICED_SEAT_COUNT", 3):
 | 
				
			||||||
 | 
					            response = self.client_post("/upgrade/", {
 | 
				
			||||||
 | 
					                'invoiced_seat_count': self.quantity - 1,
 | 
				
			||||||
 | 
					                'signed_seat_count': self.signed_seat_count,
 | 
				
			||||||
 | 
					                'salt': self.salt,
 | 
				
			||||||
 | 
					                'plan': Plan.CLOUD_ANNUAL
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					        self.assert_in_success_response(["Upgrade to Zulip Standard",
 | 
				
			||||||
 | 
					                                         "at least %d users" % (self.quantity,)], response)
 | 
				
			||||||
 | 
					        self.assertEqual(response['error_description'], 'lowball seat count')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @patch("corporate.lib.stripe.billing_logger.error")
 | 
					    @patch("corporate.lib.stripe.billing_logger.error")
 | 
				
			||||||
    def test_upgrade_with_uncaught_exception(self, mock1: Mock) -> None:
 | 
					    def test_upgrade_with_uncaught_exception(self, mock1: Mock) -> None:
 | 
				
			||||||
        self.login(self.example_email("hamlet"))
 | 
					        self.login(self.example_email("hamlet"))
 | 
				
			||||||
@@ -481,6 +513,69 @@ class StripeTest(ZulipTestCase):
 | 
				
			|||||||
                                         "Something went wrong. Please contact"], response)
 | 
					                                         "Something went wrong. Please contact"], response)
 | 
				
			||||||
        self.assertEqual(response['error_description'], 'uncaught exception during upgrade')
 | 
					        self.assertEqual(response['error_description'], 'uncaught exception during upgrade')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @mock_stripe("Customer.create", "Subscription.create", "Subscription.save",
 | 
				
			||||||
 | 
					                 "Customer.retrieve", "Invoice.list")
 | 
				
			||||||
 | 
					    def test_upgrade_billing_by_invoice(self, mock5: Mock, mock4: Mock, mock3: Mock,
 | 
				
			||||||
 | 
					                                        mock2: Mock, mock1: Mock) -> None:
 | 
				
			||||||
 | 
					        user = self.example_user("hamlet")
 | 
				
			||||||
 | 
					        self.login(user.email)
 | 
				
			||||||
 | 
					        self.client_post("/upgrade/", {
 | 
				
			||||||
 | 
					            'invoiced_seat_count': 123,
 | 
				
			||||||
 | 
					            'signed_seat_count': self.signed_seat_count,
 | 
				
			||||||
 | 
					            'salt': self.salt,
 | 
				
			||||||
 | 
					            'plan': Plan.CLOUD_ANNUAL})
 | 
				
			||||||
 | 
					        process_all_billing_log_entries()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Check that we correctly created a Customer in Stripe
 | 
				
			||||||
 | 
					        stripe_customer = stripe_get_customer(Customer.objects.get(realm=user.realm).stripe_customer_id)
 | 
				
			||||||
 | 
					        self.assertEqual(stripe_customer.email, user.email)
 | 
				
			||||||
 | 
					        # It can take a second for Stripe to attach the source to the
 | 
				
			||||||
 | 
					        # customer, and in particular it may not be attached at the time
 | 
				
			||||||
 | 
					        # stripe_get_customer is called above, causing test flakes.
 | 
				
			||||||
 | 
					        # So commenting the next line out, but leaving it here so future readers know what
 | 
				
			||||||
 | 
					        # is supposed to happen here (e.g. the default_source is not None as it would be if
 | 
				
			||||||
 | 
					        # we had not added a Subscription).
 | 
				
			||||||
 | 
					        # self.assertEqual(stripe_customer.default_source.type, 'ach_credit_transfer')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Check that we correctly created a Subscription in Stripe
 | 
				
			||||||
 | 
					        stripe_subscription = extract_current_subscription(stripe_customer)
 | 
				
			||||||
 | 
					        self.assertEqual(stripe_subscription.billing, 'send_invoice')
 | 
				
			||||||
 | 
					        self.assertEqual(stripe_subscription.days_until_due, DEFAULT_INVOICE_DAYS_UNTIL_DUE)
 | 
				
			||||||
 | 
					        self.assertEqual(stripe_subscription.plan.id,
 | 
				
			||||||
 | 
					                         Plan.objects.get(nickname=Plan.CLOUD_ANNUAL).stripe_plan_id)
 | 
				
			||||||
 | 
					        self.assertEqual(stripe_subscription.quantity, get_seat_count(user.realm))
 | 
				
			||||||
 | 
					        self.assertEqual(stripe_subscription.status, 'active')
 | 
				
			||||||
 | 
					        # Check that we correctly created an initial Invoice in Stripe
 | 
				
			||||||
 | 
					        for stripe_invoice in stripe.Invoice.list(customer=stripe_customer.id, limit=1):
 | 
				
			||||||
 | 
					            self.assertTrue(stripe_invoice.auto_advance)
 | 
				
			||||||
 | 
					            self.assertEqual(stripe_invoice.billing, 'send_invoice')
 | 
				
			||||||
 | 
					            self.assertEqual(stripe_invoice.billing_reason, 'subscription_create')
 | 
				
			||||||
 | 
					            # Transitions to 'open' after 1-2 hours
 | 
				
			||||||
 | 
					            self.assertEqual(stripe_invoice.status, 'draft')
 | 
				
			||||||
 | 
					            # Very important. Check that we're invoicing for 123, and not get_seat_count
 | 
				
			||||||
 | 
					            self.assertEqual(stripe_invoice.amount_due, 8000*123)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Check that we correctly updated Realm
 | 
				
			||||||
 | 
					        realm = get_realm("zulip")
 | 
				
			||||||
 | 
					        self.assertTrue(realm.has_seat_based_plan)
 | 
				
			||||||
 | 
					        self.assertEqual(realm.plan_type, Realm.STANDARD)
 | 
				
			||||||
 | 
					        # Check that we created a Customer in Zulip
 | 
				
			||||||
 | 
					        self.assertEqual(1, Customer.objects.filter(stripe_customer_id=stripe_customer.id,
 | 
				
			||||||
 | 
					                                                    realm=realm).count())
 | 
				
			||||||
 | 
					        # Check that RealmAuditLog has STRIPE_PLAN_QUANTITY_RESET, and doesn't have STRIPE_CARD_CHANGED
 | 
				
			||||||
 | 
					        audit_log_entries = list(RealmAuditLog.objects.order_by('-id')
 | 
				
			||||||
 | 
					                                 .values_list('event_type', 'event_time',
 | 
				
			||||||
 | 
					                                              'requires_billing_update')[:4])[::-1]
 | 
				
			||||||
 | 
					        self.assertEqual(audit_log_entries, [
 | 
				
			||||||
 | 
					            (RealmAuditLog.STRIPE_CUSTOMER_CREATED, timestamp_to_datetime(stripe_customer.created), False),
 | 
				
			||||||
 | 
					            (RealmAuditLog.STRIPE_PLAN_CHANGED, timestamp_to_datetime(stripe_subscription.created), False),
 | 
				
			||||||
 | 
					            (RealmAuditLog.STRIPE_PLAN_QUANTITY_RESET, timestamp_to_datetime(stripe_subscription.created), True),
 | 
				
			||||||
 | 
					            (RealmAuditLog.REALM_PLAN_TYPE_CHANGED, Kandra(), False),
 | 
				
			||||||
 | 
					        ])
 | 
				
			||||||
 | 
					        self.assertEqual(ujson.loads(RealmAuditLog.objects.filter(
 | 
				
			||||||
 | 
					            event_type=RealmAuditLog.STRIPE_PLAN_QUANTITY_RESET).values_list('extra_data', flat=True).first()),
 | 
				
			||||||
 | 
					            {'quantity': self.quantity})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @patch("stripe.Customer.retrieve", side_effect=mock_customer_with_subscription)
 | 
					    @patch("stripe.Customer.retrieve", side_effect=mock_customer_with_subscription)
 | 
				
			||||||
    def test_redirect_for_billing_home(self, mock_customer_with_subscription: Mock) -> None:
 | 
					    def test_redirect_for_billing_home(self, mock_customer_with_subscription: Mock) -> None:
 | 
				
			||||||
        user = self.example_user("iago")
 | 
					        user = self.example_user("iago")
 | 
				
			||||||
@@ -532,7 +627,7 @@ class StripeTest(ZulipTestCase):
 | 
				
			|||||||
        with self.assertRaisesRegex(BillingError, 'subscribing with existing subscription'):
 | 
					        with self.assertRaisesRegex(BillingError, 'subscribing with existing subscription'):
 | 
				
			||||||
            do_subscribe_customer_to_plan(self.example_user("iago"),
 | 
					            do_subscribe_customer_to_plan(self.example_user("iago"),
 | 
				
			||||||
                                          mock_customer_with_subscription(),
 | 
					                                          mock_customer_with_subscription(),
 | 
				
			||||||
                                          self.stripe_plan_id, self.quantity, 0)
 | 
					                                          self.stripe_plan_id, self.quantity, 0, True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_sign_string(self) -> None:
 | 
					    def test_sign_string(self) -> None:
 | 
				
			||||||
        string = "abc"
 | 
					        string = "abc"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -18,7 +18,8 @@ from zerver.models import UserProfile, Realm
 | 
				
			|||||||
from corporate.lib.stripe import STRIPE_PUBLISHABLE_KEY, \
 | 
					from corporate.lib.stripe import STRIPE_PUBLISHABLE_KEY, \
 | 
				
			||||||
    stripe_get_customer, upcoming_invoice_total, get_seat_count, \
 | 
					    stripe_get_customer, upcoming_invoice_total, get_seat_count, \
 | 
				
			||||||
    extract_current_subscription, process_initial_upgrade, sign_string, \
 | 
					    extract_current_subscription, process_initial_upgrade, sign_string, \
 | 
				
			||||||
    unsign_string, BillingError, process_downgrade, do_replace_payment_source
 | 
					    unsign_string, BillingError, process_downgrade, do_replace_payment_source, \
 | 
				
			||||||
 | 
					    MIN_INVOICED_SEAT_COUNT
 | 
				
			||||||
from corporate.models import Customer, Plan
 | 
					from corporate.models import Customer, Plan
 | 
				
			||||||
 | 
					
 | 
				
			||||||
billing_logger = logging.getLogger('corporate.stripe')
 | 
					billing_logger = logging.getLogger('corporate.stripe')
 | 
				
			||||||
@@ -56,7 +57,14 @@ def initial_upgrade(request: HttpRequest) -> HttpResponse:
 | 
				
			|||||||
        try:
 | 
					        try:
 | 
				
			||||||
            plan, seat_count = unsign_and_check_upgrade_parameters(
 | 
					            plan, seat_count = unsign_and_check_upgrade_parameters(
 | 
				
			||||||
                user, request.POST['plan'], request.POST['signed_seat_count'], request.POST['salt'])
 | 
					                user, request.POST['plan'], request.POST['signed_seat_count'], request.POST['salt'])
 | 
				
			||||||
            process_initial_upgrade(user, plan, seat_count, request.POST['stripeToken'])
 | 
					            if 'invoiced_seat_count' in request.POST:
 | 
				
			||||||
 | 
					                min_required_seat_count = max(seat_count, MIN_INVOICED_SEAT_COUNT)
 | 
				
			||||||
 | 
					                if int(request.POST['invoiced_seat_count']) < min_required_seat_count:
 | 
				
			||||||
 | 
					                    raise BillingError(
 | 
				
			||||||
 | 
					                        'lowball seat count',
 | 
				
			||||||
 | 
					                        "You must invoice for at least %d users." % (min_required_seat_count,))
 | 
				
			||||||
 | 
					                seat_count = int(request.POST['invoiced_seat_count'])
 | 
				
			||||||
 | 
					            process_initial_upgrade(user, plan, seat_count, request.POST.get('stripeToken', None))
 | 
				
			||||||
        except BillingError as e:
 | 
					        except BillingError as e:
 | 
				
			||||||
            error_message = e.message
 | 
					            error_message = e.message
 | 
				
			||||||
            error_description = e.description
 | 
					            error_description = e.description
 | 
				
			||||||
@@ -129,8 +137,12 @@ def billing_home(request: HttpRequest) -> HttpResponse:
 | 
				
			|||||||
        renewal_amount = 0
 | 
					        renewal_amount = 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    payment_method = None
 | 
					    payment_method = None
 | 
				
			||||||
    if stripe_customer.default_source is not None:
 | 
					    stripe_source = stripe_customer.default_source
 | 
				
			||||||
        payment_method = "Card ending in %(last4)s" % {'last4': stripe_customer.default_source.last4}
 | 
					    if stripe_source is not None:
 | 
				
			||||||
 | 
					        if stripe_source.object == 'card':
 | 
				
			||||||
 | 
					            # To fix mypy error, set Customer.default_source: Union[Source, Card] in stubs and debug
 | 
				
			||||||
 | 
					            payment_method = "Card ending in %(last4)s" % \
 | 
				
			||||||
 | 
					                             {'last4': stripe_source.last4}  # type: ignore # see above
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    context.update({
 | 
					    context.update({
 | 
				
			||||||
        'plan_name': plan_name,
 | 
					        'plan_name': plan_name,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,7 +8,7 @@ from typing import Optional, Any, Dict, List, Union
 | 
				
			|||||||
api_key: Optional[str]
 | 
					api_key: Optional[str]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Customer:
 | 
					class Customer:
 | 
				
			||||||
    default_source: Card
 | 
					    default_source: Source
 | 
				
			||||||
    created: int
 | 
					    created: int
 | 
				
			||||||
    id: str
 | 
					    id: str
 | 
				
			||||||
    source: str
 | 
					    source: str
 | 
				
			||||||
@@ -43,7 +43,12 @@ class Customer:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Invoice:
 | 
					class Invoice:
 | 
				
			||||||
 | 
					    auto_advance: bool
 | 
				
			||||||
    amount_due: int
 | 
					    amount_due: int
 | 
				
			||||||
 | 
					    billing: str
 | 
				
			||||||
 | 
					    billing_reason: str
 | 
				
			||||||
 | 
					    default_source: Source
 | 
				
			||||||
 | 
					    status: str
 | 
				
			||||||
    total: int
 | 
					    total: int
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @staticmethod
 | 
					    @staticmethod
 | 
				
			||||||
@@ -51,16 +56,22 @@ class Invoice:
 | 
				
			|||||||
                 subscription_items: List[Dict[str, Union[str, int]]]=...) -> Invoice:
 | 
					                 subscription_items: List[Dict[str, Union[str, int]]]=...) -> Invoice:
 | 
				
			||||||
        ...
 | 
					        ...
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def list(customer: str=..., limit: Optional[int]=...) -> List[Invoice]:
 | 
				
			||||||
 | 
					        ...
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Subscription:
 | 
					class Subscription:
 | 
				
			||||||
    created: int
 | 
					    created: int
 | 
				
			||||||
    status: str
 | 
					    status: str
 | 
				
			||||||
    canceled_at: int
 | 
					    canceled_at: int
 | 
				
			||||||
    cancel_at_period_end: bool
 | 
					    cancel_at_period_end: bool
 | 
				
			||||||
 | 
					    days_until_due: Optional[int]
 | 
				
			||||||
    proration_date: int
 | 
					    proration_date: int
 | 
				
			||||||
    quantity: int
 | 
					    quantity: int
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @staticmethod
 | 
					    @staticmethod
 | 
				
			||||||
    def create(customer: str=..., billing: str=..., items: List[Dict[str, Any]]=...,
 | 
					    def create(customer: str=..., billing: str=..., days_until_due: Optional[int]=...,
 | 
				
			||||||
 | 
					               items: List[Dict[str, Any]]=...,
 | 
				
			||||||
               prorate: bool=..., tax_percent: float=...) -> Subscription:
 | 
					               prorate: bool=..., tax_percent: float=...) -> Subscription:
 | 
				
			||||||
        ...
 | 
					        ...
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -68,9 +79,15 @@ class Subscription:
 | 
				
			|||||||
    def save(subscription: Subscription, idempotency_key: str=...) -> Subscription:
 | 
					    def save(subscription: Subscription, idempotency_key: str=...) -> Subscription:
 | 
				
			||||||
        ...
 | 
					        ...
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Source:
 | 
				
			||||||
 | 
					    id: str
 | 
				
			||||||
 | 
					    object: str
 | 
				
			||||||
 | 
					    type: str
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Card:
 | 
					class Card:
 | 
				
			||||||
    id: str
 | 
					    id: str
 | 
				
			||||||
    last4: str
 | 
					    last4: str
 | 
				
			||||||
 | 
					    object: str
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Plan:
 | 
					class Plan:
 | 
				
			||||||
    id: str
 | 
					    id: str
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user