mirror of
https://github.com/zulip/zulip.git
synced 2025-11-06 15:03:34 +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