From f5cb2c6838743acddbb6f54a4c86c12866185073 Mon Sep 17 00:00:00 2001 From: mnida <33556500+mnida@users.noreply.github.com> Date: Sat, 1 Oct 2022 10:20:54 -0700 Subject: [PATCH] Update Customer Detail + Event Stream View (#173) * new plan + cancel subscription * edit plan scaffolding * added new customer summary endpoints * event preview redesign * fixes for customer summary series * changed customer with revenue view calculations * add number active subscriptions to plans * update customer with revenue view to pass floats * customers table fix * improve auth settings * codestyle and test fixes * update get access to support free qty checking * changed to total * lay framework for accepting payment providers apart from stripe Co-authored-by: Diego Escobedo --- Pipfile | 1 + Pipfile.lock | 179 ++++++++----- lotus/settings.py | 11 + lotus/urls.py | 16 +- metering_billing/auth_utils.py | 39 +++ metering_billing/invoice.py | 50 ++-- ...customer_billing_address_customer_email.py | 23 ++ .../migrations/0026_alter_user_email.py | 18 ++ ...nvoice_external_payment_obj_id_and_more.py | 54 ++++ metering_billing/models.py | 47 ++-- .../serializers/internal_serializers.py | 7 + .../serializers/model_serializers.py | 97 ++++++- metering_billing/tasks.py | 29 +- metering_billing/tests/test_customer.py | 1 + metering_billing/tests/test_draft_invoices.py | 6 +- metering_billing/tests/test_get_access.py | 87 +++--- metering_billing/tests/test_tasks.py | 9 +- metering_billing/utils.py | 5 +- metering_billing/views/model_views.py | 13 +- metering_billing/views/views.py | 142 +++++++--- src/App.css | 6 + src/App.tsx | 5 +- src/api/api.ts | 18 +- .../Customers/CreateCustomerForm.tsx | 1 + src/components/Customers/CustomerDetail.css | 4 + src/components/Customers/CustomerDetail.tsx | 112 ++++++-- .../Customers/CustomerSubscriptionView.tsx | 129 +++++++-- src/components/Customers/CustomerTable.tsx | 54 +++- src/components/EventPreview.tsx | 64 ++--- src/components/MetricTable.tsx | 8 +- src/components/PlanDisplayBasic.tsx | 97 ++++--- src/components/Settings.tsx | 11 +- src/components/SideBar.tsx | 4 +- src/config/Routes.tsx | 4 + src/index.css | 32 ++- src/pages/EditPlan.tsx | 250 ++++++++++++++++++ src/pages/ViewCustomers.tsx | 29 +- src/pages/ViewMetrics.css | 3 + src/pages/ViewMetrics.tsx | 7 +- src/pages/ViewPlans.tsx | 3 +- src/types/customer-type.ts | 34 ++- vite.config.ts | 8 +- 42 files changed, 1374 insertions(+), 343 deletions(-) create mode 100644 metering_billing/migrations/0025_customer_billing_address_customer_email.py create mode 100644 metering_billing/migrations/0026_alter_user_email.py create mode 100644 metering_billing/migrations/0027_rename_payment_intent_id_invoice_external_payment_obj_id_and_more.py create mode 100644 src/components/Customers/CustomerDetail.css create mode 100644 src/pages/EditPlan.tsx create mode 100644 src/pages/ViewMetrics.css diff --git a/Pipfile b/Pipfile index fe0db65cf..3871a5aba 100644 --- a/Pipfile +++ b/Pipfile @@ -38,6 +38,7 @@ posthog = "*" python-decouple = "*" pytest-cov = "*" celery = {extras = ["redis"], version = "*"} +social-auth-app-django = "*" [dev-packages] pylint = ">=2.14.5,<2.15" diff --git a/Pipfile.lock b/Pipfile.lock index 0e6484103..3ce8a0157 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "342ebb7441c8b8187ff1447415e4faf100f945e607e7e688353e8ce3bdddf7f2" + "sha256": "2be882b57b0da1c2f45fb22c8020e9888dcedca7173eba5d694b16de52bf86a7" }, "pipfile-spec": 6, "requires": { @@ -218,59 +218,59 @@ "toml" ], "hashes": [ - "sha256:01778769097dbd705a24e221f42be885c544bb91251747a8a3efdec6eb4788f2", - "sha256:08002f9251f51afdcc5e3adf5d5d66bb490ae893d9e21359b085f0e03390a820", - "sha256:1238b08f3576201ebf41f7c20bf59baa0d05da941b123c6656e42cdb668e9827", - "sha256:14a32ec68d721c3d714d9b105c7acf8e0f8a4f4734c811eda75ff3718570b5e3", - "sha256:15e38d853ee224e92ccc9a851457fb1e1f12d7a5df5ae44544ce7863691c7a0d", - "sha256:354df19fefd03b9a13132fa6643527ef7905712109d9c1c1903f2133d3a4e145", - "sha256:35ef1f8d8a7a275aa7410d2f2c60fa6443f4a64fae9be671ec0696a68525b875", - "sha256:4179502f210ebed3ccfe2f78bf8e2d59e50b297b598b100d6c6e3341053066a2", - "sha256:42c499c14efd858b98c4e03595bf914089b98400d30789511577aa44607a1b74", - "sha256:4b7101938584d67e6f45f0015b60e24a95bf8dea19836b1709a80342e01b472f", - "sha256:564cd0f5b5470094df06fab676c6d77547abfdcb09b6c29c8a97c41ad03b103c", - "sha256:5f444627b3664b80d078c05fe6a850dd711beeb90d26731f11d492dcbadb6973", - "sha256:6113e4df2fa73b80f77663445be6d567913fb3b82a86ceb64e44ae0e4b695de1", - "sha256:61b993f3998ee384935ee423c3d40894e93277f12482f6e777642a0141f55782", - "sha256:66e6df3ac4659a435677d8cd40e8eb1ac7219345d27c41145991ee9bf4b806a0", - "sha256:67f9346aeebea54e845d29b487eb38ec95f2ecf3558a3cffb26ee3f0dcc3e760", - "sha256:6913dddee2deff8ab2512639c5168c3e80b3ebb0f818fed22048ee46f735351a", - "sha256:6a864733b22d3081749450466ac80698fe39c91cb6849b2ef8752fd7482011f3", - "sha256:7026f5afe0d1a933685d8f2169d7c2d2e624f6255fb584ca99ccca8c0e966fd7", - "sha256:783bc7c4ee524039ca13b6d9b4186a67f8e63d91342c713e88c1865a38d0892a", - "sha256:7a98d6bf6d4ca5c07a600c7b4e0c5350cd483c85c736c522b786be90ea5bac4f", - "sha256:8d032bfc562a52318ae05047a6eb801ff31ccee172dc0d2504614e911d8fa83e", - "sha256:98c0b9e9b572893cdb0a00e66cf961a238f8d870d4e1dc8e679eb8bdc2eb1b86", - "sha256:9c7b9b498eb0c0d48b4c2abc0e10c2d78912203f972e0e63e3c9dc21f15abdaa", - "sha256:9cc4f107009bca5a81caef2fca843dbec4215c05e917a59dec0c8db5cff1d2aa", - "sha256:9d6e1f3185cbfd3d91ac77ea065d85d5215d3dfa45b191d14ddfcd952fa53796", - "sha256:a095aa0a996ea08b10580908e88fbaf81ecf798e923bbe64fb98d1807db3d68a", - "sha256:a3b2752de32c455f2521a51bd3ffb53c5b3ae92736afde67ce83477f5c1dd928", - "sha256:ab066f5ab67059d1f1000b5e1aa8bbd75b6ed1fc0014559aea41a9eb66fc2ce0", - "sha256:c1328d0c2f194ffda30a45f11058c02410e679456276bfa0bbe0b0ee87225fac", - "sha256:c35cca192ba700979d20ac43024a82b9b32a60da2f983bec6c0f5b84aead635c", - "sha256:cbbb0e4cd8ddcd5ef47641cfac97d8473ab6b132dd9a46bacb18872828031685", - "sha256:cdbb0d89923c80dbd435b9cf8bba0ff55585a3cdb28cbec65f376c041472c60d", - "sha256:cf2afe83a53f77aec067033199797832617890e15bed42f4a1a93ea24794ae3e", - "sha256:d5dd4b8e9cd0deb60e6fcc7b0647cbc1da6c33b9e786f9c79721fd303994832f", - "sha256:dfa0b97eb904255e2ab24166071b27408f1f69c8fbda58e9c0972804851e0558", - "sha256:e16c45b726acb780e1e6f88b286d3c10b3914ab03438f32117c4aa52d7f30d58", - "sha256:e1fabd473566fce2cf18ea41171d92814e4ef1495e04471786cbc943b89a3781", - "sha256:e3d3c4cc38b2882f9a15bafd30aec079582b819bec1b8afdbde8f7797008108a", - "sha256:e431e305a1f3126477abe9a184624a85308da8edf8486a863601d58419d26ffa", - "sha256:e7b4da9bafad21ea45a714d3ea6f3e1679099e420c8741c74905b92ee9bfa7cc", - "sha256:ee2b2fb6eb4ace35805f434e0f6409444e1466a47f620d1d5763a22600f0f892", - "sha256:ee6ae6bbcac0786807295e9687169fba80cb0617852b2fa118a99667e8e6815d", - "sha256:ef6f44409ab02e202b31a05dd6666797f9de2aa2b4b3534e9d450e42dea5e817", - "sha256:f67cf9f406cf0d2f08a3515ce2db5b82625a7257f88aad87904674def6ddaec1", - "sha256:f855b39e4f75abd0dfbcf74a82e84ae3fc260d523fcb3532786bcbbcb158322c", - "sha256:fc600f6ec19b273da1d85817eda339fb46ce9eef3e89f220055d8696e0a06908", - "sha256:fcbe3d9a53e013f8ab88734d7e517eb2cd06b7e689bedf22c0eb68db5e4a0a19", - "sha256:fde17bc42e0716c94bf19d92e4c9f5a00c5feb401f5bc01101fdf2a8b7cacf60", - "sha256:ff934ced84054b9018665ca3967fc48e1ac99e811f6cc99ea65978e1d384454b" + "sha256:027018943386e7b942fa832372ebc120155fd970837489896099f5cfa2890f79", + "sha256:11b990d520ea75e7ee8dcab5bc908072aaada194a794db9f6d7d5cfd19661e5a", + "sha256:12adf310e4aafddc58afdb04d686795f33f4d7a6fa67a7a9d4ce7d6ae24d949f", + "sha256:1431986dac3923c5945271f169f59c45b8802a114c8f548d611f2015133df77a", + "sha256:1ef221513e6f68b69ee9e159506d583d31aa3567e0ae84eaad9d6ec1107dddaa", + "sha256:20c8ac5386253717e5ccc827caad43ed66fea0efe255727b1053a8154d952398", + "sha256:2198ea6fc548de52adc826f62cb18554caedfb1d26548c1b7c88d8f7faa8f6ba", + "sha256:255758a1e3b61db372ec2736c8e2a1fdfaf563977eedbdf131de003ca5779b7d", + "sha256:265de0fa6778d07de30bcf4d9dc471c3dc4314a23a3c6603d356a3c9abc2dfcf", + "sha256:33a7da4376d5977fbf0a8ed91c4dffaaa8dbf0ddbf4c8eea500a2486d8bc4d7b", + "sha256:42eafe6778551cf006a7c43153af1211c3aaab658d4d66fa5fcc021613d02518", + "sha256:4433b90fae13f86fafff0b326453dd42fc9a639a0d9e4eec4d366436d1a41b6d", + "sha256:4a5375e28c5191ac38cca59b38edd33ef4cc914732c916f2929029b4bfb50795", + "sha256:4a8dbc1f0fbb2ae3de73eb0bdbb914180c7abfbf258e90b311dcd4f585d44bd2", + "sha256:59f53f1dc5b656cafb1badd0feb428c1e7bc19b867479ff72f7a9dd9b479f10e", + "sha256:5dbec3b9095749390c09ab7c89d314727f18800060d8d24e87f01fb9cfb40b32", + "sha256:633713d70ad6bfc49b34ead4060531658dc6dfc9b3eb7d8a716d5873377ab745", + "sha256:6b07130585d54fe8dff3d97b93b0e20290de974dc8177c320aeaf23459219c0b", + "sha256:6c4459b3de97b75e3bd6b7d4b7f0db13f17f504f3d13e2a7c623786289dd670e", + "sha256:6d4817234349a80dbf03640cec6109cd90cba068330703fa65ddf56b60223a6d", + "sha256:723e8130d4ecc8f56e9a611e73b31219595baa3bb252d539206f7bbbab6ffc1f", + "sha256:784f53ebc9f3fd0e2a3f6a78b2be1bd1f5575d7863e10c6e12504f240fd06660", + "sha256:7b6be138d61e458e18d8e6ddcddd36dd96215edfe5f1168de0b1b32635839b62", + "sha256:7ccf362abd726b0410bf8911c31fbf97f09f8f1061f8c1cf03dfc4b6372848f6", + "sha256:83516205e254a0cb77d2d7bb3632ee019d93d9f4005de31dca0a8c3667d5bc04", + "sha256:851cf4ff24062c6aec510a454b2584f6e998cada52d4cb58c5e233d07172e50c", + "sha256:8f830ed581b45b82451a40faabb89c84e1a998124ee4212d440e9c6cf70083e5", + "sha256:94e2565443291bd778421856bc975d351738963071e9b8839ca1fc08b42d4bef", + "sha256:95203854f974e07af96358c0b261f1048d8e1083f2de9b1c565e1be4a3a48cfc", + "sha256:97117225cdd992a9c2a5515db1f66b59db634f59d0679ca1fa3fe8da32749cae", + "sha256:98e8a10b7a314f454d9eff4216a9a94d143a7ee65018dd12442e898ee2310578", + "sha256:a1170fa54185845505fbfa672f1c1ab175446c887cce8212c44149581cf2d466", + "sha256:a6b7d95969b8845250586f269e81e5dfdd8ff828ddeb8567a4a2eaa7313460c4", + "sha256:a8fb6cf131ac4070c9c5a3e21de0f7dc5a0fbe8bc77c9456ced896c12fcdad91", + "sha256:af4fffaffc4067232253715065e30c5a7ec6faac36f8fc8d6f64263b15f74db0", + "sha256:b4a5be1748d538a710f87542f22c2cad22f80545a847ad91ce45e77417293eb4", + "sha256:b5604380f3415ba69de87a289a2b56687faa4fe04dbee0754bfcae433489316b", + "sha256:b9023e237f4c02ff739581ef35969c3739445fb059b060ca51771e69101efffe", + "sha256:bc8ef5e043a2af066fa8cbfc6e708d58017024dc4345a1f9757b329a249f041b", + "sha256:c4ed2820d919351f4167e52425e096af41bfabacb1857186c1ea32ff9983ed75", + "sha256:cca4435eebea7962a52bdb216dec27215d0df64cf27fc1dd538415f5d2b9da6b", + "sha256:d900bb429fdfd7f511f868cedd03a6bbb142f3f9118c09b99ef8dc9bf9643c3c", + "sha256:d9ecf0829c6a62b9b573c7bb6d4dcd6ba8b6f80be9ba4fc7ed50bf4ac9aecd72", + "sha256:dbdb91cd8c048c2b09eb17713b0c12a54fbd587d79adcebad543bc0cd9a3410b", + "sha256:de3001a203182842a4630e7b8d1a2c7c07ec1b45d3084a83d5d227a3806f530f", + "sha256:e07f4a4a9b41583d6eabec04f8b68076ab3cd44c20bd29332c6572dda36f372e", + "sha256:ef8674b0ee8cc11e2d574e3e2998aea5df5ab242e012286824ea3c6970580e53", + "sha256:f4f05d88d9a80ad3cac6244d36dd89a3c00abc16371769f1340101d3cb899fc3", + "sha256:f642e90754ee3e06b0e7e51bce3379590e76b7f76b708e1a71ff043f87025c84", + "sha256:fc2af30ed0d5ae0b1abdb4ebdce598eafd5b35397d4d75deb341a614d333d987" ], "markers": "python_version >= '3.7'", - "version": "==6.4.4" + "version": "==6.5.0" }, "cryptography": { "hashes": [ @@ -304,6 +304,14 @@ "markers": "python_version >= '3.6'", "version": "==38.0.1" }, + "defusedxml": { + "hashes": [ + "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", + "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==0.7.1" + }, "distlib": { "hashes": [ "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46", @@ -588,6 +596,14 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", "version": "==1.7.0" }, + "oauthlib": { + "hashes": [ + "sha256:1565237372795bf6ee3e5aba5e2a85bd5a65d0e2aa5c628b9a97b7d7a0da3721", + "sha256:88e912ca1ad915e1dcc1c06fc9259d19de8deacd6fd17cc2df266decc2e49066" + ], + "markers": "python_version >= '3.6'", + "version": "==3.2.1" + }, "packaging": { "hashes": [ "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", @@ -684,6 +700,14 @@ ], "version": "==2.21" }, + "pyjwt": { + "hashes": [ + "sha256:8d82e7087868e94dd8d7d418e5088ce64f7daab4b36db654cbaedb46f9d1ca80", + "sha256:e77ab89480905d86998442ac5788f35333fa85f65047a534adc38edf3c88fc3b" + ], + "markers": "python_version >= '3.7'", + "version": "==2.5.0" + }, "pyparsing": { "hashes": [ "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb", @@ -729,11 +753,11 @@ }, "pytest-cov": { "hashes": [ - "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6", - "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470" + "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b", + "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470" ], "index": "pypi", - "version": "==3.0.0" + "version": "==4.0.0" }, "python-crontab": { "hashes": [ @@ -765,6 +789,13 @@ "index": "pypi", "version": "==0.20.0" }, + "python3-openid": { + "hashes": [ + "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf", + "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b" + ], + "version": "==3.2.0" + }, "pytz": { "hashes": [ "sha256:220f481bdafa09c3955dfbdddb7b57780e9a94f5127e35456a48589b9e0c0197", @@ -834,6 +865,14 @@ "markers": "python_version >= '3.7' and python_version < '4.0'", "version": "==2.28.1" }, + "requests-oauthlib": { + "hashes": [ + "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5", + "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.3.1" + }, "sentry-sdk": { "hashes": [ "sha256:d6c71d2f85710b66822adaa954af7912bab135d6c85febd5b0f3dfd4ab37e181", @@ -844,11 +883,11 @@ }, "setuptools": { "hashes": [ - "sha256:a8f6e213b4b0661f590ccf40de95d28a177cd747d098624ad3f69c40287297e9", - "sha256:c2d2709550f15aab6c9110196ea312f468f41cd546bceb24127a1be6fdcaeeb1" + "sha256:1b6bdc6161661409c5f21508763dc63ab20a9ac2f8ba20029aaaa7fdb9118012", + "sha256:3050e338e5871e70c72983072fe34f6032ae1cdeeeb67338199c2f74e083a80e" ], "markers": "python_version >= '3.7'", - "version": "==65.4.0" + "version": "==65.4.1" }, "six": { "hashes": [ @@ -858,6 +897,22 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.16.0" }, + "social-auth-app-django": { + "hashes": [ + "sha256:52241a25445a010ab1c108bafff21fc5522d5c8cd0d48a92c39c7371824b065d", + "sha256:b6e3132ce087cdd6e1707aeb1b588be41d318408fcf6395435da0bc6fe9a9795" + ], + "index": "pypi", + "version": "==5.0.0" + }, + "social-auth-core": { + "hashes": [ + "sha256:1e3440d104f743b02dfe258c9d4dba5b4065abf24b2f7eb362b47054d21797df", + "sha256:4686f0e43cf12954216875a32e944847bb1dc69e7cd9573d16a9003bb05ca477" + ], + "markers": "python_version >= '3.6'", + "version": "==4.3.0" + }, "sqlparse": { "hashes": [ "sha256:0323c0ec29cd52bceabc1b4d9d579e311f3e4961b98d174201d5622a23b85e34", @@ -1818,11 +1873,11 @@ }, "setuptools": { "hashes": [ - "sha256:a8f6e213b4b0661f590ccf40de95d28a177cd747d098624ad3f69c40287297e9", - "sha256:c2d2709550f15aab6c9110196ea312f468f41cd546bceb24127a1be6fdcaeeb1" + "sha256:1b6bdc6161661409c5f21508763dc63ab20a9ac2f8ba20029aaaa7fdb9118012", + "sha256:3050e338e5871e70c72983072fe34f6032ae1cdeeeb67338199c2f74e083a80e" ], "markers": "python_version >= '3.7'", - "version": "==65.4.0" + "version": "==65.4.1" }, "six": { "hashes": [ @@ -1860,7 +1915,7 @@ "sha256:571854ebbb5eac89abcb4a2e47d7ea27b89bf29e09c35395da6f03dd4ae23d1c", "sha256:f2ef9da9cef846ee027947dc99a45d6b68a63b0ebc21944649505bf2e8bc5fe7" ], - "markers": "python_version >= '3.6' and python_version < '4'", + "markers": "python_version >= '3.6' and python_version < '4.0'", "version": "==0.11.5" }, "typing-extensions": { diff --git a/lotus/settings.py b/lotus/settings.py index d67b42436..95b38b4fc 100644 --- a/lotus/settings.py +++ b/lotus/settings.py @@ -13,6 +13,7 @@ import re import ssl from pathlib import Path +from telnetlib import AUTHENTICATION import dj_database_url import django_heroku @@ -91,6 +92,7 @@ "django.contrib.staticfiles", "rest_framework", "metering_billing", + "social_django", "djmoney", "django_extensions", "django_celery_beat", @@ -143,6 +145,12 @@ WSGI_APPLICATION = "lotus.wsgi.application" AUTH_USER_MODEL = "metering_billing.User" +AUTHENTICATION_BACKENDS = ["metering_billing.auth_utils.EmailOrUsernameModelBackend"] +SOCIAL_AUTH_JSONFIELD_ENABLED = True +SESSION_EXPIRE_AT_BROWSER_CLOSE = True +SESSION_COOKIE_AGE = 2 * 60 * 60 # set just 10 seconds to test +SESSION_SAVE_EVERY_REQUEST = True + # Database # https://docs.djangoproject.com/en/4.0/ref/settings/#databases @@ -300,6 +308,9 @@ "DEFAULT_PERMISSION_CLASSES": [ "metering_billing.permissions.HasUserAPIKey", ], + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + ], "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "COERCE_DECIMAL_TO_STRING": False, } diff --git a/lotus/urls.py b/lotus/urls.py index 0d128500e..e33547b7e 100644 --- a/lotus/urls.py +++ b/lotus/urls.py @@ -34,7 +34,9 @@ from metering_billing.views.views import ( APIKeyCreate, CancelSubscriptionView, - CustomerWithRevenueView, + CustomerDetailView, + CustomersSummaryView, + CustomersWithRevenueView, DraftInvoiceView, EventPreviewView, GetCustomerAccessView, @@ -63,9 +65,19 @@ path("api/track/", csrf_exempt(track.track_event), name="track_event"), path( "api/customer_summary/", - CustomerWithRevenueView.as_view(), + CustomersSummaryView.as_view(), name="customer_summary", ), + path( + "api/customer_detail/", + CustomerDetailView.as_view(), + name="customer_detail", + ), + path( + "api/customer_totals/", + CustomersWithRevenueView.as_view(), + name="customer_totals", + ), path( "api/period_metric_usage/", PeriodMetricUsageView.as_view(), diff --git a/metering_billing/auth_utils.py b/metering_billing/auth_utils.py index 80c5fc168..e8becfe39 100644 --- a/metering_billing/auth_utils.py +++ b/metering_billing/auth_utils.py @@ -1,5 +1,9 @@ from curses import keyname +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend +from django.db.models import Q + from metering_billing.exceptions import ( NoMatchingAPIKey, OrganizationMismatch, @@ -9,6 +13,41 @@ from metering_billing.permissions import HasUserAPIKey +class EmailOrUsernameModelBackend(ModelBackend): + """ + Authentication backend which allows users to authenticate using either their + username or email address + + Source: https://stackoverflow.com/a/35836674/59984 + """ + + def authenticate(self, request, username=None, password=None, **kwargs): + # n.b. Django <2.1 does not pass the `request` + + user_model = get_user_model() + + if username is None: + username = kwargs.get(user_model.USERNAME_FIELD) + + # The `username` field is allows to contain `@` characters so + # technically a given email address could be present in either field, + # possibly even for different users, so we'll query for all matching + # records and test each one. + users = user_model._default_manager.filter( + Q(**{user_model.USERNAME_FIELD: username}) | Q(email__iexact=username) + ) + + # Test whether any matched user has the provided password: + for user in users: + if user.check_password(password): + return user + if not users: + # Run the default password hasher once to reduce the timing + # difference between an existing and a non-existing user (see + # https://code.djangoproject.com/ticket/20760) + user_model().set_password(password) + + # AUTH METHODS def get_organization_from_key(key): try: diff --git a/metering_billing/invoice.py b/metering_billing/invoice.py index f9a44fe66..2af23b475 100644 --- a/metering_billing/invoice.py +++ b/metering_billing/invoice.py @@ -11,6 +11,7 @@ Organization, Subscription, ) +from metering_billing.serializers.model_serializers import InvoiceSerializer from metering_billing.utils import ( calculate_sub_pc_usage_revenue, make_all_decimals_floats, @@ -59,7 +60,7 @@ class InvoiceSubscriptionSerializer(serializers.ModelSerializer): class Meta: model = Subscription - fields = ("start_date", "end_date", "billing_plan") + fields = ("start_date", "end_date", "billing_plan", "subscription_uid") def generate_invoice(subscription, draft=False, issue_date=None): @@ -97,13 +98,23 @@ def generate_invoice(subscription, draft=False, issue_date=None): amount_cents = int( amount.quantize(Decimal(".01"), rounding=ROUND_DOWN) * Decimal(100) ) + + customer_connected_to_pp = customer.payment_provider_id != "" + org_pp_id = None + if customer_connected_to_pp: + cust_pp_type = customer.payment_provider + org_pps = organization.payment_provider_ids + if cust_pp_type in org_pps: + org_pp_id = org_pps[cust_pp_type] + + status = "unpaid" if draft: status = "draft" - payment_intent_id = None - elif organization.stripe_id is not None or ( + external_payment_obj_id = None + elif (customer_connected_to_pp and org_pp_id) or ( SELF_HOSTED and STRIPE_SECRET_KEY != "" ): - if customer.payment_provider_id is not None: + if customer.payment_provider == "stripe": payment_intent_kwargs = { "amount": amount_cents, "currency": billing_plan.currency, @@ -112,16 +123,14 @@ def generate_invoice(subscription, draft=False, issue_date=None): "description": f"Invoice for {organization.company_name}", } if not SELF_HOSTED: - payment_intent_kwargs["stripe_account"] = organization.stripe_id + payment_intent_kwargs["stripe_account"] = org_pp_id payment_intent = stripe.PaymentIntent.create(**payment_intent_kwargs) - status = payment_intent.status - payment_intent_id = payment_intent.id + external_payment_obj_id = payment_intent.id + # can be extensible by adding an elif depending on payment provider workflow else: - status = "customer_not_connected_to_stripe" - payment_intent_id = None + external_payment_obj_id = None else: - status = "organization_not_connected_to_stripe" - payment_intent_id = None + external_payment_obj_id = None # Create the invoice org_serializer = InvoiceOrganizationSerializer(organization) @@ -132,27 +141,18 @@ def generate_invoice(subscription, draft=False, issue_date=None): invoice = Invoice.objects.create( cost_due=amount_cents / 100, issue_date=issue_date, + org_connected_to_cust_payment_provider=org_pp_id != None, + cust_connected_to_payment_provider=customer_connected_to_pp, organization=org_serializer.data, customer=customer_serializer.data, subscription=subscription_serializer.data, - status=status, - payment_intent_id=payment_intent_id, + payment_status=status, + external_payment_obj_id=external_payment_obj_id, line_items=usage_dict, ) if not draft: - invoice_data = { - invoice: { - "cost_due": amount_cents / 100, - "issue_date": issue_date, - "organization": org_serializer.data, - "customer": customer_serializer.data, - "subscription": subscription_serializer.data, - "status": status, - "payment_intent_id": payment_intent_id, - "line_items": usage_dict, - } - } + invoice_data = InvoiceSerializer(invoice).data invoice_created_webhook(invoice_data, organization) return invoice diff --git a/metering_billing/migrations/0025_customer_billing_address_customer_email.py b/metering_billing/migrations/0025_customer_billing_address_customer_email.py new file mode 100644 index 000000000..3252e73bf --- /dev/null +++ b/metering_billing/migrations/0025_customer_billing_address_customer_email.py @@ -0,0 +1,23 @@ +# Generated by Django 4.0.5 on 2022-09-30 19:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("metering_billing", "0024_alter_billingplan_components_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="customer", + name="billing_address", + field=models.CharField(blank=True, max_length=500, null=True), + ), + migrations.AddField( + model_name="customer", + name="email", + field=models.EmailField(blank=True, max_length=100, null=True), + ), + ] diff --git a/metering_billing/migrations/0026_alter_user_email.py b/metering_billing/migrations/0026_alter_user_email.py new file mode 100644 index 000000000..0cb765641 --- /dev/null +++ b/metering_billing/migrations/0026_alter_user_email.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.5 on 2022-10-01 01:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("metering_billing", "0025_customer_billing_address_customer_email"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="email", + field=models.EmailField(max_length=254, unique=True), + ), + ] diff --git a/metering_billing/migrations/0027_rename_payment_intent_id_invoice_external_payment_obj_id_and_more.py b/metering_billing/migrations/0027_rename_payment_intent_id_invoice_external_payment_obj_id_and_more.py new file mode 100644 index 000000000..a157bdeae --- /dev/null +++ b/metering_billing/migrations/0027_rename_payment_intent_id_invoice_external_payment_obj_id_and_more.py @@ -0,0 +1,54 @@ +# Generated by Django 4.0.5 on 2022-10-01 16:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("metering_billing", "0026_alter_user_email"), + ] + + operations = [ + migrations.RenameField( + model_name="invoice", + old_name="payment_intent_id", + new_name="external_payment_obj_id", + ), + migrations.RemoveField( + model_name="invoice", + name="status", + ), + migrations.AddField( + model_name="customer", + name="payment_provider", + field=models.CharField( + blank=True, choices=[("stripe", "Stripe")], max_length=100, null=True + ), + ), + migrations.AddField( + model_name="invoice", + name="cust_connected_to_payment_provider", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="invoice", + name="org_connected_to_cust_payment_provider", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="invoice", + name="payment_status", + field=models.CharField( + choices=[("draft", "Draft"), ("paid", "Paid"), ("unpaid", "Unpaid")], + default="unpaid", + max_length=40, + ), + preserve_default=False, + ), + migrations.AddField( + model_name="organization", + name="payment_provider_ids", + field=models.JSONField(blank=True, default=dict, null=True), + ), + ] diff --git a/metering_billing/models.py b/metering_billing/models.py index d1e7a0b36..2889ef563 100644 --- a/metering_billing/models.py +++ b/metering_billing/models.py @@ -1,3 +1,4 @@ +import email import uuid from dateutil.parser import isoparse @@ -30,6 +31,10 @@ class AGGREGATION_TYPES(object): (AGGREGATION_TYPES.LAST, _("Last")), ) +SUPPORTED_PAYMENT_PROVIDERS = Choices( + ("stripe", _("Stripe")), +) + class EVENT_TYPES(object): AGGREGATION = "aggregation" @@ -74,6 +79,7 @@ class Organization(models.Model): ) company_name = models.CharField(max_length=100, default=" ") stripe_id = models.CharField(max_length=110, blank=True, null=True) + payment_provider_ids = models.JSONField(default=dict, blank=True, null=True) created = models.DateField(auto_now=True) payment_plan = models.CharField( max_length=40, choices=PAYMENT_PLANS, default=PAYMENT_PLANS.self_hosted_free @@ -94,6 +100,7 @@ class User(AbstractUser): organization = models.ForeignKey( Organization, on_delete=models.CASCADE, null=True, blank=True ) + email = models.EmailField(unique=True) class Customer(models.Model): @@ -113,13 +120,21 @@ class Customer(models.Model): organization = models.ForeignKey(Organization, on_delete=models.CASCADE, null=False) name = models.CharField(max_length=100) + email = models.EmailField(max_length=100, blank=True, null=True) customer_id = models.CharField(max_length=40) currency = models.CharField(max_length=3, default="USD") + payment_provider = models.CharField( + max_length=100, + blank=True, + null=True, + choices=SUPPORTED_PAYMENT_PROVIDERS, + ) payment_provider_id = models.CharField(max_length=50, null=True, blank=True) properties = models.JSONField(default=dict, blank=True, null=True) balance = MoneyField( decimal_places=10, max_digits=20, default_currency="USD", default=0.0 ) + billing_address = models.CharField(max_length=500, blank=True, null=True) def __str__(self) -> str: return str(self.name) + " " + str(self.customer_id) @@ -386,28 +401,18 @@ class Meta: class Invoice(models.Model): class INVOICE_STATUS_TYPES(object): - ORG_NOT_CONNECTED_TO_STRIPE = "organization_not_connected_to_stripe" - CUST_NOT_CONNECTED_TO_STRIPE = "customer_not_connected_to_stripe" - REQUIRES_PAYMENT_METHOD = "requires_payment_method" - REQUIRES_ACTION = "requires_action" - PROCESSING = "processing" - SUCCEEDED = "succeeded" + # REQUIRES_PAYMENT_METHOD = "requires_payment_method" + # REQUIRES_ACTION = "requires_action" + # PROCESSING = "processing" + # SUCCEEDED = "succeeded" DRAFT = "draft" + PAID = "paid" + UNPAID = "unpaid" INVOICE_STATUS_CHOICES = Choices( - (INVOICE_STATUS_TYPES.REQUIRES_PAYMENT_METHOD, _("Requires Payment Method")), - (INVOICE_STATUS_TYPES.REQUIRES_ACTION, _("Requires Action")), - (INVOICE_STATUS_TYPES.PROCESSING, _("Processing")), - (INVOICE_STATUS_TYPES.SUCCEEDED, _("Succeeded")), (INVOICE_STATUS_TYPES.DRAFT, _("Draft")), - ( - INVOICE_STATUS_TYPES.ORG_NOT_CONNECTED_TO_STRIPE, - _("Organization Not Connected to Stripe"), - ), - ( - INVOICE_STATUS_TYPES.CUST_NOT_CONNECTED_TO_STRIPE, - _("Customer Not Connected to Stripe"), - ), + (INVOICE_STATUS_TYPES.PAID, _("Paid")), + (INVOICE_STATUS_TYPES.UNPAID, _("Unpaid")), ) cost_due = MoneyField( @@ -415,8 +420,10 @@ class INVOICE_STATUS_TYPES(object): ) issue_date = models.DateTimeField(max_length=100, auto_now=True) invoice_pdf = models.FileField(upload_to="invoices/", null=True, blank=True) - status = models.CharField(max_length=40, choices=INVOICE_STATUS_CHOICES) - payment_intent_id = models.CharField(max_length=240, null=True, blank=True) + org_connected_to_cust_payment_provider = models.BooleanField(default=False) + cust_connected_to_payment_provider = models.BooleanField(default=False) + payment_status = models.CharField(max_length=40, choices=INVOICE_STATUS_CHOICES) + external_payment_obj_id = models.CharField(max_length=240, null=True, blank=True) line_items = models.JSONField() organization = models.JSONField() customer = models.JSONField() diff --git a/metering_billing/serializers/internal_serializers.py b/metering_billing/serializers/internal_serializers.py index 17845ed99..77b77a5f1 100644 --- a/metering_billing/serializers/internal_serializers.py +++ b/metering_billing/serializers/internal_serializers.py @@ -10,6 +10,9 @@ class GetCustomerAccessRequestSerializer(serializers.Serializer): customer_id = serializers.CharField(required=True) event_name = serializers.CharField(required=False) feature_name = serializers.CharField(required=False) + event_limit_type = serializers.ChoiceField( + choices=["free", "total"], required=False + ) def validate(self, data): if not data.get("event_name") and not data.get("feature_name"): @@ -20,6 +23,10 @@ def validate(self, data): raise serializers.ValidationError( "Cannot provide both event_name and feature_name" ) + if data.get("event_name") and not data.get("event_limit_type"): + raise serializers.ValidationError( + "Must provide event_limit_type when providing event_name" + ) return data diff --git a/metering_billing/serializers/model_serializers.py b/metering_billing/serializers/model_serializers.py index 4977d93ed..14bb55f30 100644 --- a/metering_billing/serializers/model_serializers.py +++ b/metering_billing/serializers/model_serializers.py @@ -39,7 +39,7 @@ class Meta: "id", "company_name", "payment_plan", - "stripe_id", + "payment_provider_ids", ) @@ -80,6 +80,95 @@ class Meta: ## CUSTOMER +class FilterActiveSubscriptionSerializer(serializers.ListSerializer): + def to_representation(self, data): + data = data.filter(status="active") + return super(FilterActiveSubscriptionSerializer, self).to_representation(data) + + +class SubscriptionCustomerSummarySerializer(serializers.ModelSerializer): + class Meta: + model = Subscription + fields = ("billing_plan_name", "end_date", "auto_renew") + list_serializer_class = FilterActiveSubscriptionSerializer + + billing_plan_name = serializers.CharField(source="billing_plan.name") + + +class CustomerSummarySerializer(serializers.ModelSerializer): + class Meta: + model = Customer + fields = ( + "customer_name", + "customer_id", + "subscriptions", + ) + + subscriptions = SubscriptionCustomerSummarySerializer( + read_only=True, many=True, source="subscription_set" + ) + customer_name = serializers.CharField(source="name") + + +class SubscriptionCustomerDetailSerializer(serializers.ModelSerializer): + class Meta: + model = Subscription + fields = ( + "billing_plan_name", + "subscription_uid", + "start_date", + "end_date", + "auto_renew", + "status", + ) + list_serializer_class = FilterActiveSubscriptionSerializer + + billing_plan_name = serializers.CharField(source="billing_plan.name") + + +class CustomerDetailSerializer(serializers.ModelSerializer): + class Meta: + model = Customer + fields = ( + "customer_id", + "email", + "balance", + "billing_address", + "customer_name", + "invoices", + "total_revenue_due", + "subscriptions", + ) + + customer_name = serializers.CharField(source="name") + subscriptions = SubscriptionCustomerDetailSerializer( + read_only=True, many=True, source="subscription_set" + ) + invoices = serializers.SerializerMethodField() + total_revenue_due = serializers.SerializerMethodField() + + def get_invoices(self, obj): + timeline = self.context.get("invoices") + timeline = InvoiceSerializer(timeline, many=True).data + return timeline + + def get_total_revenue_due(self, obj): + total_revenue_due = float(self.context.get("total_revenue_due")) + return total_revenue_due + + +class CustomerWithRevenueSerializer(serializers.ModelSerializer): + class Meta: + model = Customer + fields = ("customer_id", "total_revenue_due") + + total_revenue_due = serializers.SerializerMethodField() + + def get_total_revenue_due(self, obj): + total_revenue_due = float(self.context.get("total_revenue_due")) + return total_revenue_due + + class CustomerSerializer(serializers.ModelSerializer): class Meta: model = Customer @@ -246,10 +335,14 @@ def update(self, instance, validated_data): class BillingPlanReadSerializer(BillingPlanSerializer): class Meta(BillingPlanSerializer.Meta): - fields = BillingPlanSerializer.Meta.fields + ("time_created",) + fields = BillingPlanSerializer.Meta.fields + ( + "time_created", + "active_subscriptions", + ) components = PlanComponentReadSerializer(many=True) time_created = serializers.SerializerMethodField() + active_subscriptions = serializers.IntegerField() def get_time_created(self, obj) -> datetime.date: return str(obj.time_created.date()) diff --git a/metering_billing/tasks.py b/metering_billing/tasks.py index 22d38c304..b5579e35a 100644 --- a/metering_billing/tasks.py +++ b/metering_billing/tasks.py @@ -29,6 +29,18 @@ def calculate_invoice(): ending_subscriptions = list( Subscription.objects.filter(status="active", end_date__lt=now) ) + invoice_sub_uids_seen = Invoice.objects.values_list( + "subscription__subscription_uid", flat=True + ) + ended_subs_no_invoice = Subscription.objects.filter( + status="ended", end_date__lt=now + ) + if len(invoice_sub_uids_seen) > 0: + ended_subs_no_invoice = ended_subs_no_invoice.exclude( + subscription_uid__in=invoice_sub_uids_seen + ) + ending_subscriptions.extend(ended_subs_no_invoice) + # prefetch organization customer stripe keys orgs_seen = set() for sub in ending_subscriptions: @@ -48,12 +60,13 @@ def calculate_invoice(): ) continue # End the old subscription and delete draft invoices + already_ended = old_subscription.status == "ended" old_subscription.status = "ended" old_subscription.save() now = datetime.datetime.now(timezone.utc).date() - Invoice.objects.filter(issue_date__lt=now, status="draft").delete() + Invoice.objects.filter(issue_date__lt=now, payment_status="draft").delete() # Renew the subscription - if old_subscription.auto_renew: + if old_subscription.auto_renew and not already_ended: subscription_kwargs = { "organization": old_subscription.organization, "customer": old_subscription.customer, @@ -63,7 +76,7 @@ def calculate_invoice(): "is_new": False, } sub = Subscription.objects.create(**subscription_kwargs) - if sub.end_date >= now and sub.start_date <= now: + if sub.start_date <= now <= sub.end_date: sub.status = "active" else: sub.status = "ended" @@ -83,11 +96,9 @@ def start_subscriptions(): @shared_task def update_invoice_status(): - incomplete_invoices = Invoice.objects.filter( - ~Q(status="succeeded") & ~Q(status="draft") - ) + incomplete_invoices = Invoice.objects.filter(Q(payment_status="unpaid")) for incomplete_invoice in incomplete_invoices: - pi_id = incomplete_invoice.payment_intent_id + pi_id = incomplete_invoice.external_payment_obj_id if pi_id is not None: try: pi = stripe.PaymentIntent.retrieve(pi_id) @@ -95,8 +106,8 @@ def update_invoice_status(): print(e) print("Error retrieving payment intent {}".format(pi_id)) continue - if pi.status != incomplete_invoice.status: - incomplete_invoice.status = pi.status + if pi.status == "succeeded": + incomplete_invoice.payment_status = "paid" incomplete_invoice.save() diff --git a/metering_billing/tests/test_customer.py b/metering_billing/tests/test_customer.py index 968c66c72..be1d0c45a 100644 --- a/metering_billing/tests/test_customer.py +++ b/metering_billing/tests/test_customer.py @@ -124,6 +124,7 @@ def insert_customer_payload(): "balance": 30, "currency": "USD", "payment_provider_id": "test_payment_provider_id", + "payment_provider": "stripe", "properties": {}, } return payload diff --git a/metering_billing/tests/test_draft_invoices.py b/metering_billing/tests/test_draft_invoices.py index a5dbf7909..2522e3b5e 100644 --- a/metering_billing/tests/test_draft_invoices.py +++ b/metering_billing/tests/test_draft_invoices.py @@ -112,7 +112,7 @@ def do_draft_invoice_test_common_setup(*, auth_method): @pytest.mark.django_db(transaction=True) class TestGenerateInvoice: def test_generate_invoice(self, draft_invoice_test_common_setup): - setup_dict = draft_invoice_test_common_setup(auth_method="api_key") + setup_dict = draft_invoice_test_common_setup(auth_method="session_auth") active_subscriptions = Subscription.objects.filter( status="active", @@ -121,7 +121,7 @@ def test_generate_invoice(self, draft_invoice_test_common_setup): ) assert len(active_subscriptions) == 1 - prev_invoices_len = Invoice.objects.filter(status="draft").count() + prev_invoices_len = Invoice.objects.filter(payment_status="draft").count() payload = {"customer_id": setup_dict["customer"].customer_id} response = setup_dict["client"].get(reverse("draft_invoice"), payload) @@ -133,6 +133,6 @@ def test_generate_invoice(self, draft_invoice_test_common_setup): customer=setup_dict["customer"], ) assert len(after_active_subscriptions) == len(active_subscriptions) - new_invoices_len = Invoice.objects.filter(status="draft").count() + new_invoices_len = Invoice.objects.filter(payment_status="draft").count() assert new_invoices_len == prev_invoices_len + 1 diff --git a/metering_billing/tests/test_get_access.py b/metering_billing/tests/test_get_access.py index 0d04d3d03..f19a48de0 100644 --- a/metering_billing/tests/test_get_access.py +++ b/metering_billing/tests/test_get_access.py @@ -53,39 +53,32 @@ def do_get_access_test_common_setup(*, auth_method): setup_dict["client"] = client (customer,) = add_customers_to_org(org, n=1) setup_dict["customer"] = customer - event_properties = ( - {"num_characters": 350, "peak_bandwith": 65}, - {"num_characters": 125, "peak_bandwith": 148}, - {"num_characters": 543, "peak_bandwith": 16}, - ) event_set = baker.make( Event, organization=org, customer=customer, event_name="email_sent", time_created=datetime.datetime.now() - relativedelta(days=1), - properties=itertools.cycle(event_properties), - _quantity=3, + _quantity=5, ) - deny_metric_set = baker.make( + deny_limit_metric_set = baker.make( BillableMetric, organization=org, event_name="email_sent", - property_name=itertools.cycle(["num_characters", "peak_bandwith", ""]), - aggregation_type=itertools.cycle(["sum", "max", "count"]), - _quantity=3, + property_name=itertools.cycle([""]), + aggregation_type=itertools.cycle(["count"]), + _quantity=1, ) - setup_dict["deny_metrics"] = deny_metric_set + setup_dict["deny_limit_metrics"] = deny_limit_metric_set event_set = baker.make( Event, organization=org, customer=customer, event_name="api_call", time_created=datetime.datetime.now() - relativedelta(days=1), - properties=itertools.cycle(event_properties), _quantity=5, ) - allow_metric_set = baker.make( + allow_limit_metric_set = baker.make( BillableMetric, organization=org, event_name="api_call", @@ -93,7 +86,16 @@ def do_get_access_test_common_setup(*, auth_method): aggregation_type=itertools.cycle(["count"]), _quantity=1, ) - setup_dict["allow_metrics"] = allow_metric_set + setup_dict["allow_limit_metrics"] = allow_limit_metric_set + allow_free_metric_set = baker.make( + BillableMetric, + organization=org, + event_name="bogus_event", + property_name=itertools.cycle([""]), + aggregation_type=itertools.cycle(["count"]), + _quantity=1, + ) + setup_dict["allow_free_metrics"] = allow_free_metric_set billing_plan = baker.make( BillingPlan, organization=org, @@ -105,24 +107,26 @@ def do_get_access_test_common_setup(*, auth_method): ) plan_component_set = baker.make( PlanComponent, # sum char (over), max bw (ok), count (ok) - billable_metric=itertools.cycle(deny_metric_set + allow_metric_set), - free_metric_units=itertools.cycle([50, 0, 1, 5]), - cost_per_batch=itertools.cycle([5, 0.05, 50]), - metric_units_per_batch=itertools.cycle([100, 1, 1, 1]), - max_metric_units=itertools.cycle([500, 250, 10, 25]), - _quantity=4, + billable_metric=itertools.cycle( + deny_limit_metric_set + allow_limit_metric_set + allow_free_metric_set + ), + free_metric_units=itertools.cycle([1, 1, 20]), + cost_per_batch=itertools.cycle([10, 10, 10]), + metric_units_per_batch=itertools.cycle([1, 1, 1]), + max_metric_units=itertools.cycle([3, 6, 20]), + _quantity=3, ) feature_set = baker.make( Feature, organization=org, - feature_name=itertools.cycle(["feature1", "feature2", "feature3"]), - _quantity=3, + feature_name=itertools.cycle(["feature1", "feature2"]), + _quantity=2, ) setup_dict["plan_components"] = plan_component_set billing_plan.components.add(*plan_component_set) billing_plan.save() setup_dict["features"] = feature_set - billing_plan.features.add(*feature_set[:2]) + billing_plan.features.add(*feature_set[:1]) billing_plan.save() setup_dict["billing_plan"] = billing_plan subscription = baker.make( @@ -141,45 +145,62 @@ def do_get_access_test_common_setup(*, auth_method): @pytest.mark.django_db(transaction=True) class TestGetAccess: - def test_get_access_bm_allow(self, get_access_test_common_setup): + def test_get_access_limit_bm_allow(self, get_access_test_common_setup): setup_dict = get_access_test_common_setup(auth_method="api_key") payload = { "customer_id": setup_dict["customer"].customer_id, - "event_name": setup_dict["allow_metrics"][0].event_name, + "event_name": setup_dict["allow_limit_metrics"][0].event_name, + "event_limit_type": "total", } response = setup_dict["client"].get(reverse("customer_access"), payload) assert response.status_code == status.HTTP_200_OK assert response.json()["access"] == True - def test_get_access_bm_deny(self, get_access_test_common_setup): + def test_get_access_limit_bm_deny(self, get_access_test_common_setup): setup_dict = get_access_test_common_setup(auth_method="api_key") payload = { "customer_id": setup_dict["customer"].customer_id, - "event_name": setup_dict["deny_metrics"][0].event_name, + "event_name": setup_dict["deny_limit_metrics"][0].event_name, + "event_limit_type": "total", } response = setup_dict["client"].get(reverse("customer_access"), payload) - assert response.status_code == status.HTTP_200_OK assert response.json()["access"] == False - def test_get_access_feature_allow(self, get_access_test_common_setup): + def test_get_access_free_bm_allow(self, get_access_test_common_setup): setup_dict = get_access_test_common_setup(auth_method="api_key") payload = { "customer_id": setup_dict["customer"].customer_id, - "feature_name": setup_dict["features"][0].feature_name, + "event_name": setup_dict["allow_free_metrics"][0].event_name, + "event_limit_type": "free", } response = setup_dict["client"].get(reverse("customer_access"), payload) assert response.status_code == status.HTTP_200_OK assert response.json()["access"] == True + def test_get_access_free_bm_deny(self, get_access_test_common_setup): + setup_dict = get_access_test_common_setup(auth_method="api_key") + payload = { "customer_id": setup_dict["customer"].customer_id, - "feature_name": setup_dict["features"][1].feature_name, + "event_name": setup_dict["allow_limit_metrics"][0].event_name, + "event_limit_type": "free", + } + response = setup_dict["client"].get(reverse("customer_access"), payload) + assert response.status_code == status.HTTP_200_OK + assert response.json()["access"] == False + + def test_get_access_feature_allow(self, get_access_test_common_setup): + setup_dict = get_access_test_common_setup(auth_method="api_key") + + payload = { + "customer_id": setup_dict["customer"].customer_id, + "feature_name": setup_dict["features"][0].feature_name, } response = setup_dict["client"].get(reverse("customer_access"), payload) @@ -191,7 +212,7 @@ def test_get_access_feature_deny(self, get_access_test_common_setup): payload = { "customer_id": setup_dict["customer"].customer_id, - "feature_name": setup_dict["features"][2].feature_name, + "feature_name": setup_dict["features"][1].feature_name, } response = setup_dict["client"].get(reverse("customer_access"), payload) diff --git a/metering_billing/tests/test_tasks.py b/metering_billing/tests/test_tasks.py index eab2ca084..b98a4f5b0 100644 --- a/metering_billing/tests/test_tasks.py +++ b/metering_billing/tests/test_tasks.py @@ -38,6 +38,7 @@ def do_task_test_common_setup(): invoice_settings={"default_payment_method": "pm_card_visa"}, ) customer.payment_provider_id = stripe_cust.id + customer.payment_provider = "stripe" customer.save() setup_dict["customer"] = customer event_properties = ( @@ -142,13 +143,13 @@ def test_update_invoice_status(self, task_test_common_setup): invoice = baker.make( Invoice, issue_date=setup_dict["subscription"].end_date, - status="requires_payment_method", - payment_intent_id=payment_intent.id, + payment_status="unpaid", + external_payment_obj_id=payment_intent.id, ) - assert invoice.status != "succeeded" + assert invoice.payment_status != "paid" update_invoice_status() invoice = Invoice.objects.filter(id=invoice.id).first() - assert invoice.status == "succeeded" + assert invoice.payment_status == "paid" diff --git a/metering_billing/utils.py b/metering_billing/utils.py index b53b3379c..019443578 100644 --- a/metering_billing/utils.py +++ b/metering_billing/utils.py @@ -431,7 +431,7 @@ def get_customer_usage_and_revenue(customer): def make_all_decimals_floats(json): - if type(json) in [dict, list, Decimal, collections.OrderedDict]: + if type(json) in [dict, collections.OrderedDict]: for key, value in json.items(): if isinstance(value, dict) or isinstance(value, collections.OrderedDict): make_all_decimals_floats(value) @@ -440,6 +440,9 @@ def make_all_decimals_floats(json): make_all_decimals_floats(item) elif isinstance(value, Decimal): json[key] = float(value) + if isinstance(json, list): + for item in json: + make_all_decimals_floats(item) def make_all_dates_times_strings(json): diff --git a/metering_billing/views/model_views.py b/metering_billing/views/model_views.py index c53e23ce6..540fbd063 100644 --- a/metering_billing/views/model_views.py +++ b/metering_billing/views/model_views.py @@ -2,6 +2,7 @@ import posthog from django.db import IntegrityError +from django.db.models import Count, Q from metering_billing.exceptions import DuplicateCustomerID from metering_billing.models import ( Alert, @@ -208,8 +209,16 @@ def get_serializer_class(self): def get_queryset(self): organization = parse_organization(self.request) - return BillingPlan.objects.filter(organization=organization).prefetch_related( - "components" + return ( + BillingPlan.objects.filter(organization=organization) + .prefetch_related( + "components", + ) + .annotate( + active_subscriptions=Count( + "subscription__pk", filter=Q(subscription__status="active") + ) + ) ) def perform_create(self, serializer): diff --git a/metering_billing/views/views.py b/metering_billing/views/views.py index 116aedece..000035c0a 100644 --- a/metering_billing/views/views.py +++ b/metering_billing/views/views.py @@ -4,7 +4,7 @@ import posthog import stripe from django.core.paginator import Paginator -from django.db.models import Q +from django.db.models import Prefetch, Q from django.http import JsonResponse from drf_spectacular.utils import extend_schema, inline_serializer from lotus.settings import SELF_HOSTED, STRIPE_SECRET_KEY @@ -36,16 +36,18 @@ def import_stripe_customers(organization): If customer exists in Stripe and also exists in Lotus (compared by matching names), then update the customer's payment provider ID from Stripe. """ num_cust_added = 0 - if organization.stripe_id or (SELF_HOSTED and STRIPE_SECRET_KEY != ""): + org_ppis = organization.payment_provider_ids + if "stripe" in org_ppis or (SELF_HOSTED and STRIPE_SECRET_KEY != ""): stripe_cust_kwargs = {} - if organization.stripe_id: - stripe_cust_kwargs["stripe_account"] = organization.stripe_id + if org_ppis.get("stripe") != "": + stripe_cust_kwargs["stripe_account"] = org_ppis.get("stripe") stripe_customers_response = stripe.Customer.list(**stripe_cust_kwargs) for stripe_customer in stripe_customers_response.auto_paging_iter(): try: customer = Customer.objects.get( organization=organization, name=stripe_customer.name ) + customer.payment_provider = "stripe" customer.payment_provider_id = stripe_customer.id customer.save() num_cust_added += 1 @@ -71,8 +73,8 @@ def get(self, request, format=None): """ organization = parse_organization(request) - - stripe_id = organization.stripe_id + org_ppis = organization.payment_provider_ids + stripe_id = org_ppis.get("stripe") if (stripe_id and len(stripe_id) > 0) or ( SELF_HOSTED and STRIPE_SECRET_KEY != "" @@ -132,7 +134,8 @@ def post(self, request, format=None): connected_account_id = response["stripe_user_id"] - organization.stripe_id = connected_account_id + organization.payment_provider_ids.stripe = connected_account_id + organization.save() n_cust_added = import_stripe_customers(organization) @@ -150,7 +153,7 @@ def post(self, request, format=None): class PeriodMetricRevenueView(APIView): - permission_classes = [IsAuthenticated | HasUserAPIKey] + permission_classes = [IsAuthenticated] @extend_schema( parameters=[PeriodComparisonRequestSerializer], @@ -255,7 +258,7 @@ def get(self, request, format=None): class PeriodSubscriptionsView(APIView): - permission_classes = [IsAuthenticated | HasUserAPIKey] + permission_classes = [IsAuthenticated] @extend_schema( parameters=[PeriodComparisonRequestSerializer], @@ -422,12 +425,85 @@ def get(self, request, format=None): ) -class CustomerWithRevenueView(APIView): +class CustomersSummaryView(APIView): + permission_classes = [IsAuthenticated] + @extend_schema( + responses={200: CustomerSummarySerializer}, + ) + def get(self, request, format=None): + """ + Get the current settings for the organization. + """ + organization = parse_organization(request) + customers = Customer.objects.filter(organization=organization).prefetch_related( + Prefetch( + "subscription_set", + queryset=Subscription.objects.filter(organization=organization), + to_attr="subscriptions", + ), + Prefetch( + "subscription_set__billing_plan", + queryset=BillingPlan.objects.filter(organization=organization), + to_attr="billing_plans", + ), + ) + serializer = CustomerSummarySerializer(customers, many=True) + return JsonResponse(serializer.data, status=status.HTTP_200_OK, safe=False) + + +class CustomerDetailView(APIView): permission_classes = [IsAuthenticated] @extend_schema( - responses={200: CustomerRevenueSummarySerializer}, + responses={200: CustomerDetailSerializer}, + ) + def get(self, request, format=None): + """ + Get the current settings for the organization. + """ + organization = parse_organization(request) + customer_id = request.query_params.get("customer_id") + customer = ( + Customer.objects.filter(organization=organization, customer_id=customer_id) + .prefetch_related( + Prefetch( + "subscription_set", + queryset=Subscription.objects.filter(organization=organization), + to_attr="subscriptions", + ), + Prefetch( + "subscription_set__billing_plan", + queryset=BillingPlan.objects.filter(organization=organization), + to_attr="billing_plans", + ), + ) + .get() + ) + sub_usg_summaries = get_customer_usage_and_revenue(customer) + total_revenue_due = sum( + x["total_revenue_due"] for x in sub_usg_summaries["subscriptions"] + ) + invoices = Invoice.objects.filter( + organization__company_name=organization.company_name, + customer__customer_id=customer.customer_id, + ) + serializer = CustomerDetailSerializer( + customer, + context={ + "total_revenue_due": total_revenue_due, + "invoices": invoices, + }, + ) + return JsonResponse(serializer.data, status=status.HTTP_200_OK) + + +class CustomersWithRevenueView(APIView): + + permission_classes = [IsAuthenticated] + + @extend_schema( + responses={200: CustomerWithRevenueSerializer(many=True)}, ) def get(self, request, format=None): """ @@ -435,26 +511,21 @@ def get(self, request, format=None): """ organization = parse_organization(request) customers = Customer.objects.filter(organization=organization) - customers_dict = {"customers": []} + cust = [] for customer in customers: - customer_dict = {} sub_usg_summaries = get_customer_usage_and_revenue(customer) - customer_dict["total_revenue_due"] = sum( + customer_total_revenue_due = sum( x["total_revenue_due"] for x in sub_usg_summaries["subscriptions"] ) - customer_dict["customer_name"] = customer.name - customer_dict["customer_id"] = customer.customer_id - customer_dict["subscriptions"] = [ - x["billing_plan_name"] for x in sub_usg_summaries["subscriptions"] - ] - serializer = CustomerRevenueSerializer(data=customer_dict) - serializer.is_valid(raise_exception=True) - customers_dict["customers"].append(serializer.validated_data) - serializer = CustomerRevenueSummarySerializer(data=customers_dict) - serializer.is_valid(raise_exception=True) - ret = serializer.validated_data - make_all_decimals_floats(ret) - return JsonResponse(ret, status=status.HTTP_200_OK) + serializer = CustomerWithRevenueSerializer( + customer, + context={ + "total_revenue_due": customer_total_revenue_due, + }, + ) + cust.append(serializer.data) + make_all_decimals_floats(cust) + return JsonResponse(cust, status=status.HTTP_200_OK, safe=False) class EventPreviewView(APIView): @@ -496,7 +567,7 @@ def get(self, request, format=None): class DraftInvoiceView(APIView): - permission_classes = [IsAuthenticated | HasUserAPIKey] + permission_classes = [IsAuthenticated] @extend_schema( parameters=[DraftInvoiceRequestSerializer], @@ -675,6 +746,7 @@ def get(self, request, format=None): ) event_name = serializer.validated_data.get("event_name") feature_name = serializer.validated_data.get("feature_name") + event_limit_type = serializer.validated_data.get("event_limit_type") subscriptions = Subscription.objects.select_related("billing_plan").filter( organization=organization, status="active", @@ -689,7 +761,10 @@ def get(self, request, format=None): for component in sub.billing_plan.components.all(): if component.billable_metric.event_name == event_name: metric = component.billable_metric - metric_limit = component.max_metric_units + if event_limit_type == "free": + metric_limit = component.free_metric_units + elif event_limit_type == "total": + metric_limit = component.max_metric_units if not metric_limit: metric_usages[metric.billable_metric_name] = { "metric_usage": None, @@ -702,11 +777,16 @@ def get(self, request, format=None): query_start_date=sub.start_date, query_end_date=sub.end_date, customer=customer, - )[0]["usage_qty"] + ) + metric_usage = list(metric_usage) + if len(metric_usage) > 0: + metric_usage = metric_usage[0]["usage_qty"] + else: + metric_usage = 0 metric_usages[metric.billable_metric_name] = { "metric_usage": metric_usage, "metric_limit": metric_limit, - "access": metric_usage <= metric_limit, + "access": metric_usage < metric_limit, } if all(v["access"] for k, v in metric_usages.items()): return JsonResponse( diff --git a/src/App.css b/src/App.css index 435c7b6d0..300f3fe47 100644 --- a/src/App.css +++ b/src/App.css @@ -1,2 +1,8 @@ @import "antd/dist/antd.css"; @import "@ant-design/pro-components/dist/components.css"; + +.App { + text-align: center; + margin: 0 30%; + font-family: "Inter", sans-serif; +} diff --git a/src/App.tsx b/src/App.tsx index 5149e5dbc..8011a711d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,11 +11,12 @@ function App() { const fetchSessionInfo = async (): Promise<{ isAuthenticated: boolean }> => Authentication.getSession().then((res) => { return res; - }); + }, + ); const { data: sessionData, isLoading } = useQuery<{ isAuthenticated: boolean; - }>(["session"], fetchSessionInfo); + }>(["session"], fetchSessionInfo, {refetchInterval: 60000,}); const isAuthenticated = isLoading ? false : sessionData?.isAuthenticated; if (isLoading) { diff --git a/src/api/api.ts b/src/api/api.ts index 4a52eca1e..c50a6958f 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -1,8 +1,9 @@ import axios, { AxiosResponse } from "axios"; import { - CustomerSummary, - CustomerTableItem, + CustomerPlus, + CustomerDetail, CustomerType, + CustomerTotal, } from "../types/customer-type"; import { PlanType, CreatePlanType } from "../types/plan-type"; import { RevenueType } from "../types/revenue-type"; @@ -19,6 +20,7 @@ import { import Cookies from "universal-cookie"; import { EventPages } from "../types/event-type"; import { CreateOrgAccountType } from "../types/account-type"; +import { cancelSubscriptionType } from "../components/Customers/CustomerSubscriptionView"; axios.defaults.xsrfCookieName = "csrftoken"; axios.defaults.xsrfHeaderName = "X-CSRFToken"; @@ -40,7 +42,7 @@ const requests = { }; export const Customer = { - getCustomers: (): Promise => + getCustomers: (): Promise => requests.get("api/customer_summary/"), getACustomer: (id: number): Promise => requests.get(`api/customers/${id}`), @@ -48,6 +50,14 @@ export const Customer = { requests.post("api/customers/", post), subscribe: (post: CreateSubscriptionType): Promise => requests.post("api/subscriptions/", post), + cancelSubscription: ( + post: cancelSubscriptionType + ): Promise => + requests.post("api/cancel_subscription/", post), + getCustomerTotals: (): Promise => + requests.get("api/customer_totals/"), + getCustomerDetail: (customer_id: string): Promise => + requests.get(`api/customer_detail/`, { params: { customer_id } }), }; export const Plan = { @@ -131,7 +141,7 @@ export const Metrics = { requests.get("api/period_metric_usage/", { params: { start_date, end_date, top_n_customers }, }), - getMetrics: (): Promise => requests.get("api/metrics/"), + getMetrics: (): Promise => requests.get("api/metrics/"), createMetric: (post: MetricType): Promise => requests.post("api/metrics/", post), deleteMetric: (id: number): Promise<{}> => diff --git a/src/components/Customers/CreateCustomerForm.tsx b/src/components/Customers/CreateCustomerForm.tsx index 066ed427c..806628249 100644 --- a/src/components/Customers/CreateCustomerForm.tsx +++ b/src/components/Customers/CreateCustomerForm.tsx @@ -5,6 +5,7 @@ export interface CreateCustomerState { customer_id: string; title: string; subscriptions: string[]; + total_revenue_due: number; } const CreateCustomerForm = (props: { diff --git a/src/components/Customers/CustomerDetail.css b/src/components/Customers/CustomerDetail.css new file mode 100644 index 000000000..22773a858 --- /dev/null +++ b/src/components/Customers/CustomerDetail.css @@ -0,0 +1,4 @@ +.ant-modal { + width: 60%; + height: 60%; +} diff --git a/src/components/Customers/CustomerDetail.tsx b/src/components/Customers/CustomerDetail.tsx index 7777ed2a6..cde1414f7 100644 --- a/src/components/Customers/CustomerDetail.tsx +++ b/src/components/Customers/CustomerDetail.tsx @@ -1,20 +1,31 @@ -import React, { FC, useEffect, useState } from "react"; +import React, { useState } from "react"; import { Form, Tabs, Modal, Select } from "antd"; -import { CreateCustomerState } from "./CreateCustomerForm"; import { PlanType } from "../../types/plan-type"; import { CreateSubscriptionType } from "../../types/subscription-type"; import LoadingSpinner from "../LoadingSpinner"; import { Customer } from "../../api/api"; -import SubscriptionView from "./CustomerSubscriptionView"; -import { useMutation, useQueryClient } from "react-query"; +import SubscriptionView, { + cancelSubscriptionType, +} from "./CustomerSubscriptionView"; +import { + useMutation, + useQueryClient, + useQuery, + UseQueryResult, +} from "react-query"; import dayjs from "dayjs"; +import { + CustomerDetailType, + CustomerDetailSubscription, +} from "../../types/customer-type"; +import "./CustomerDetail.css"; const { Option } = Select; function CustomerDetail(props: { visible: boolean; onCancel: () => void; - customer: CreateCustomerState; + customer_id: string; plans: PlanType[] | undefined; changePlan: (plan_id: string, customer_id: string) => void; }) { @@ -22,26 +33,53 @@ function CustomerDetail(props: { const queryClient = useQueryClient(); const [currentTab, setCurrentTab] = useState("subscriptions"); - const [customerSubscriptions, setCustomerSubscriptions] = useState( - props.customer.subscriptions - ); + const [customerSubscriptions, setCustomerSubscriptions] = useState< + CustomerDetailSubscription[] + >([]); + + const { data, isLoading }: UseQueryResult = + useQuery(["customer_detail", props.customer_id], () => + Customer.getCustomerDetail(props.customer_id).then((res) => { + setCustomerSubscriptions(res.subscriptions); + return res; + }) + ); const mutation = useMutation( (post: CreateSubscriptionType) => Customer.subscribe(post), { onSettled: () => { queryClient.invalidateQueries(["customer_list"]); + queryClient.invalidateQueries(["customer_detail", props.customer_id]); + }, + } + ); + + const cancelMutation = useMutation( + (post: cancelSubscriptionType) => Customer.cancelSubscription(post), + { + onSettled: () => { + queryClient.invalidateQueries(["customer_list"]); + queryClient.invalidateQueries(["customer_detail", props.customer_id]); }, } ); + const cancelSubscription = (props: { + subscription_uid: string; + bill_now: boolean; + revoke_access: boolean; + }) => { + cancelMutation.mutate(props); + }; + const addSubscriptions = (subscription: any) => { setCustomerSubscriptions([...customerSubscriptions, subscription.name]); console.log(subscription, "subscription"); const today = dayjs().format("YYYY-MM-DD"); const newSubscription: CreateSubscriptionType = { - customer_id: props.customer.customer_id, + customer_id: props.customer_id, billing_plan_id: subscription.billing_plan_id, start_date: today, }; @@ -50,16 +88,18 @@ function CustomerDetail(props: { }; const onClick = (e) => { - console.log("click ", e); setCurrentTab(e.key); }; return ( {props.plans === undefined ? (
@@ -68,8 +108,8 @@ function CustomerDetail(props: { ) : (
-

{props.customer.name}

-

Id: {props.customer.customer_id}

+

{data?.customer_name}

+

Id: {props.customer_id}

- - Content of Tab Pane 1 + {" "} + + {data !== undefined ? ( +
+
+

Info

+

Email: {data.email}

+

Billing Address: {data.billing_address}

+
+
+

Timeline

+
+
+ ) : ( +

No Data

+ )}
-
- -
-
- -

History

+ {data !== undefined ? ( +
+ +
+ ) : null}
+ +

Invoices

+
{" "}
diff --git a/src/components/Customers/CustomerSubscriptionView.tsx b/src/components/Customers/CustomerSubscriptionView.tsx index e95be4a73..ac05cf15e 100644 --- a/src/components/Customers/CustomerSubscriptionView.tsx +++ b/src/components/Customers/CustomerSubscriptionView.tsx @@ -1,14 +1,31 @@ -import React, { FC, useEffect, useState } from "react"; +import React, { FC, Fragment, useEffect, useState } from "react"; import { PlanType } from "../../types/plan-type"; -import { Card, List, Form, Select, Button } from "antd"; +import { Card, List, Form, Select, Button, Dropdown, Menu } from "antd"; +import { CustomerDetailSubscription } from "../../types/customer-type"; interface Props { - subscriptions: string[]; + subscriptions: CustomerDetailSubscription[]; plans: PlanType[] | undefined; onChange: (subscription: any) => void; + onCancel: (subscription: cancelSubscriptionType) => void; +} +interface SubscriptionType { + billing_plan_name: string; + subscription_uid: string; + auto_renew: boolean; +} +export interface cancelSubscriptionType { + subscription_uid: string; + bill_now: boolean; + revoke_access: boolean; } -const SubscriptionView: FC = ({ subscriptions, plans, onChange }) => { +const SubscriptionView: FC = ({ + subscriptions, + plans, + onChange, + onCancel, +}) => { const [selectedPlan, setSelectedPlan] = useState(); const [form] = Form.useForm(); @@ -17,11 +34,36 @@ const SubscriptionView: FC = ({ subscriptions, plans, onChange }) => { useState<{ label: string; value: string }[]>(); const selectPlan = (plan_id: string) => { - console.log(plan_id, "22"); - setSelectedPlan(plan_id); }; + const cancelSubscription = (props: cancelSubscriptionType) => { + onCancel(props); + }; + + const cancelAcessBillNowSubscription = () => { + cancelSubscription({ + subscription_uid: subscriptions[0].subscription_uid, + bill_now: true, + revoke_access: true, + }); + }; + + const cancelDontBillSubscription = () => { + cancelSubscription({ + subscription_uid: subscriptions[0].subscription_uid, + bill_now: false, + revoke_access: true, + }); + }; + const cancelDontRenewSubscriptions = () => { + cancelSubscription({ + subscription_uid: subscriptions[0].subscription_uid, + bill_now: false, + revoke_access: false, + }); + }; + useEffect(() => { if (plans !== undefined) { const planMap = plans.reduce((acc, plan) => { @@ -34,13 +76,45 @@ const SubscriptionView: FC = ({ subscriptions, plans, onChange }) => { return { label: plan.name, value: plan.billing_plan_id }; } ); - console.log(newplanList); setPlanList(newplanList); } }, [plans]); + const cancelMenu = ( + cancelAcessBillNowSubscription()}> + {" "} + Cancel and Bill Now + + ), + key: "0", + }, + { + label: ( + + ), + key: "1", + }, + { + label: ( + + ), + key: "1", + }, + ]} + /> + ); + const handleSubmit = () => { - console.log(selectedPlan); if (selectedPlan) { onChange(idtoPlan[selectedPlan]); } @@ -74,13 +148,38 @@ const SubscriptionView: FC = ({ subscriptions, plans, onChange }) => { ); } return ( - - {subscriptions.map((subscription) => ( - - {subscription} - - ))} - +
+

Active Plan

+
+ + {subscriptions.map((subscription) => ( + + +
+

+ {subscription.billing_plan_name} +

+
+

Subscriptions ID: {subscription.subscription_uid}

+

Start Date: {subscription.start_date}

+

End Date: {subscription.end_date}

+

Renews: {subscription.auto_renew ? "yes" : "no"}

+
+
+
+
+ ))} +
+
+ + + + + + +
+
+
); }; diff --git a/src/components/Customers/CustomerTable.tsx b/src/components/Customers/CustomerTable.tsx index d6c13e194..9b16608d2 100644 --- a/src/components/Customers/CustomerTable.tsx +++ b/src/components/Customers/CustomerTable.tsx @@ -1,10 +1,15 @@ import React, { FC, useState, useEffect } from "react"; import type { ProColumns } from "@ant-design/pro-components"; import { ProTable } from "@ant-design/pro-components"; -import { CustomerTableItem } from "../../types/customer-type"; +import { + CustomerPlus, + CustomerSummary, + CustomerTableItem, + CustomerTotal, + CustomerDetailSubscription, +} from "../../types/customer-type"; import { CustomerType } from "../../types/customer-type"; import { Button, Tag } from "antd"; -import { useNavigate } from "react-router-dom"; import LoadingSpinner from "../LoadingSpinner"; import CreateCustomerForm, { CreateCustomerState } from "./CreateCustomerForm"; import { @@ -37,7 +42,11 @@ const columns: ProColumns[] = [ width: 120, dataIndex: "subscriptions", render: (_, record) => ( - {record.subscriptions[0]} +
+ {record.subscriptions.map((sub) => ( + {sub.billing_plan_name} + ))} +
), }, { @@ -49,7 +58,8 @@ const columns: ProColumns[] = [ ]; interface Props { - customerArray: CustomerTableItem[]; + customerArray: CustomerPlus[]; + totals: CustomerTotal[] | undefined; } const defaultCustomerState: CreateCustomerState = { @@ -57,15 +67,42 @@ const defaultCustomerState: CreateCustomerState = { name: "", customer_id: "", subscriptions: [], + total_revenue_due: 0, }; -const CustomerTable: FC = ({ customerArray }) => { +const CustomerTable: FC = ({ customerArray, totals }) => { const [visible, setVisible] = useState(false); const [customerVisible, setCustomerVisible] = useState(false); const [customerState, setCustomerState] = useState(defaultCustomerState); + const [tableData, setTableData] = useState(); const queryClient = useQueryClient(); + useEffect(() => { + if (customerArray !== undefined) { + const dataInstance: CustomerTableItem[] = []; + if (totals !== undefined) { + for (let i = 0; i < customerArray.length; i++) { + const entry: CustomerTableItem = { + ...customerArray[i], + ...totals[i], + }; + dataInstance.push(entry); + } + } else { + for (let i = 0; i < customerArray.length; i++) { + const entry: CustomerTableItem = { + ...customerArray[i], + total_revenue_due: 0.0, + }; + dataInstance.push(entry); + } + } + setTableData(dataInstance); + console.log(dataInstance); + } + }, [customerArray, totals]); + const { data, isLoading }: UseQueryResult = useQuery( ["plans"], () => @@ -106,6 +143,7 @@ const CustomerTable: FC = ({ customerArray }) => { name: record.customer_name, customer_id: record.customer_id, subscriptions: record.subscriptions, + total_revenue_due: record.total_revenue_due, }); }; const openCustomerModal = () => { @@ -120,7 +158,7 @@ const CustomerTable: FC = ({ customerArray }) => { const onSave = (state: CreateCustomerState) => { const customerInstance: CustomerType = { customer_id: state.customer_id, - name: state.name, + customer_name: state.name, }; mutation.mutate(customerInstance); }; @@ -128,7 +166,7 @@ const CustomerTable: FC = ({ customerArray }) => {
columns={columns} - dataSource={customerArray} + dataSource={tableData} rowKey="customer_id" onRow={(record, rowIndex) => { return { @@ -166,7 +204,7 @@ const CustomerTable: FC = ({ customerArray }) => { onCancel={onDetailCancel} changePlan={changePlan} plans={data} - customer={customerState} + customer_id={customerState.customer_id} />
); diff --git a/src/components/EventPreview.tsx b/src/components/EventPreview.tsx index fb62dd78c..891aa4b8e 100644 --- a/src/components/EventPreview.tsx +++ b/src/components/EventPreview.tsx @@ -1,11 +1,13 @@ import React, { FC, useState, useEffect, useRef } from "react"; import { useQuery, UseQueryResult } from "react-query"; -import { List, Descriptions } from "antd"; +import { List, Descriptions, Collapse } from "antd"; import { EventPreviewType, EventPages } from "../types/event-type"; import { Events } from "../api/api"; import LoadingSpinner from "./LoadingSpinner"; import dayjs from "dayjs"; +const { Panel } = Collapse; + const EventPreivew: FC = () => { const [page, setPage] = useState(1); @@ -35,38 +37,40 @@ const EventPreivew: FC = () => { ); } return ( -
- +
+ {data.events.map((event) => ( - - - - {event.event_name} - - - {dayjs(event.time_created).format("YYYY-MM-DD HH:mm:ss")} - - - {event.properties - ? Object.keys(event.properties).map((key, i) => ( -

- - {key}: {event.properties[key]} + +

event_name: {event.event_name}

+

customer_id: {event.customer_id}

+
+ } + key={event.id} + > +
+
+

Id: {event.idempotency_id}

+

properties:

+
+
+

time_created: {event.time_created}

+
+ {event.properties && + Object.keys(event.properties).map((keyName, i) => ( +
  • + + {keyName} : {event.properties[keyName]} -

    - )) - : null} - - - {event.customer_id} - - - +
  • + ))} +
    +
    +
    + ))} -
    +
    ); }; diff --git a/src/components/MetricTable.tsx b/src/components/MetricTable.tsx index 489396b28..7893b1518 100644 --- a/src/components/MetricTable.tsx +++ b/src/components/MetricTable.tsx @@ -36,10 +36,16 @@ const MetricTable: FC = ({ metricArray }) => { const columns: ProColumns[] = [ { title: "Metric Name", - width: 200, + width: 150, dataIndex: "billable_metric_name", align: "left", }, + { + title: "Type", + width: 100, + dataIndex: "event_type", + align: "left", + }, { title: "Event Name", width: 120, diff --git a/src/components/PlanDisplayBasic.tsx b/src/components/PlanDisplayBasic.tsx index 6b599e2a3..3e575df85 100644 --- a/src/components/PlanDisplayBasic.tsx +++ b/src/components/PlanDisplayBasic.tsx @@ -1,66 +1,95 @@ import React, { FC } from "react"; import { PlanType } from "../types/plan-type"; -import { Card, Divider, List } from "antd"; +import { Card, Menu, Dropdown, List, Statistic } from "antd"; import { Plan } from "../api/api"; function PlanDisplayBasic(props: { plan: PlanType }) { + const planMenu = ( + + + Edit + + + Delete + + + ); + return ( - -
    -
    + + e.preventDefault()}> + ... + + + } + title={ +
    {props.plan.name}
    - -

    {props.plan.description}

    + } + > +
    +
    +

    {props.plan.description}

    -
    -
    -

    - Plan id: {props.plan.billing_plan_id} -

    -

    - Date Created: {props.plan.time_created} -

    +
    +
    +

    + Plan id: {props.plan.billing_plan_id} +

    +
    +
    +

    + {" "} + Interval: {props.plan.interval} +

    +

    + {" "} + Recurring Price: ${props.plan.flat_rate} +

    +
    +
    +

    + Date Created: {props.plan.time_created} +

    +

    + {" "} + Pay In Advance: Yes +

    +
    -
    -

    - {" "} - Interval: {props.plan.interval} -

    -

    - {" "} - Recurring Price: ${props.plan.flat_rate} -

    -
    -

    - {" "} - Pay In Advance: Yes -

    -
    +
    ( - + -

    +

    Metric: {item.billable_metric.event_name}

    {" "} - Aggregation Type:{" "} - {item.billable_metric.aggregation_type} + Cost: ${item.cost_per_batch} per{" "} + {item.metric_units_per_batch}

    {" "} - Property: {item.billable_metric.property_name} + Free Units: {item.free_metric_units}

    )} />
    +
    + +
    ); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 355b4f0d3..27309a061 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -74,7 +74,7 @@ const Settings: FC = () => {
    - +
    @@ -89,7 +89,14 @@ const Settings: FC = () => { ))} */}
    - + + Ok + + } + >

    New API Key

    diff --git a/src/components/SideBar.tsx b/src/components/SideBar.tsx index f73953c7f..b7e43c89c 100644 --- a/src/components/SideBar.tsx +++ b/src/components/SideBar.tsx @@ -48,7 +48,7 @@ const SideBar: FC = () => { }; return ( -

    +
    { style={{ height: "100%", margin: "30px", + background: "#000000", }} > lotus @@ -104,6 +105,7 @@ const SideBar: FC = () => { Settings + Logout diff --git a/src/config/Routes.tsx b/src/config/Routes.tsx index 2538d8bff..e2dd5e135 100644 --- a/src/config/Routes.tsx +++ b/src/config/Routes.tsx @@ -10,6 +10,7 @@ import { Divider, Layout } from "antd"; import { MenuUnfoldOutlined, MenuFoldOutlined } from "@ant-design/icons"; import CreatePlan from "../pages/CreatePlan"; import ViewMetrics from "../pages/ViewMetrics"; +import EditPlan from "../pages/EditPlan"; const { Header, Sider, Content, Footer } = Layout; @@ -49,6 +50,9 @@ const AppRoutes: FC = () => { } /> } /> } /> + + } /> + } /> } /> } /> diff --git a/src/index.css b/src/index.css index 1a485a8a6..bb4142a6e 100644 --- a/src/index.css +++ b/src/index.css @@ -1,14 +1,8 @@ +@import url("https://rsms.me/inter/inter.css"); @tailwind base; -@layer base { - h1 { - @apply text-3xl font-main; - } -} - @tailwind components; @tailwind utilities; -@import url("https://rsms.me/inter/inter.css"); html { font-family: "Inter", sans-serif; @@ -19,6 +13,30 @@ html { } } +h1 { + font-family: "Inter"; + font-style: normal; + font-weight: 700; + font-size: 32px; + line-height: 100%; + /* identical to box height, or 32px */ + + letter-spacing: -0.06em; + + /* Black */ + + color: #1d1d1f; +} + +h2 { + font-family: "Inter"; + font-style: normal; + font-weight: 700; + font-size: 26px; + line-height: 100%; + color: #1d1d1f; +} + html, body { padding: 0; diff --git a/src/pages/EditPlan.tsx b/src/pages/EditPlan.tsx new file mode 100644 index 000000000..a8c25944a --- /dev/null +++ b/src/pages/EditPlan.tsx @@ -0,0 +1,250 @@ +import { + Button, + Checkbox, + Form, + Card, + Input, + Select, + InputNumber, + PageHeader, + List, + Divider, +} from "antd"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import UsageComponentForm from "../components/UsageComponentForm"; +import { useMutation } from "react-query"; +import { MetricNameType } from "../types/metric-type"; +import { toast } from "react-toastify"; +import { Metrics } from "../api/api"; +import { CreatePlanType, CreateComponent } from "../types/plan-type"; +import { Plan } from "../api/api"; + +interface ComponentDisplay { + metric: string; + cost_per_batch: number; + metric_units_per_batch: number; + free_amount: number; +} + +const CreatePlan = () => { + const [visible, setVisible] = useState(false); + const navigate = useNavigate(); + const [metrics, setMetrics] = useState([]); + const [form] = Form.useForm(); + + useEffect(() => { + Metrics.getMetrics().then((res) => { + const data: MetricNameType[] = res; + if (data) { + const metricList: string[] = []; + for (let i = 0; i < data.length; i++) { + if (typeof data[i].billable_metric_name !== undefined) { + metricList.push(data[i].billable_metric_name); + } + } + setMetrics(metricList); + } + }); + }, []); + + const mutation = useMutation( + (post: CreatePlanType) => Plan.createPlan(post), + { + onSuccess: () => { + toast.success("Successfully created Plan", { + position: toast.POSITION.TOP_CENTER, + }); + navigate("/plans"); + }, + onError: () => { + toast.error("Failed to create Plan", { + position: toast.POSITION.TOP_CENTER, + }); + }, + } + ); + + const onFinishFailed = (errorInfo: any) => { + console.log("Failed:", errorInfo); + }; + + const hideUserModal = () => { + setVisible(false); + }; + + const showUserModal = () => { + setVisible(true); + }; + + const goBackPage = () => { + navigate(-1); + }; + + const submitPricingPlan = () => { + console.log("Submit Pricing Plan"); + form + .validateFields() + .then((values) => { + const usagecomponentslist: CreateComponent[] = []; + const components = form.getFieldValue("components"); + if (components) { + for (let i = 0; i < components.length; i++) { + const usagecomponent: CreateComponent = { + billable_metric_name: components[i].metric, + cost_per_batch: components[i].cost_per_batch, + metric_units_per_batch: components[i].metric_units_per_batch, + free_metric_units: components[i].free_amount, + max_metric_units: components[i].max_metric_units, + }; + usagecomponentslist.push(usagecomponent); + } + } + + const plan: CreatePlanType = { + name: values.name, + description: values.description, + flat_rate: values.flat_rate, + pay_in_advance: values.pay_in_advance, + interval: values.billing_interval, + components: usagecomponentslist, + }; + mutation.mutate(plan); + form.resetFields(); + }) + .catch((info) => { + console.log("Validate Failed:", info); + }); + }; + + return ( +
    + + { + if (name === "component_form") { + const { create_plan } = forms; + const components = create_plan.getFieldValue("components") || []; + create_plan.setFieldsValue({ components: [...components, values] }); + setVisible(false); + } + }} + > +
    + + + + + + + + + + + + + + + Pay In Advance + +
    +
    + + + + + prevValues.components !== curValues.components + } + > + {({ getFieldValue }) => { + const components: ComponentDisplay[] = + getFieldValue("components") || []; + console.log(components); + return components.length ? ( + + {components.map((component, index) => ( + + +

    + Cost: {component.cost_per_batch} per{" "} + {component.metric_units_per_batch} events{" "} +

    +
    +

    + Free Amount Per Billing Cycle:{" "} + {component.free_amount} +

    +
    +
    + ))} +
    + ) : null; + }} +
    +
    + +
    +
    + + + + +
    + +
    +
    + ); +}; + +export default CreatePlan; diff --git a/src/pages/ViewCustomers.tsx b/src/pages/ViewCustomers.tsx index bbb4f3d0b..487f3694e 100644 --- a/src/pages/ViewCustomers.tsx +++ b/src/pages/ViewCustomers.tsx @@ -1,6 +1,10 @@ import React, { FC, useEffect, useState } from "react"; import CustomerTable from "../components/Customers/CustomerTable"; -import { CustomerSummary, CustomerTableItem } from "../types/customer-type"; +import { + CustomerPlus, + CustomerTableItem, + CustomerTotal, +} from "../types/customer-type"; import { Customer } from "../api/api"; import LoadingSpinner from "../components/LoadingSpinner"; import { useQuery, UseQueryResult, useQueryClient } from "react-query"; @@ -9,12 +13,21 @@ const ViewCustomers: FC = () => { const [customers, setCustomers] = useState([]); const queryClient = useQueryClient(); - const { data, isLoading }: UseQueryResult = - useQuery(["customer_list"], () => - Customer.getCustomers().then((res) => { - return res; - }) - ); + const { data, isLoading }: UseQueryResult = useQuery< + CustomerPlus[] + >(["customer_list"], () => + Customer.getCustomers().then((res) => { + console.log; + return res; + }) + ); + const { data: customerTotals, isLoading: totalLoading } = useQuery< + CustomerTotal[] + >(["customer_totals"], () => + Customer.getCustomerTotals().then((res) => { + return res; + }) + ); return (
    @@ -23,7 +36,7 @@ const ViewCustomers: FC = () => { {isLoading || data === undefined ? ( ) : ( - + )}
    diff --git a/src/pages/ViewMetrics.css b/src/pages/ViewMetrics.css new file mode 100644 index 000000000..53a126518 --- /dev/null +++ b/src/pages/ViewMetrics.css @@ -0,0 +1,3 @@ +.ant-card-body { + width: 100%; +} diff --git a/src/pages/ViewMetrics.tsx b/src/pages/ViewMetrics.tsx index cb8eac6ef..e274444c8 100644 --- a/src/pages/ViewMetrics.tsx +++ b/src/pages/ViewMetrics.tsx @@ -16,12 +16,15 @@ import CreateMetricForm, { } from "../components/CreateMetricForm"; import { toast } from "react-toastify"; import EventPreivew from "../components/EventPreview"; +import "./ViewMetrics.css"; const defaultMetricState: CreateMetricState = { title: "Create a new Metric", event_name: "", aggregation_type: "", property_name: "", + event_type: "", + stateful_aggregation_period: "", }; const ViewMetrics: FC = () => { @@ -95,8 +98,8 @@ const ViewMetrics: FC = () => { )} {isError &&
    Something went wrong
    }
    - -

    Event Stream

    + +

    Event Stream

    diff --git a/src/pages/ViewPlans.tsx b/src/pages/ViewPlans.tsx index 66d529f6a..97e9a8705 100644 --- a/src/pages/ViewPlans.tsx +++ b/src/pages/ViewPlans.tsx @@ -46,8 +46,7 @@ const ViewPlans: FC = () => { ( diff --git a/src/types/customer-type.ts b/src/types/customer-type.ts index c29125f2a..0821439c9 100644 --- a/src/types/customer-type.ts +++ b/src/types/customer-type.ts @@ -1,17 +1,43 @@ import { PlanDisplay } from "./plan-type"; import { SubscriptionType } from "./subscription-type"; export interface CustomerType { - name: string; + customer_name: string; billing_id?: string; balance?: string; customer_id: string; } -export interface CustomerTableItem extends CustomerType { - subscriptions: string[]; +export interface CustomerDetailType extends CustomerType { + email: string; + timeline: object; total_revenue_due: number; + subscriptions: CustomerDetailSubscription[]; + billing_address: string; } +export interface CustomerPlus extends CustomerType { + subscriptions: CustomerSubscription[]; +} +export interface CustomerTableItem extends CustomerPlus { + total_revenue_due: number; +} + +export interface CustomerTotal { + customer_id: string; + total_revenue_due: number; +} + +interface CustomerSubscription { + billing_plan_name: string; + auto_renew: boolean; + end_date: string; +} + +export interface CustomerDetailSubscription extends CustomerSubscription { + subscription_uid: string; + start_date: string; + status: string; +} export interface CustomerSummary { - customers: CustomerTableItem[]; + customers: CustomerPlus[]; } diff --git a/vite.config.ts b/vite.config.ts index adfadb16f..c20b27f68 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -59,8 +59,12 @@ export default defineConfig({ less: { javascriptEnabled: true, // modifyVars: { - // "primary-color": "#DEC27D", - // compact: true, + white: "#333", + "component-background": "#777", + "primary-color": "#1DA57A", + "link-color": "#1DA57A", + "border-radius-base": "2px", + "font-family": "Inter, sans-serif", }, }, },