diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ad8187bffa..0c2777e7d9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -571,6 +571,84 @@ importers: specifier: ^5.9.3 version: 5.9.3 + spacetime-stripe-ts: + dependencies: + '@spacetimedb/crypto': + specifier: workspace:^ + version: link:../spacetime-crypto-ts + valibot: + specifier: ^1.4.2 + version: 1.4.2(typescript@5.9.3) + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + stripe: + specifier: ^22.1.0 + version: 22.5.0(@types/node@22.18.0) + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-stripe-ts/example: + dependencies: + '@spacetimedb/submodule-shared': + specifier: workspace:* + version: link:../../spacetime-submodule-shared-ts + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.23 + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-stripe-ts/example/spacetimedb: + dependencies: + '@spacetimedb/stripe': + specifier: workspace:* + version: link:../.. + spacetimedb: + specifier: workspace:* + version: link:../../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + spacetime-submodule-shared-ts: devDependencies: '@types/node': @@ -13824,6 +13902,15 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + stripe@22.5.0: + resolution: {integrity: sha512-QVwMwriC0bbySx6R4dpsvJ0W//GojC1kwWVS6rPSoVqDUIZX4Hy3TaUrd2AZeXEAaKbfWIjQjvo3vKAReHZ0vQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + structured-clone-es@1.0.0: resolution: {integrity: sha512-FL8EeKFFyNQv5cMnXI31CIMCsFarSVI2bF0U0ImeNE3g/F1IvJQyqzOXxPBRXiwQfyBTlbNe88jh1jFW0O/jiQ==} @@ -31475,6 +31562,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + stripe@22.5.0(@types/node@22.18.0): + optionalDependencies: + '@types/node': 22.18.0 + structured-clone-es@1.0.0: {} style-to-js@1.1.17: @@ -32355,6 +32446,10 @@ snapshots: optionalDependencies: typescript: 5.6.3 + valibot@1.4.2(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 diff --git a/spacetime-stripe-ts/.npmrc b/spacetime-stripe-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-stripe-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-stripe-ts/LICENSE.txt b/spacetime-stripe-ts/LICENSE.txt new file mode 100644 index 00000000000..ea0cb1c5e9e --- /dev/null +++ b/spacetime-stripe-ts/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-stripe-ts/README.md b/spacetime-stripe-ts/README.md new file mode 100644 index 00000000000..4c41b4d38e4 --- /dev/null +++ b/spacetime-stripe-ts/README.md @@ -0,0 +1,242 @@ +# @spacetimedb/stripe + +A SpacetimeDB submodule that mirrors Stripe customers, subscriptions, Checkout +sessions, invoices, and payments. Stripe webhooks feed private base tables, and +host modules expose product-specific views and workflows. Procedures are +synchronous and webhook payloads use valibot validation. + +--- + +## Install + +```bash +npm install @spacetimedb/stripe spacetimedb@^2.8.3 +``` + +Requires SpacetimeDB 2.8.3 or later for submodule mounting. + +For the install-to-publish workflow, see +[Getting started](https://spacetimedb.com/docs/). + +This submodule can be published directly as its own SpacetimeDB module from the root entry point. + +## Usage + +### Integrate into an application + +Register Stripe in the application schema and initialize its private tables. The +host must place authorization in front of customer, Checkout, portal, and +billing procedures and expose only caller-scoped billing views: + +```ts +import { schema } from 'spacetimedb/server'; +import * as stripe from '@spacetimedb/stripe/submodule'; + +const spacetimedb = schema({ stripe }); +export default spacetimedb; + +export const init = spacetimedb.init(ctx => { + stripe.installStripe(ctx.as.stripe); +}); +``` + +Configure credentials through an administrator-only startup path. See the +[Premium Store host module](./example/spacetimedb/) +for service-identity delegation, safe return URLs, catalog synchronization, and +webhook routing. + +Expose a product-facing Checkout procedure that resolves the application user, +validates every price against a server-owned catalog, fixes the allowed return +URL origins, and delegates to Stripe: + +```ts +export const create_store_checkout_session = spacetimedb.procedure( + storeCheckoutParams, + storeCheckoutResult, + (ctx, args) => { + const checkout = authorizeStoreCheckout(ctx, args); + return stripe.create_checkout_session(ctx.as.stripe, checkout); + } +); +``` + +`storeCheckoutParams`, `storeCheckoutResult`, and `authorizeStoreCheckout` +belong to the host application. The generated client calls that wrapper and +navigates only to the returned Stripe URL: + +```ts +const checkout = await conn.procedures.createStoreCheckoutSession({ + items: [{ priceId, quantity: 1n }], + customerId, + mode: 'payment', + successUrl: `${location.origin}/?checkout=success`, + cancelUrl: `${location.origin}/?checkout=cancelled`, + metadataJson: JSON.stringify({ cartId }), + subscriptionMetadataJson: undefined, + paymentIntentMetadataJson: undefined, +}); + +if (!checkout.url) throw new Error('stripe.checkout_url_missing'); +location.assign(checkout.url); +``` + +### Standalone configuration + +Stripe credentials live in a private `stripe_config` singleton. During `init`, a +fresh database seeds the owner into the private `stripe_admin_identity` table. + +```bash +spacetime call --server http://127.0.0.1:3000 stripe-ts set_stripe_config \ + '"sk_test_..."' \ + null \ + '{"some":"whsec_..."}' # webhook signing secret, optional +``` + +For `t.option(...)` CLI arguments, use `null` for no value and +`{"some":"value"}` for a string value. + +Verify: + +```bash +spacetime call --server http://127.0.0.1:3000 stripe-ts get_stripe_config_status '{}' +``` + +The Stripe secret stays in private module state. Every provider-backed, +billing-state, configuration, and query procedure is admin-gated. A host module +can perform application-specific authorization and then call the helpers through +its submodule-scoped `ctx.as.stripe` context. + +## Private tables + +| Table | Key | Notes | +| ------------------------- | ---------------------------- | ----------------------------------------- | +| `stripe_customer` | `stripe_customer_id` | indexed by email, app-userId | +| `stripe_subscription` | `stripe_subscription_id` | indexed by customer, org, user | +| `stripe_checkout_session` | `stripe_checkout_session_id` | indexed by customer, status | +| `stripe_invoice` | `stripe_invoice_id` | indexed by customer, subscription, status | +| `stripe_payment` | `stripe_payment_intent_id` | indexed by customer, status | + +- `stripe_webhook_event`: idempotency log (`get_webhook_event_count` exposes the size) +- `stripe_config`: credentials singleton +- `stripe_admin_identity`: admin allowlist + +Stripe base tables are private. Expose product-specific fields and rows through +caller-scoped host views. Resolve customer, user, and organization IDs from +trusted application context. + +## API + +**Setup** + +- `set_stripe_config(secretKey, stripeVersion, webhookSigningSecret)` +- `set_stripe_webhook_signing_secret(webhookSigningSecret)`: rotates only the webhook secret +- `get_stripe_config_status()`: returns `{ isConfigured, hasWebhookSecret, secretKeyLength, ... }` +- `add_admin_identity(identity)` / `remove_admin_identity(identity)` + +**Customer / billing flows** + +- `create_customer({ email, name, metadataJson, idempotencyKey })` +- `create_or_update_customer({ stripeCustomerId, email, name, metadataJson })` +- `get_or_create_customer({ userId, email, name})` +- `create_checkout_session({ items, mode, successUrl, cancelUrl, customerId, ...metadata })` +- `validate_stripe_price({ priceId })`: confirms a price exists and is active +- `get_remote_checkout_session({ sessionId })`: fetch session state from Stripe +- `create_customer_portal_session({ customerId, returnUrl })` +- `cancel_subscription({ stripeSubscriptionId, cancelAtPeriodEnd })` +- `reactivate_subscription({ stripeSubscriptionId })` +- `update_subscription_quantity({ stripeSubscriptionId, quantity })` +- `update_subscription_metadata({ stripeSubscriptionId, metadataJson, orgId, userId })` +- `stripe_api_request({ method, path, formBody, idempotencyKey })`: admin-gated + request to a relative `/v1/` path on `api.stripe.com`; accepted methods are + `GET`, `POST`, and `DELETE` + +**Webhook ingest / replay** + +- `ingest_stripe_webhook(eventId, eventType, livemode, payloadJson, signatureHeader)`: idempotent +- `replay_webhook_event(eventId)`: re-applies a stored event +- `get_webhook_event_count()`: observability +- `stripe_webhook_handler` and `handleStripeWebhook` support direct host HTTP + routing. +- `upsert_customer`, `upsert_subscription`, `update_payment_customer`, and + `update_subscription_quantity_internal` apply trusted synchronization data. + +**Admin queries** + +- `get_customer`, `get_customer_by_email`, `get_customer_by_user_id` +- `get_subscription`, `list_subscriptions`, `list_subscriptions_with_creation_time`, `get_subscription_by_org_id`, `list_subscriptions_by_org_id`, `list_subscriptions_by_user_id` +- `get_payment`, `list_payments`, `list_payments_by_org_id`, `list_payments_by_user_id` +- `list_invoices`, `list_invoices_by_org_id`, `list_invoices_by_user_id` +- `get_checkout_session`, `list_checkout_sessions` + +List procedures return at most 1,000 rows. Build paginated, product-specific +views in the host module when a UI needs a larger history. + +Package entrypoints: + +- `@spacetimedb/stripe` can run as a standalone billing database. +- `@spacetimedb/stripe/submodule` supplies submodule billing, webhook, + configuration, and query operations. + +## Webhook events handled + +``` +customer.created customer.updated +customer.subscription.created customer.subscription.updated +customer.subscription.deleted checkout.session.completed +invoice.created invoice.finalized +invoice.paid invoice.payment_succeeded +invoice.payment_failed payment_intent.succeeded +``` + +Other event types are accepted but stored with `status = 'ignored'`. + +## Webhook signature verification + +Both entry points verify the Stripe signature in-module against the configured +`webhookSigningSecret` (HMAC-SHA256 over `${timestamp}.${rawBody}` via +`@spacetimedb/crypto`). Missing secrets produce a service-unavailable response: + +- `stripe_webhook_handler` (HTTP) - for direct Stripe-to-SpacetimeDB delivery. +- `ingest_stripe_webhook` (reducer) - for a relay forwarding the raw body + + `stripe-signature` header over the SDK; it verifies before mutating state. + +`replay_webhook_event` re-applies an already-stored event and is admin-gated. +The relay reducer also verifies that its separately supplied event metadata +matches the signed payload before using the event ID as its idempotency key. +Webhook application is atomic. Invalid event data rolls back the event row and +business-table changes so Stripe can redeliver the event. + +## Integration testing + +```bash +# Build + publish + happy paths, idempotency, signed-metadata checks, and authorization checks +pnpm run test:smoke + +# Real Stripe sandbox via Stripe CLI (requires `stripe login`) +pnpm run test:stripe:e2e +``` + +The smoke test publishes only to the dedicated `stripe-ts-smoke-test` database. +The Stripe CLI E2E suite likewise defaults to the dedicated `stripe-ts-e2e` +database, forwards the original signed body, and rotates only that database's +ephemeral listener secret. + +## Architecture notes + +- **valibot for runtime validation.** `vStripeEvent` is a `v.variant('type', [...])` over the 12 supported event types. `parseWithSchema` returns a tagged result; `assertExhaustive` makes the typed `switch` compiler-checked. +- **SDK types, sync HTTP.** The `stripe` npm package supplies event types such as `Stripe.CustomerCreatedEvent`. Procedures use the synchronous `ctx.http.fetch` API through the request boundary in `submodule/http.ts`. +- **Compile-time SDK alignment.** `_align*` checks in `schema.ts` assert valibot output is structurally assignable to `Stripe.*Event`. If Stripe ships a breaking change, typecheck fails. +- **Idempotency.** Each webhook event is keyed by `event.id`; re-ingest is a no-op. `replay_webhook_event` applies the stored event state again. + +## Testing + +```bash +pnpm test +pnpm run lint +``` + +Credentialed sandbox coverage is described in **Integration testing** above. + +## License + +[BUSL-1.1](./LICENSE.txt) - same as SpacetimeDB. diff --git a/spacetime-stripe-ts/example/.env.example b/spacetime-stripe-ts/example/.env.example new file mode 100644 index 00000000000..51fd88a7224 --- /dev/null +++ b/spacetime-stripe-ts/example/.env.example @@ -0,0 +1,32 @@ +# Stripe setup. Loaded by the authorized example server during startup. +STRIPE_SECRET_KEY=sk_test_... + +# Optional webhook signature verification and API-version override. +STRIPE_WEBHOOK_SECRET= +STRIPE_VERSION= + +# Opt in to creating/linking Stripe test prices during startup. +STRIPE_SYNC_PRICES=0 + +# Browser provider actions are enabled automatically only for a non-production +# loopback host. Otherwise, opt in only after adding authentication/rate limits. +STRIPE_ALLOW_BROWSER_PROVIDER_ACTIONS=0 + +# Server-owned Checkout success/cancel origin. Production requires HTTPS. +STRIPE_RETURN_BASE_URL=http://127.0.0.1:8787 + +# Keep this set to development for local use. Production disables automatic +# loopback-only provider actions unless they are explicitly authorized below. +NODE_ENV=development + +# Optional: override DB target. +STDB_URI=ws://127.0.0.1:3000 +STDB_HTTP=http://127.0.0.1:3000 +SPACETIMEDB_DB_NAME=spacetime-stripe-example +# Optional. When unset, the server creates a persistent local identity token in +# .stdb-server-token and the logged-in publishing identity authorizes it. +# STDB_SERVER_TOKEN= + +# Optional. +PORT=8787 +HOST=127.0.0.1 diff --git a/spacetime-stripe-ts/example/.npmrc b/spacetime-stripe-ts/example/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-stripe-ts/example/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-stripe-ts/example/README.md b/spacetime-stripe-ts/example/README.md new file mode 100644 index 00000000000..327f6cce3b9 --- /dev/null +++ b/spacetime-stripe-ts/example/README.md @@ -0,0 +1,206 @@ +# Premium Store + +Premium Store demonstrates a host database that mounts +`@spacetimedb/stripe/submodule` and delegates customer, price, checkout, and +webhook operations through the `stripe` namespace. The browser can shop and create +Stripe Checkout sessions, but it cannot configure Stripe or mutate administrative +catalog state. + +## What this demonstrates + +- Mounting the Stripe submodule inside an application-owned store module. +- Keeping Stripe credentials in private module state. +- Using a narrow server API for customer lookup, price validation, and Checkout; + the browser never receives the privileged service identity. +- Seeding an application catalog independently of Stripe provider records. +- Creating or linking idempotent Stripe test prices during explicit server setup. +- Receiving Stripe webhooks through the module's native HTTP route. + +## Prerequisites + +- Node.js 20 or later and pnpm 10. +- The released SpacetimeDB 2.8 CLI. +- A local SpacetimeDB server reachable as `local`. +- A logged-in CLI identity that publishes the database. +- A Stripe **test-mode** secret key (`sk_test_...`). +- Optional: the Stripe CLI or another tunnel for forwarding test webhooks. + +Select the supported CLI release, then keep the local server running in a +separate terminal: + +```powershell +spacetime version install 2.8.3 +spacetime version use 2.8.3 +spacetime start +``` + +```powershell +spacetime server ping local +spacetime login show +``` + +## Quick start + +From `spacetime-stripe-ts/example`: + +```powershell +pnpm install +node -e "require('node:fs').copyFileSync('.env.example', '.env')" +``` + +Set `STRIPE_SECRET_KEY=sk_test_...` in `.env`. Set +`STRIPE_SYNC_PRICES=1` for the first full checkout run; this creates or reuses +three Stripe test prices and writes their IDs to `store_product`. + +```powershell +pnpm run build:module:fresh +pnpm run dev +``` + +Open . Add a product to the cart and create a Stripe test +Checkout session. No charge occurs unless the Checkout page is completed with a +Stripe test payment method. + +`build:module:fresh` deletes and recreates only the local `spacetime-stripe-example` +database. Use `pnpm run build:module` to preserve existing data. + +## Use in your project + +This workspace tests the submodule source in this repository. Consumer applications install published releases: + +```bash +npm install @spacetimedb/stripe spacetimedb@^2.8.3 +``` + +Follow the package's +[integration guide](../README.md#integrate-into-an-application). Copy the +service-identity, narrow Checkout API, caller-scoped billing views, and signed +webhook route. The product catalog and storefront are demonstration code. + +## Configuration + +| Variable | Default | Purpose | +| --------------------------------------- | ------------------------------------ | ------------------------------------------------------------- | +| `STRIPE_SECRET_KEY` | empty | Required for provider operations. Use a test-mode key. | +| `STRIPE_WEBHOOK_SECRET` | empty | Verifies incoming Stripe webhook signatures. | +| `STRIPE_VERSION` | submodule default | Optional Stripe API-version override. | +| `STRIPE_SYNC_PRICES` | `0` | Set to `1` to create/link missing test prices during startup. | +| `STRIPE_ALLOW_BROWSER_PROVIDER_ACTIONS` | automatic on non-production loopback | Explicit provider-action opt-in for other environments. | +| `STRIPE_RETURN_BASE_URL` | `http://127.0.0.1:8787` | Server-owned Checkout return origin. | +| `NODE_ENV` | empty | Set to `production` to disable development-only defaults. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser and server WebSocket endpoint. | +| `STDB_HTTP` | `http://127.0.0.1:3000` | CLI administration endpoint. Must match `STDB_URI`. | +| `SPACETIMEDB_DB_NAME` | `spacetime-stripe-example` | Published database name. | +| `STDB_SERVER_TOKEN` | generated locally | Optional pre-provisioned server identity token. | +| `HOST` | `127.0.0.1` | Static-server bind address. | +| `PORT` | `8787` | Static-server port. | + +When no server token is supplied, the server persists one in the ignored +`.stdb-server-token` file. The logged-in publishing identity registers that server +identity in both the host store and Stripe submodule administrator registries. The +browser identity is never granted either role. + +## Startup behavior + +The example server performs the following bounded setup before accepting HTTP: + +1. Connect with the persistent server identity. +2. Authorize it through the logged-in CLI publishing identity. +3. Seed the default store catalog if it is empty. +4. Store Stripe configuration when `STRIPE_SECRET_KEY` is present. +5. Synchronize missing prices only when `STRIPE_SYNC_PRICES=1`. + +Price synchronization is opt-in because it creates test-mode objects in the linked +Stripe account. Existing prices use stable lookup keys and are reused. + +## Architecture + +```text +Browser storefront + -> public store_product subscription + -> same-origin /api checkout/customer/validation routes + -> authorized server identity + -> host checkout/customer/validation procedures + -> stripe submodule namespace + -> Stripe API + +Stripe + -> POST /route/stripe/webhook on the SpacetimeDB database + -> host router + -> stripe submodule webhook handler + +Authorized example server + -> private configuration and catalog setup during startup +``` + +The Node server exposes only browser-safe health and configuration routes: + +| Route | Purpose | +| ----------------- | ----------------------------------- | +| `GET /api/health` | Local health probe. | +| `GET /api/config` | Browser-safe database/setup status. | + +There are no HTTP administration endpoints. The settings panel contains buyer and +debugging controls only. + +## Webhooks + +Forward Stripe test events to the database's native route: + +```text +http://127.0.0.1:3000/v1/database/spacetime-stripe-example/route/stripe/webhook +``` + +Use the signing secret produced by the forwarding tool as +`STRIPE_WEBHOOK_SECRET`, then restart so the private submodule configuration is +updated. + +## Security and deployment boundaries + +- Never use a live-mode Stripe key for casual example testing. +- Stripe secrets and the persistent server token must never be committed or sent to + the browser. +- The server binds to loopback by default. +- Production deployments should provision an authenticated service identity + through deployment infrastructure. +- Browser provider actions are automatic only for a non-production loopback host. + Production and externally bound development servers default to disabled. Add + application authentication and rate limiting, then set + `STRIPE_ALLOW_BROWSER_PROVIDER_ACTIONS=1` only when required. +- The server owns Checkout return URLs. Set `STRIPE_RETURN_BASE_URL` to the public + HTTPS origin in production; browser-supplied redirect URLs are ignored. +- Checkout success in the UI is a redirect result; authoritative fulfillment must + come from verified webhooks. + +## Verification + +```powershell +pnpm --dir spacetimedb run build +pnpm run build +pnpm exec tsc -p tsconfig.json +``` + +For the provider-backed smoke test, set `STRIPE_SYNC_PRICES=1`, fresh-publish, +start the server, confirm three prices synchronize, add a product to the cart, and +create a test Checkout session. + +## Troubleshooting + +- **Products say “Sync price first”:** set `STRIPE_SYNC_PRICES=1` and restart with + a valid test key. +- **`stripe.not_authorized`:** publish with the logged-in CLI identity and restart; + both the store and Stripe namespaces must authorize the server identity. +- **Connection targets disagree:** make `STDB_URI`, `STDB_HTTP`, and the publish + target refer to the same server. +- **Webhook state is stale:** verify the forwarding URL and + `STRIPE_WEBHOOK_SECRET`. + +## Important files + +- `spacetimedb/src/store/operations.ts`: application catalog and Stripe + delegation. +- `server.ts`: safe startup configuration and server identity authorization. +- `src/app.ts`: typed browser-side SpacetimeDB adapter. +- `public/index.html`: storefront and buyer tools. +- `public/ui.js`: storefront state, rendering, and interaction handling. +- `public/styles.css`: storefront presentation. diff --git a/spacetime-stripe-ts/example/package.json b/spacetime-stripe-ts/example/package.json new file mode 100644 index 00000000000..90d0a5087b6 --- /dev/null +++ b/spacetime-stripe-ts/example/package.json @@ -0,0 +1,28 @@ +{ + "name": "spacetime-stripe-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings/app --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", + "check": "tsc --noEmit", + "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", + "build": "pnpm run spacetime:generate && pnpm run build:app", + "dev": "pnpm run build && tsx server.ts" + }, + "dependencies": { + "@spacetimedb/submodule-shared": "workspace:*", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", + "esbuild": "^0.28.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-stripe-ts/example/public/index.html b/spacetime-stripe-ts/example/public/index.html new file mode 100644 index 00000000000..dd34614603f --- /dev/null +++ b/spacetime-stripe-ts/example/public/index.html @@ -0,0 +1,155 @@ + + + + + + + SpacetimeDB Premium Store + + + +
+
+
+ SpacetimeDB + Premium Store Test App +
+
+ + +
+
+ +
+
+

Storefront

+

+ Browse products and add them to cart. Open cart to checkout. +

+
+
Loading catalog from SpacetimeDB...
+
+
+
+ + +
+ + + + + +
+ + + + + + + + diff --git a/spacetime-stripe-ts/example/public/styles.css b/spacetime-stripe-ts/example/public/styles.css new file mode 100644 index 00000000000..cc0996a0bbc --- /dev/null +++ b/spacetime-stripe-ts/example/public/styles.css @@ -0,0 +1,1169 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&family=Source+Code+Pro:wght@400;500;600&display=swap'); + +:root { + /* Tokens match spacetime-web/spacetimedb.com/app/styles/variables.css */ + --font-inter: 'Inter Variable', 'Inter', sans-serif; + --font-source: 'Source Code Pro Variable', 'Source Code Pro', monospace; + --font-ibm: 'IBM Plex Mono', monospace; + + --color-green: #4cf490; + --color-green-10: #4cf4901a; + --color-green-20: #4cf49033; + --color-green-25: #4cf49040; + --color-green-50: #4cf49080; + --color-green-75: #4cf490bf; + --color-white: #d7d8d9; + --color-yellow: #fbdc8e; + --color-yellow-10: #fbdc8e1a; + --color-yellow-20: #fbdc8e33; + --color-purple: #a880ff; + --color-purple-2: #8a38f5; + --color-orange: #ff9e9e; + --color-blue: #02befa; + --color-blue-10: #02befa1a; + --color-blue-20: #02befa33; + --color-pink: #ff80fb; + --color-teal: #00ccb4; + --color-red: #ff4c4c; + --color-brown: #3b3b3b; + + --color-n1: #e6e9f0; + --color-n2: #ced3e0; + --color-n3: #b6c0cf; + --color-n4: #6f7987; + --color-n5: #363840; + --color-n6: #202126; + --color-n7: #050505; + --color-n8: #060606; + + --color-shade1: #162d38; + --color-shade2: #122530; + --color-shade3: #122129; + --color-shade4: #121e24; + --color-shade5: #0f191f; + --color-shade6: #0e161a; + --color-shade7: #0b1114; + --color-shade8: #0b0e12; + + --color-border: var(--color-shade4); + --color-text: var(--color-n1); + + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body { + font-family: var(--font-inter); + color: var(--color-white); + background: var(--color-shade7); +} + +body::before { + content: none; +} + +/* Compact scrollbars matching the SpacetimeDB dashboard. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--color-shade4) var(--color-shade7); +} +*::-webkit-scrollbar { + width: 4px; + height: 4px; +} +*::-webkit-scrollbar-track { + background: transparent; +} +*::-webkit-scrollbar-thumb { + background: var(--color-shade4); + border-radius: 2px; +} +*::-webkit-scrollbar-thumb:hover { + background: var(--color-shade3); +} +*::-webkit-scrollbar-corner { + background: var(--color-shade7); +} + +.shell { + width: min(1320px, calc(100% - 32px)); + margin: 14px auto 30px; +} + +.topnav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 1px solid #17303b; + border-radius: var(--radius-lg); + background: linear-gradient(180deg, #0d1920, #0b1319); + padding: 9px 12px; + box-shadow: inset 0 1px 0 #26435166; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.brand-wordmark { + display: block; + height: 28px; +} + +.brand-sub { + padding: 2px 7px; + border: 1px solid #2a4250; + border-radius: 999px; + font-family: var(--font-ibm); + font-size: 10px; + color: #9cb1cb; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.topnav-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.pill-link, +.meta-pill { + border: 1px solid #27414e; + border-radius: 999px; + padding: 4px 10px; + font-family: var(--font-ibm); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #c6d1e1; +} + +.meta-pill.good { + border-color: #31684c; + color: var(--color-green); +} + +.pill-link { + text-decoration: none; + transition: + border-color 120ms ease, + color 120ms ease; +} + +.pill-link:hover { + border-color: #3b6173; + color: #e6edf7; +} + +.cart-button { + width: auto; + border: 1px solid #2f5363; + border-radius: 999px; + background: #112433; + color: #d4deea; + padding: 7px 12px; + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.icon-button { + width: 34px; + height: 34px; + border: 1px solid #2f5363; + border-radius: 999px; + background: #112433; + color: #d4deea; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.icon-button svg { + width: 16px; + height: 16px; + fill: currentColor; +} + +.subbar { + margin-top: 10px; + border: 1px solid #1a3541; + border-radius: 12px; + background: linear-gradient(180deg, #10202bde, #0d1720de); + padding: 10px 12px; +} + +.status-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.status-pill { + border: 1px solid #27414e; + border-radius: 999px; + padding: 5px 11px; + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #c4d0e0; +} + +.status-pill.good { + border-color: #2f644a; + color: var(--color-green); +} + +.status-pill.warn { + border-color: #5b5737; + color: var(--color-yellow); +} + +.layout { + margin-top: 10px; + display: grid; + grid-template-columns: 1fr; + gap: 14px; +} + +.panel { + border: 1px solid #17313d; + border-radius: 16px; + background: linear-gradient(180deg, #0f1d27e6, #0c161dde); + box-shadow: inset 0 1px 0 #2a435033; + padding: 14px; +} + +.panel h1, +.panel h2 { + margin: 0; + font-size: 31px; + letter-spacing: -0.03em; + line-height: 1.1; + font-weight: 500; +} + +.panel-sub { + margin: 5px 0 12px; + color: var(--color-n3); + font-size: 17px; +} + +.catalog-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.product-card { + border: 1px solid #203846; + border-radius: var(--radius-lg); + background: linear-gradient(180deg, #111f29, #0d1820); + padding: 12px; + display: flex; + flex-direction: column; + transition: + border-color 100ms ease, + transform 100ms ease, + box-shadow 100ms ease; +} + +.product-card:hover { + border-color: #2f5363; + transform: translateY(-1px); + box-shadow: 0 10px 22px #0000002e; +} + +.product-row { + display: flex; + flex-direction: column; + gap: 10px; + align-items: stretch; + height: 100%; +} + +.product-media { + height: 96px; + border: 1px solid #264353; + border-radius: 10px; + background: + radial-gradient(circle at 24% 22%, #4cf49044, transparent 45%), + radial-gradient(circle at 75% 78%, #02befa33, transparent 44%), + linear-gradient(140deg, #112738, #0f1d29); +} + +.product-head { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; +} + +.badge-row { + display: flex; + align-items: center; + gap: 6px; +} + +.product-copy { + display: grid; + gap: 6px; +} + +.product-rating { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: #a8bdd4; + min-height: 18px; +} + +.product-stars { + color: #f59e0b; + letter-spacing: 0.04em; +} + +.card-actions { + display: flex; + align-items: center; + justify-content: flex-end; + margin-top: auto; + padding-top: 10px; + border-top: 1px solid #1b3140; +} + +.btn.add-cart { + width: auto; + min-width: 140px; + background: linear-gradient(90deg, #4cf490, #02befa); + border-color: #4cf490; + color: #03131b; + font-weight: 700; +} + +.card-actions .btn { + font-size: 11px; + padding: 9px 10px; +} + +.card-stepper { + display: inline-flex; + align-items: center; + border: 2px solid #2f664d; + border-radius: 999px; + overflow: hidden; + background: #0f1f2a; + min-height: 40px; +} + +.stepper-btn { + width: 40px; + height: 36px; + border: 0; + background: transparent; + color: #d6e5f6; + font-size: 22px; + line-height: 1; + cursor: pointer; +} + +.stepper-btn:hover { + background: #16303d; +} + +.stepper-btn.is-trash { + font-size: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.stepper-btn .trash-icon { + width: 15px; + height: 15px; + stroke: #e8f1fb; + fill: none; + stroke-width: 1.9; + stroke-linecap: round; + stroke-linejoin: round; +} + +.stepper-count { + padding: 0 14px; + font-family: var(--font-ibm); + font-size: 12px; + letter-spacing: 0.04em; + color: #d6e5f6; +} + +.card-stepper.bump { + animation: stepper-bump 220ms ease; +} + +@keyframes stepper-bump { + 0% { + transform: scale(1); + } + 45% { + transform: scale(1.04); + } + 100% { + transform: scale(1); + } +} + +.cart-stepper .stepper-count { + min-width: 98px; + text-align: center; +} + +.card-top { + display: flex; + justify-content: flex-start; + align-items: baseline; + gap: 8px; + margin-top: 0; +} + +.merch-badge { + border: 1px solid #2f4f5f; + border-radius: 999px; + padding: 2px 8px; + font-family: var(--font-ibm); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #9fb2c8; + width: fit-content; +} + +.merch-badge.sale { + border-color: #3b5c41; + color: #7ee6ad; + background: #4cf49014; +} + +.merch-badge.mode { + border-color: #2d4b5a; + color: #9fb2c8; + background: #10212b; +} + +.price-label { + font-size: 24px; + letter-spacing: -0.03em; + font-weight: 700; + line-height: 1; +} + +.price-stack { + display: inline-flex; + align-items: baseline; + gap: 10px; + text-align: left; + flex-wrap: wrap; +} + +.price-compare { + margin-top: 0; + font-size: 13px; + color: #8ea3bc; +} + +.price-compare s { + color: #7389a2; +} + +.product-title { + margin: 0; + font-size: 18px; + line-height: 1.25; + letter-spacing: -0.02em; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.product-description { + margin: 0; + color: var(--color-n3); + font-size: 12px; + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.form-row { + display: flex; + flex-direction: column; + gap: 5px; +} + +.form-row.full { + grid-column: 1 / -1; +} + +.help-text { + margin-top: 6px; + color: #88a5ba; + font-size: 12px; +} + +.admin-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +label { + color: #9eb2cb; + font-size: 13px; +} + +input, +button, +textarea { + width: 100%; + border-radius: 8px; + box-sizing: border-box; + font-family: inherit; +} + +input, +textarea { + border: 1px solid #213947; + background: #0a1217; + color: var(--color-white); + padding: 11px 12px; +} + +input:focus, +textarea:focus { + outline: 1px solid #2c4e5f; +} + +.summary { + margin-top: 10px; + border: 1px solid #203846; + border-radius: 12px; + background: #0d1720; + padding: 10px; +} + +.summary-title { + font-size: 14px; + color: var(--color-n2); +} + +.summary-value { + margin-top: 4px; + font-size: 26px; + letter-spacing: -0.03em; +} + +.summary-meta { + margin-top: 6px; + color: #9ab0cb; + font-family: var(--font-ibm); + font-size: 12px; +} + +.cart-meta { + margin-top: 4px; + color: #9ab0cb; + font-family: var(--font-ibm); + font-size: 11px; +} + +.shop-actions { + margin-top: 12px; + display: flex; + gap: 8px; +} + +.price-status { + margin-top: 8px; + display: inline-flex; + align-items: center; + gap: 7px; + border-radius: 999px; + padding: 4px 10px; + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + border: 1px solid #2f4552; + color: #b8c7d8; +} + +.price-status.good { + border-color: #336b50; + color: var(--color-green); +} + +.price-status.bad { + border-color: #65434f; + color: var(--color-orange); +} + +.price-status.warn { + border-color: #5e5b40; + color: var(--color-yellow); +} + +.actions { + display: grid; + gap: 8px; + margin-top: 10px; +} + +.action-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.btn { + border: 1px solid #2c4957; + border-radius: 9px; + padding: 11px 12px; + cursor: pointer; + font-family: var(--font-ibm); + font-size: 12px; + letter-spacing: 0.11em; + text-transform: uppercase; + transition: + filter 120ms ease, + transform 80ms ease; +} + +.btn:active { + transform: translateY(1px); +} + +/* .btn.primary matches spacetimedb.com Button.module.css: + n3 bg, n8 text, white on hover, green on active, green focus outline. */ +.btn.primary { + background: var(--color-n3); + border: 2px solid var(--color-n3); + color: var(--color-n8); + font-weight: 600; +} +.btn.primary:hover:not(:disabled) { + background: var(--color-white); + border-color: var(--color-white); + color: var(--color-n8); + filter: none; +} +.btn.primary:active:not(:disabled) { + background: var(--color-green); + border-color: var(--color-green); +} +.btn.primary:focus-visible { + outline: 2px solid var(--color-green); + outline-offset: 2px; +} + +.btn.secondary { + background: transparent; + color: #d2dce8; +} + +.btn.ghost { + background: #101f29; + color: #ccd8e8; +} + +.btn:hover { + filter: brightness(1.08); +} + +.btn:focus-visible { + outline: 2px solid var(--color-green); + outline-offset: 2px; +} + +.btn:disabled { + opacity: 0.56; + cursor: not-allowed; +} + +.dev-tools { + padding: 10px; +} + +.dev-tools .form-grid { + margin-bottom: 8px; +} + +textarea { + min-height: 170px; + resize: vertical; + font-family: var(--font-ibm); + font-size: 12px; + line-height: 1.35; +} + +#errorLine { + position: fixed; + top: 18px; + left: 50%; + transform: translateX(-50%); + z-index: 80; + pointer-events: none; + max-width: min(420px, calc(100% - 44px)); +} +#errorLine:empty { + display: none; +} +.error-line-msg { + pointer-events: auto; + padding: 10px 14px; + border-radius: var(--radius-lg); + font-size: 13px; + font-family: var(--font-ibm); + color: var(--color-orange); + background: rgba(255, 158, 158, 0.08); + border: 1px solid rgba(255, 158, 158, 0.45); + box-shadow: 0 16px 36px rgba(0, 0, 0, 0.5); + animation: errorLineSlideIn 180ms ease; + cursor: pointer; +} +@keyframes errorLineSlideIn { + from { + opacity: 0; + transform: translateY(-12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.checkout-banner { + position: fixed; + left: 50%; + bottom: 22px; + transform: translate(-50%, 24px); + opacity: 0; + pointer-events: none; + z-index: 70; + max-width: min(560px, calc(100% - 32px)); + padding: 12px 18px; + border-radius: 12px; + font-size: 14px; + color: var(--color-white); + background: linear-gradient(180deg, #0f2a35, #0c1f28); + border: 1px solid #1c4856; + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + gap: 12px; + transition: + transform 220ms ease, + opacity 220ms ease; +} +.checkout-banner.is-visible { + opacity: 1; + transform: translate(-50%, 0); + pointer-events: auto; +} +.checkout-banner.success { + border-color: #2f7d52; + box-shadow: + 0 18px 40px rgba(0, 0, 0, 0.5), + 0 0 0 1px #2f7d5240; +} +.checkout-banner.success .checkout-banner-dot { + background: var(--color-green); + box-shadow: 0 0 0 4px rgba(75, 244, 144, 0.18); +} +.checkout-banner.canceled { + border-color: #6a4f1c; +} +.checkout-banner.canceled .checkout-banner-dot { + background: var(--color-yellow); + box-shadow: 0 0 0 4px rgba(251, 220, 142, 0.18); +} +.checkout-banner-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex: none; +} +.checkout-banner-text { + flex: 1 1 auto; + min-width: 0; + line-height: 1.45; +} +.checkout-banner-text strong { + font-weight: 600; +} +.checkout-banner button.checkout-banner-close { + flex: 0 0 auto; + width: 26px; + height: 26px; + padding: 0; + background: transparent; + border: 1px solid #2a4250; + color: var(--color-n3); + font-size: 14px; + line-height: 1; + border-radius: 6px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} +.checkout-banner button.checkout-banner-close:hover { + border-color: #3a5666; + color: var(--color-white); +} + +.dev-tools-backdrop { + position: fixed; + inset: 0; + background: #04080bb8; + backdrop-filter: blur(2px); + z-index: 50; +} + +.dev-tools-popout { + position: fixed; + top: 0; + right: 0; + width: min(540px, 100%); + height: 100vh; + z-index: 60; + border-left: 1px solid #1f3845; + background: linear-gradient(180deg, #0f1d27f7, #0c161df5); + box-shadow: -16px 0 40px #00000066; + display: flex; + flex-direction: column; +} + +.dev-tools-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + border-bottom: 1px solid #1f3845; +} + +.dev-tools-title { + font-family: var(--font-ibm); + text-transform: uppercase; + letter-spacing: 0.09em; + font-size: 12px; + color: #9eb3cd; +} + +.dev-tools-close { + width: auto; + padding: 6px 10px; + font-size: 11px; +} + +.dev-tools-body { + overflow-y: auto; + padding: 2px 0 12px; +} + +.dev-tools-backdrop[hidden], +.dev-tools-popout[hidden] { + display: none; +} + +.cart-backdrop { + position: fixed; + inset: 0; + background: #04080bb8; + backdrop-filter: blur(2px); + z-index: 40; +} + +.cart-popout { + position: fixed; + top: 0; + right: 0; + width: min(560px, 100%); + height: 100vh; + z-index: 45; + border-left: 1px solid #1f3845; + background: linear-gradient(180deg, #0f1d27f7, #0c161df5); + box-shadow: -16px 0 40px #00000066; + display: flex; + flex-direction: column; +} + +.cart-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + border-bottom: 1px solid #1f3845; +} + +.cart-title { + font-family: var(--font-ibm); + text-transform: uppercase; + letter-spacing: 0.09em; + font-size: 12px; + color: #9eb3cd; +} + +.cart-body { + padding: 12px; + height: 100%; + display: flex; + flex-direction: column; + gap: 10px; +} + +.cart-list { + display: grid; + gap: 8px; + flex: 1; + overflow-y: auto; + padding-right: 2px; + align-content: start; + grid-auto-rows: max-content; +} + +.cart-item { + border: 1px solid #1f3845; + border-radius: 10px; + background: #0d1820; + padding: 9px; +} + +.cart-item-top { + display: grid; + grid-template-columns: 44px 1fr auto; + gap: 10px; + align-items: center; +} + +.cart-item-thumb { + width: 44px; + height: 44px; + border-radius: 10px; + border: 1px solid #2a4757; + background: radial-gradient( + circle at 30% 28%, + #4cf49066, + #02befa2b 55%, + #0f1920 100% + ); +} + +.cart-item-content { + min-width: 0; +} + +.cart-item-name { + font-size: 16px; + font-weight: 600; + line-height: 1.2; +} + +.cart-item-meta { + font-family: var(--font-ibm); + font-size: 11px; + color: #9eb3cd; + margin-top: 4px; +} + +.cart-item-sub { + font-family: var(--font-ibm); + font-size: 11px; + color: #b8c6d7; + margin-top: 4px; +} + +.cart-item-controls { + justify-self: end; +} + +.cart-item-top .btn { + width: auto; + padding: 6px 10px; + font-size: 11px; +} + +.cart-empty { + border: 1px dashed #2d4656; + border-radius: 10px; + padding: 18px 14px; + color: #98abc2; + min-height: 180px; + display: grid; + align-content: center; + justify-items: center; + text-align: center; + gap: 8px; +} + +.cart-empty-title { + font-size: 18px; + letter-spacing: -0.02em; + color: #d7d8d9; +} + +.cart-empty-copy { + font-size: 13px; + color: #9ab0cb; + max-width: 260px; +} + +.cart-footer { + border-top: 1px solid #1f3845; + padding-top: 10px; + display: grid; + gap: 10px; +} + +.cart-summary { + display: flex; + align-items: center; + justify-content: space-between; + font-family: var(--font-ibm); + font-size: 11px; + color: #9ab0cb; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.cart-backdrop[hidden], +.cart-popout[hidden] { + display: none; +} + +.loading-card { + grid-column: 1 / -1; + border: 1px dashed #2d4656; + border-radius: 12px; + padding: 14px; + color: #98abc2; + font-family: var(--font-ibm); +} + +.skeleton-card { + border: 1px solid #203846; + border-radius: var(--radius-lg); + background: linear-gradient(180deg, #111f29, #0d1820); + padding: 12px; + display: grid; + gap: 8px; +} + +.skeleton-line { + border-radius: 7px; + background: linear-gradient(100deg, #132735 0%, #1c3342 38%, #132735 76%); + background-size: 220% 100%; + animation: skeleton-shimmer 1.25s linear infinite; +} + +.skeleton-media { + height: 96px; + border-radius: 10px; +} + +.skeleton-line.badges { + height: 18px; + width: 44%; +} + +.skeleton-line.title { + height: 22px; + width: 76%; +} + +.skeleton-line.desc { + height: 14px; + width: 92%; +} + +.skeleton-line.meta { + height: 14px; + width: 50%; +} + +.skeleton-line.price { + height: 28px; + width: 40%; +} + +.skeleton-line.actions { + height: 40px; + width: 100%; + margin-top: 8px; +} + +@keyframes skeleton-shimmer { + from { + background-position: 220% 0; + } + to { + background-position: -30% 0; + } +} + +@media (max-width: 980px) { + .layout { + grid-template-columns: 1fr; + } + .catalog-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 680px) { + .shell { + width: calc(100% - 20px); + } + .catalog-grid { + grid-template-columns: 1fr; + } + .form-grid { + grid-template-columns: 1fr; + } + .brand-wordmark { + height: 24px; + } + .product-media { + height: 120px; + } + .card-actions { + gap: 6px; + } +} diff --git a/spacetime-stripe-ts/example/public/ui.js b/spacetime-stripe-ts/example/public/ui.js new file mode 100644 index 00000000000..00c4e802500 --- /dev/null +++ b/spacetime-stripe-ts/example/public/ui.js @@ -0,0 +1,766 @@ +const STORAGE_KEYS = { + cart: 'stdb.premiumStore.cart.v1', +}; + +const state = { + catalog: [], + cart: [], + checkoutPending: false, + animatedStepperKey: null, +}; + +const byId = id => document.getElementById(id); + +const HTML_ESCAPE = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, character => HTML_ESCAPE[character]); +} + +const ui = { + btnCart: byId('btnCart'), + btnSettings: byId('btnSettings'), + cartBackdrop: byId('cartBackdrop'), + cartPopout: byId('cartPopout'), + btnCartClose: byId('btnCartClose'), + btnContinueShopping: byId('btnContinueShopping'), + cartList: byId('cartList'), + cartSummary: byId('cartSummary'), + catalogGrid: byId('catalogGrid'), + email: byId('email'), + name: byId('name'), + userId: byId('userId'), + customerId: byId('customerId'), + btnCheckoutCart: byId('btnCheckoutCart'), + btnDevToolsClose: byId('btnDevToolsClose'), + btnCustomer: byId('btnCustomer'), + devToolsBackdrop: byId('devToolsBackdrop'), + devToolsPopout: byId('devToolsPopout'), + errorLine: byId('errorLine'), + log: byId('log'), + checkoutBanner: byId('checkoutBanner'), + checkoutBannerText: byId('checkoutBannerText'), + checkoutBannerClose: byId('checkoutBannerClose'), +}; + +let errorLineTimer = null; +function setError(message) { + if (errorLineTimer) { + clearTimeout(errorLineTimer); + errorLineTimer = null; + } + if (!message) { + ui.errorLine.innerHTML = ''; + return; + } + ui.errorLine.innerHTML = ''; + const pill = document.createElement('div'); + pill.className = 'error-line-msg'; + pill.textContent = message; + pill.addEventListener('click', () => setError('')); + ui.errorLine.appendChild(pill); + errorLineTimer = setTimeout(() => setError(''), 6000); +} + +let checkoutBannerTimer = null; +function showCheckoutBanner(kind, message) { + if (!ui.checkoutBanner) return; + ui.checkoutBanner.hidden = false; + ui.checkoutBanner.classList.remove('success', 'canceled'); + ui.checkoutBanner.classList.add(kind); + ui.checkoutBannerText.innerHTML = message; + void ui.checkoutBanner.offsetWidth; + ui.checkoutBanner.classList.add('is-visible'); + if (checkoutBannerTimer) clearTimeout(checkoutBannerTimer); + checkoutBannerTimer = setTimeout(hideCheckoutBanner, 8000); +} +function hideCheckoutBanner() { + if (!ui.checkoutBanner) return; + ui.checkoutBanner.classList.remove('is-visible'); + if (checkoutBannerTimer) { + clearTimeout(checkoutBannerTimer); + checkoutBannerTimer = null; + } +} +function consumePostCheckoutQueryFlags() { + const url = new URL(window.location.href); + const purchased = url.searchParams.get('purchased') === '1'; + const canceled = url.searchParams.get('canceled') === '1'; + if (!purchased && !canceled) return; + + if (purchased) { + state.cart = []; + state.animatedStepperKey = null; + persistCart(); + showCheckoutBanner( + 'success', + 'Checkout complete. Stripe confirmed the session.' + ); + } else { + showCheckoutBanner( + 'canceled', + 'Checkout canceled. No charge was made. Your cart is still here.' + ); + } + url.searchParams.delete('purchased'); + url.searchParams.delete('canceled'); + window.history.replaceState({}, '', url.toString()); +} + +function writeLog(message) { + const time = new Date().toLocaleTimeString(); + ui.log.value = `[${time}] ${message}\n\n` + ui.log.value; +} + +function setButtonLoading(button, isLoading, loadingText = 'Loading...') { + if (!button) return; + if (isLoading) { + if (!button.dataset.originalText) { + button.dataset.originalText = button.textContent || ''; + } + button.textContent = loadingText; + button.disabled = true; + return; + } + + if (button.dataset.originalText) { + button.textContent = button.dataset.originalText; + delete button.dataset.originalText; + } + button.disabled = false; +} + +function closeDevTools() { + ui.devToolsBackdrop.hidden = true; + ui.devToolsPopout.hidden = true; +} + +function openDevTools() { + ui.devToolsBackdrop.hidden = false; + ui.devToolsPopout.hidden = false; +} + +function openCart() { + ui.cartBackdrop.hidden = false; + ui.cartPopout.hidden = false; +} + +function missingPriceMessage(productName) { + return `Missing Stripe price ID for ${productName}. Set STRIPE_SYNC_PRICES=1 and restart the example server.`; +} + +function closeCart() { + ui.cartBackdrop.hidden = true; + ui.cartPopout.hidden = true; +} + +function parsePositiveInteger(value, fallback = 1) { + const parsed = Number.parseInt(String(value ?? ''), 10); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return parsed; +} + +function parseAmountFromPriceLabel(priceLabel) { + const text = String(priceLabel ?? ''); + const match = text.match(/-?\d[\d,]*(?:\.\d{1,2})?/); + if (!match) return 0; + const normalized = match[0].replace(/,/g, ''); + const parsed = Number.parseFloat(normalized); + return Number.isFinite(parsed) ? parsed : 0; +} + +function getMerchandising(item) { + const byId = { + 'orbital-starter-pack': { + badge: 'Best Seller', + rating: 4.8, + reviews: 214, + }, + 'warp-pass': { + badge: 'Limited Offer', + compareAt: 12, + rating: 4.6, + reviews: 129, + }, + 'fleet-command-bundle': { rating: 4.7, reviews: 88 }, + }; + return byId[item.id] || null; +} + +function formatUsd(amount) { + try { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount); + } catch { + return `$${amount.toFixed(2)}`; + } +} + +async function api(path, payload) { + const response = await fetch(path, { + method: payload ? 'POST' : 'GET', + headers: payload ? { 'content-type': 'application/json' } : undefined, + body: payload ? JSON.stringify(payload) : undefined, + }); + const data = await response.json(); + if (!response.ok || data.ok === false) { + throw new Error(data.error || `Request failed (${response.status})`); + } + return data; +} + +function buildPostCheckoutUrl(flag) { + const url = new URL(window.location.origin); + url.searchParams.set(flag, '1'); + return url.toString(); +} + +function makeDefaultUserId() { + return `pilot_${crypto.randomUUID().slice(0, 8)}`; +} + +function seedDefaultBuyerDetails() { + ui.email.value = 'pilot@spacetime.dev'; + ui.name.value = 'Orbital Pilot'; + ui.userId.value = makeDefaultUserId(); +} + +function hydrateCart() { + try { + const raw = localStorage.getItem(STORAGE_KEYS.cart); + if (!raw) return; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return; + + state.cart = parsed + .map(item => ({ + id: String(item?.id || ''), + name: String(item?.name || ''), + priceLabel: String(item?.priceLabel || ''), + mode: item?.mode === 'subscription' ? 'subscription' : 'payment', + priceId: String(item?.priceId || ''), + quantity: parsePositiveInteger(item?.quantity, 1), + })) + .filter(item => item.id && item.name && item.priceId); + } catch { + state.cart = []; + } +} + +function persistCart() { + try { + localStorage.setItem(STORAGE_KEYS.cart, JSON.stringify(state.cart)); + } catch { + // Storage may be unavailable in private windows. + } +} + +function getCartEntry(itemId, priceId) { + return state.cart.find( + entry => entry.id === itemId && entry.priceId === priceId + ); +} + +function getCartQuantity(itemId, priceId) { + const entry = getCartEntry(itemId, priceId); + return entry ? entry.quantity : 0; +} + +function getCartKey(itemId, priceId) { + return `${itemId}::${priceId}`; +} + +function syncCartState() { + const count = state.cart.length; + const totalQty = state.cart.reduce((sum, item) => sum + item.quantity, 0); + const oneTimeSubtotal = state.cart.reduce((sum, item) => { + if (item.mode !== 'payment') return sum; + return sum + parseAmountFromPriceLabel(item.priceLabel) * item.quantity; + }, 0); + const recurringMonthly = state.cart.reduce((sum, item) => { + if (item.mode !== 'subscription') return sum; + return sum + parseAmountFromPriceLabel(item.priceLabel) * item.quantity; + }, 0); + + const cartModes = new Set(state.cart.map(entry => entry.mode)); + const isMixedMode = cartModes.size > 1; + const hasMissingPrice = state.cart.some(entry => !entry.priceId); + if (state.checkoutPending) { + ui.btnCheckoutCart.disabled = true; + ui.btnCheckoutCart.textContent = 'Redirecting to Stripe...'; + } else if (hasMissingPrice) { + ui.btnCheckoutCart.disabled = true; + ui.btnCheckoutCart.textContent = 'Sync Stripe prices first'; + } else if (isMixedMode) { + ui.btnCheckoutCart.disabled = true; + ui.btnCheckoutCart.textContent = "Can't mix one-time + subscription"; + } else { + ui.btnCheckoutCart.disabled = count === 0; + ui.btnCheckoutCart.textContent = + totalQty > 0 + ? `Checkout with Stripe (${totalQty})` + : 'Checkout with Stripe'; + } + if (ui.btnCart) ui.btnCart.textContent = `Cart (${totalQty})`; + if (ui.cartSummary) { + ui.cartSummary.innerHTML = + `Subtotal: ${formatUsd(oneTimeSubtotal)}` + + `Recurring: ${formatUsd(recurringMonthly)}/mo`; + } + persistCart(); + renderCartList(); + if (state.catalog.length > 0) renderCatalog(); +} + +function renderCartList() { + if (state.cart.length === 0) { + ui.cartList.innerHTML = ` +
+
Your cart is empty
+
Add items from the storefront to start checkout.
+
+ `; + return; + } + + ui.cartList.innerHTML = state.cart + .map(item => { + const unitPrice = parseAmountFromPriceLabel(item.priceLabel); + const lineTotal = unitPrice * item.quantity; + const cartKey = getCartKey(item.id, item.priceId); + const itemId = escapeHtml(item.id); + const itemName = escapeHtml(item.name); + const priceId = escapeHtml(item.priceId); + const stepperClass = + state.animatedStepperKey === cartKey + ? ' card-stepper bump cart-stepper' + : ' card-stepper cart-stepper'; + return ` +
+
+ +
+
${itemName}
+
${item.mode === 'subscription' ? 'Subscription' : 'One-time payment'}
+
${formatUsd(unitPrice)} x ${item.quantity} = ${formatUsd(lineTotal)}${item.mode === 'subscription' ? ' /mo' : ''}
+
+
+
+ +
${item.quantity} in cart
+ +
+
+
+
+ `; + }) + .join(''); + + for (const button of ui.cartList.querySelectorAll('.js-cart-plus')) { + button.addEventListener('click', () => { + const productId = button.getAttribute('data-product-id') || ''; + const priceId = button.getAttribute('data-price-id') || ''; + const item = state.catalog.find( + product => product.id === productId && product.priceId === priceId + ); + if (!item) return; + addItemToCart(item, 1); + }); + } + + for (const button of ui.cartList.querySelectorAll('.js-cart-minus')) { + button.addEventListener('click', () => { + const productId = button.getAttribute('data-product-id') || ''; + const priceId = button.getAttribute('data-price-id') || ''; + const index = state.cart.findIndex( + entry => entry.id === productId && entry.priceId === priceId + ); + if (index < 0) return; + state.cart[index].quantity -= 1; + if (state.cart[index].quantity <= 0) { + const removed = state.cart.splice(index, 1)[0]; + if (removed) + writeLog(`cart_remove: ${removed.id} qty=${removed.quantity}`); + } + syncCartState(); + }); + } +} + +function renderCatalog() { + if (state.catalog.length === 0) { + ui.catalogGrid.innerHTML = + '
No catalog items found.
'; + return; + } + + const cards = state.catalog + .map(item => { + const merch = getMerchandising(item); + const unitPrice = parseAmountFromPriceLabel(item.priceLabel); + const compareAt = + merch?.compareAt && merch.compareAt > unitPrice + ? merch.compareAt + : null; + const purchaseType = + item.mode === 'subscription' ? 'Subscription' : 'One-time'; + const rating = merch?.rating ?? 4.7; + const reviews = merch?.reviews ?? 100; + const stars = '★★★★★'; + const inCartQty = getCartQuantity(item.id, item.priceId); + const cartKey = getCartKey(item.id, item.priceId); + const stepperClass = + state.animatedStepperKey === cartKey + ? 'card-stepper bump' + : 'card-stepper'; + const isMissingPrice = !item.priceId; + const itemId = escapeHtml(item.id); + const itemName = escapeHtml(item.name); + const itemDescription = escapeHtml(item.description); + const priceId = escapeHtml(item.priceId); + const priceLabel = escapeHtml(item.priceLabel); + const badge = merch?.badge ? escapeHtml(merch.badge) : ''; + return ` +
+
+ +
+
+ ${badge ? `${badge}` : ''} + ${purchaseType} +
+
+

${itemName}

+

${itemDescription}

+
+
${rating.toFixed(1)}${stars}(${reviews})
+
+
+
${priceLabel}
+ ${compareAt ? `
List: ${formatUsd(compareAt)}
` : ''} +
+
+
+ ${ + inCartQty > 0 + ? ` +
+ +
${inCartQty} in cart
+ +
+ ` + : isMissingPrice + ? '' + : `` + } +
+
+
+
+ `; + }) + .join(''); + + ui.catalogGrid.innerHTML = cards; + for (const button of ui.catalogGrid.querySelectorAll('.js-add-to-cart')) { + button.addEventListener('click', async () => { + const productId = button.getAttribute('data-product-id') || ''; + const item = state.catalog.find(product => product.id === productId); + if (!item) return; + setError(''); + addItemToCart(item, 1); + await validatePriceForItem(item).catch(error => setError(error.message)); + }); + } + + for (const button of ui.catalogGrid.querySelectorAll('.js-card-plus')) { + button.addEventListener('click', async () => { + const productId = button.getAttribute('data-product-id') || ''; + const priceId = button.getAttribute('data-price-id') || ''; + const item = state.catalog.find( + product => product.id === productId && product.priceId === priceId + ); + if (!item) return; + setError(''); + addItemToCart(item, 1); + await validatePriceForItem(item).catch(error => setError(error.message)); + }); + } + + for (const button of ui.catalogGrid.querySelectorAll('.js-card-minus')) { + button.addEventListener('click', () => { + const productId = button.getAttribute('data-product-id') || ''; + const priceId = button.getAttribute('data-price-id') || ''; + const index = state.cart.findIndex( + entry => entry.id === productId && entry.priceId === priceId + ); + if (index < 0) return; + state.cart[index].quantity -= 1; + if (state.cart[index].quantity <= 0) { + const removed = state.cart.splice(index, 1)[0]; + if (removed) + writeLog(`cart_remove: ${removed.id} qty=${removed.quantity}`); + } + syncCartState(); + }); + } +} + +function renderCatalogSkeleton(count = 6) { + const cards = Array.from( + { length: count }, + () => ` + + ` + ).join(''); + ui.catalogGrid.innerHTML = cards; +} + +window.addEventListener('stdb:catalog', event => { + const products = event.detail?.products ?? []; + const sorted = [...products].sort( + (a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) + ); + state.catalog = sorted; + const catalogById = new Map(sorted.map(item => [item.id, item])); + const previousCartCount = state.cart.length; + state.cart = state.cart + .filter(entry => { + const item = catalogById.get(entry.id); + return item && item.priceId && item.priceId === entry.priceId; + }) + .map(entry => { + const item = catalogById.get(entry.id); + return { + ...entry, + name: item.name, + priceLabel: item.priceLabel, + mode: item.mode, + }; + }); + if (state.cart.length !== previousCartCount) { + setError( + 'Your saved cart contained outdated Stripe prices and was refreshed.' + ); + } + syncCartState(); +}); + +window.addEventListener('stdb:connState', event => { + const detail = event.detail || {}; + if (detail.state === 'connected') { + writeLog('stdb: connected'); + return; + } + if (detail.state === 'error') { + const message = detail.detail || 'SpacetimeDB connection failed.'; + setError(message); + writeLog(`stdb: error: ${message}`); + return; + } + writeLog('stdb: connecting'); +}); + +async function validatePriceForItem(item) { + if (!item?.priceId) { + writeLog('price validation: missing price id'); + return; + } + if (!window.stdb) { + writeLog('price validation: STDB not connected yet'); + return; + } + const validation = await window.stdb.validatePrice(item.priceId); + if (validation.valid) { + writeLog(`price valid: ${item.priceId} (active=${validation.active})`); + } else { + writeLog(`price invalid: ${validation.message || 'not found'}`); + } +} + +async function getOrCreateCustomer() { + setError(''); + if (!window.stdb) { + throw new Error('STDB not connected yet. Try again.'); + } + const result = await window.stdb.getOrCreateCustomer({ + userId: ui.userId.value, + email: ui.email.value || undefined, + name: ui.name.value || undefined, + }); + if (result.customerId) ui.customerId.value = result.customerId; + writeLog(`get_or_create_customer: ${JSON.stringify(result)}`); +} + +async function createCheckoutForCart(cartItems, triggerButton) { + if (!cartItems || cartItems.length === 0) throw new Error('Cart is empty.'); + const missingPrice = cartItems.find(entry => !entry.priceId); + if (missingPrice) { + throw new Error(missingPriceMessage(missingPrice.name)); + } + + const modes = Array.from(new Set(cartItems.map(entry => entry.mode))); + if (modes.length > 1) { + throw new Error( + 'Stripe checkout cannot mix one-time and subscription items in the same session. ' + + 'Please remove one type and check out separately.' + ); + } + const mode = modes[0]; + + const successUrl = buildPostCheckoutUrl('purchased'); + const cancelUrl = buildPostCheckoutUrl('canceled'); + + state.checkoutPending = true; + syncCartState(); + setButtonLoading(triggerButton, true, 'Redirecting...'); + + try { + if (!window.stdb) { + throw new Error('STDB not connected yet. Try again.'); + } + const result = await window.stdb.createCheckoutSession({ + items: cartItems.map(entry => ({ + priceId: entry.priceId, + quantity: entry.quantity, + })), + customerId: ui.customerId.value || undefined, + mode, + successUrl, + cancelUrl, + }); + writeLog(`create_checkout_session: ${JSON.stringify(result)}`); + + if (!result.url) { + throw new Error('Stripe checkout URL missing from session response.'); + } + window.location.assign(result.url); + } finally { + setButtonLoading(triggerButton, false); + state.checkoutPending = false; + syncCartState(); + } +} + +function addItemToCart(item, quantity) { + if (!item) return; + if (!item.priceId) { + const message = missingPriceMessage(item.name); + setError(message); + writeLog(message); + return; + } + state.animatedStepperKey = getCartKey(item.id, item.priceId); + const existing = state.cart.find( + entry => entry.id === item.id && entry.priceId === item.priceId + ); + if (existing) { + existing.quantity += quantity; + } else { + state.cart.push({ + id: item.id, + name: item.name, + priceLabel: item.priceLabel, + mode: item.mode, + priceId: item.priceId, + quantity, + }); + } + syncCartState(); + writeLog(`cart_add: ${item.id} qty=${quantity}`); + setTimeout(() => { + if (state.animatedStepperKey === getCartKey(item.id, item.priceId)) { + state.animatedStepperKey = null; + if (state.catalog.length > 0) renderCatalog(); + } + }, 260); +} + +async function createCheckoutCartNext() { + setError(''); + if (state.cart.length === 0) throw new Error('Cart is empty.'); + for (const entry of state.cart) { + const item = state.catalog.find(product => product.id === entry.id); + if (!item) + throw new Error(`Cart item ${entry.id} is absent from the catalog.`); + } + await createCheckoutForCart(state.cart, ui.btnCheckoutCart); +} + +function wireEvents() { + if (ui.btnCart) ui.btnCart.addEventListener('click', openCart); + ui.btnCartClose.addEventListener('click', closeCart); + ui.btnContinueShopping.addEventListener('click', closeCart); + ui.cartBackdrop.addEventListener('click', closeCart); + + if (ui.btnSettings) { + ui.btnSettings.addEventListener('click', openDevTools); + } + + ui.btnCustomer.addEventListener('click', () => { + getOrCreateCustomer().catch(error => setError(error.message)); + }); + + ui.btnCheckoutCart.addEventListener('click', () => { + createCheckoutCartNext().catch(error => setError(error.message)); + }); + + ui.btnDevToolsClose.addEventListener('click', closeDevTools); + ui.devToolsBackdrop.addEventListener('click', closeDevTools); + + if (ui.checkoutBannerClose) { + ui.checkoutBannerClose.addEventListener('click', hideCheckoutBanner); + } + + document.addEventListener('keydown', event => { + const key = String(event.key || '').toLowerCase(); + if ((event.ctrlKey || event.metaKey) && event.shiftKey && key === 'd') { + event.preventDefault(); + openDevTools(); + return; + } + if (event.key === 'Escape' && !ui.cartPopout.hidden) { + closeCart(); + return; + } + if (event.key === 'Escape' && !ui.devToolsPopout.hidden) { + closeDevTools(); + } + }); +} + +async function boot() { + renderCatalogSkeleton(); + await api('/api/config'); + seedDefaultBuyerDetails(); + hydrateCart(); + wireEvents(); + consumePostCheckoutQueryFlags(); + syncCartState(); + if (state.catalog.length > 0) renderCatalog(); +} + +boot().catch(error => { + setError(error.message); + writeLog(`boot error: ${error.message}`); +}); diff --git a/spacetime-stripe-ts/example/server.ts b/spacetime-stripe-ts/example/server.ts new file mode 100644 index 00000000000..83dbdd58714 --- /dev/null +++ b/spacetime-stripe-ts/example/server.ts @@ -0,0 +1,419 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import express, { type Request, type Response } from 'express'; +import dotenv from 'dotenv'; +import { + discardStoredServerToken, + exampleUiAssetsDir, + grantServerIdentity, + loadServerToken, + saveServerToken, +} from '@spacetimedb/submodule-shared/server'; +import { + DbConnection, + tables, + type ErrorContext, +} from './src/module_bindings/app'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// App .env wins. Falls back to spacetime-stripe-ts/.env for package-level defaults. +dotenv.config({ path: path.resolve(__dirname, '.env') }); +dotenv.config({ path: path.resolve(__dirname, '..', '.env') }); + +const PORT = Number.parseInt(process.env.PORT ?? '8787', 10); +const HOST = process.env.HOST?.trim() || '127.0.0.1'; +const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; +const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; +const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-stripe-example'; +const NODE_ENV = (process.env.NODE_ENV ?? '').replace(/^['"]|['"]$/g, ''); +const IS_PRODUCTION = NODE_ENV === 'production'; +const SPACETIME_BIN = process.env.SPACETIME_BIN?.trim() || 'spacetime'; +const SYNC_PRICES_ON_START = process.env.STRIPE_SYNC_PRICES === '1'; +const IS_LOOPBACK_HOST = + HOST === '127.0.0.1' || HOST === 'localhost' || HOST === '::1'; +const ALLOW_BROWSER_PROVIDER_ACTIONS = + (!IS_PRODUCTION && IS_LOOPBACK_HOST) || + process.env.STRIPE_ALLOW_BROWSER_PROVIDER_ACTIONS === '1'; +const DEFAULT_RETURN_HOST = + HOST === '0.0.0.0' || HOST === '::' ? '127.0.0.1' : HOST; +const STRIPE_RETURN_BASE_URL = + process.env.STRIPE_RETURN_BASE_URL?.trim() || + `http://${DEFAULT_RETURN_HOST}:${PORT}`; +const SERVER_TOKEN_PATH = path.resolve(__dirname, '.stdb-server-token'); + +let stdb: DbConnection | null = null; +let stripeConfigured = false; + +type SyncPriceResult = { + productId: string; + priceId: string; + action: 'created' | 'linked' | 'kept'; +}; + +type ConnectedServer = { + connection: DbConnection; + identity: string; +}; + +class RequestError extends Error { + constructor( + readonly status: number, + message: string + ) { + super(message); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function requiredString( + value: unknown, + field: string, + maxLength: number +): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > maxLength + ) { + throw new RequestError(400, `invalid_${field}`); + } + return value; +} + +function optionalString( + value: unknown, + field: string, + maxLength: number +): string | undefined { + if (value === undefined || value === null || value === '') return undefined; + return requiredString(value, field, maxLength); +} + +function providerActionsReady(): void { + if (!ALLOW_BROWSER_PROVIDER_ACTIONS) { + throw new RequestError(403, 'browser_provider_actions_disabled'); + } + if (!stripeConfigured) throw new RequestError(503, 'stripe_not_configured'); +} + +function sendRouteError(route: string, error: unknown, res: Response): void { + if (error instanceof RequestError) { + res.status(error.status).json({ error: error.message }); + return; + } + console.error( + `[stripe] ${route} failed: ${error instanceof Error ? error.message : String(error)}` + ); + res.status(502).json({ error: `${route}_failed` }); +} + +function returnUrl(flag: 'purchased' | 'canceled'): string { + const url = new URL('/', STRIPE_RETURN_BASE_URL); + if (IS_PRODUCTION && url.protocol !== 'https:') { + throw new RequestError(500, 'stripe_return_url_requires_https'); + } + url.searchParams.set(flag, '1'); + return url.toString(); +} + +function connectAttempt(token: string | undefined): Promise { + return new Promise((resolve, reject) => { + let builder = DbConnection.builder() + .withUri(STDB_URI) + .withDatabaseName(DB_NAME) + .onConnect((connection, identity, nextToken) => { + if (!process.env.STDB_SERVER_TOKEN?.trim()) { + saveServerToken(SERVER_TOKEN_PATH, nextToken); + } + resolve({ connection, identity: identity.toHexString() }); + }) + .onDisconnect((_ctx, err) => { + console.error( + `[stdb] disconnected: ${err?.message ?? 'unknown'}. exiting` + ); + process.exit(1); + }) + .onConnectError((_ctx: ErrorContext, err) => reject(err)); + if (token) builder = builder.withToken(token); + builder.build(); + }); +} + +async function connect(): Promise { + const stored = loadServerToken( + SERVER_TOKEN_PATH, + process.env.STDB_SERVER_TOKEN + ); + try { + return await connectAttempt(stored.token); + } catch (error) { + if (stored.source !== 'file') throw error; + discardStoredServerToken(SERVER_TOKEN_PATH); + console.warn( + '[stdb] stored server token was rejected; creating a new identity' + ); + return connectAttempt(undefined); + } +} + +function requireStdb(): DbConnection { + if (!stdb) throw new Error('STDB not connected yet'); + return stdb; +} + +async function syncStripePricesFromCatalog(): Promise { + const raw = await requireStdb().procedures.syncStoreProductsWithStripe({}); + return JSON.parse(raw) as SyncPriceResult[]; +} + +async function configureStripeFromEnv(): Promise< + 'configured' | 'already-configured' +> { + const secretKey = process.env.STRIPE_SECRET_KEY?.trim(); + if (!secretKey) { + throw new Error( + 'STRIPE_SECRET_KEY is not set in spacetime-stripe-ts/example/.env.' + ); + } + + await requireStdb().procedures.configureStripe({ + secretKey, + stripeVersion: process.env.STRIPE_VERSION || undefined, + webhookSigningSecret: process.env.STRIPE_WEBHOOK_SECRET || undefined, + }); + stripeConfigured = true; + return 'configured'; +} + +const app = express(); +app.use(express.json({ limit: '512kb' })); +app.use('/assets', express.static(exampleUiAssetsDir, staticOptions())); +app.use(express.static(path.join(__dirname, 'public'), staticOptions())); + +function staticOptions() { + if (IS_PRODUCTION) return {}; + return { + etag: false, + maxAge: 0, + setHeaders: (res: Response) => { + res.setHeader('Cache-Control', 'no-store'); + }, + }; +} + +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ ok: true, databaseName: DB_NAME }); +}); + +app.get('/api/config', (_req: Request, res: Response) => { + const envStripeSecret = process.env.STRIPE_SECRET_KEY?.trim() ?? ''; + res.json({ + spacetimeUri: STDB_URI, + databaseName: DB_NAME, + hasStripeSecretKey: envStripeSecret.length > 0, + stripeConfigured, + adminEndpointsEnabled: false, + browserProviderActionsEnabled: ALLOW_BROWSER_PROVIDER_ACTIONS, + syncPricesOnStart: SYNC_PRICES_ON_START, + }); +}); + +app.post('/api/customer', async (req: Request, res: Response) => { + try { + providerActionsReady(); + if (!isRecord(req.body)) throw new RequestError(400, 'invalid_body'); + const userId = requiredString(req.body.userId, 'user_id', 128); + const email = optionalString(req.body.email, 'email', 320); + const name = optionalString(req.body.name, 'name', 200); + const result = await requireStdb().procedures.getOrCreateStoreCustomer({ + userId, + email, + name, + }); + res.json(result); + } catch (error) { + sendRouteError('customer', error, res); + } +}); + +app.post('/api/checkout', async (req: Request, res: Response) => { + try { + providerActionsReady(); + if (!isRecord(req.body) || !Array.isArray(req.body.items)) { + throw new RequestError(400, 'invalid_body'); + } + if (req.body.items.length === 0 || req.body.items.length > 20) { + throw new RequestError(400, 'invalid_items'); + } + + const mode = requiredString(req.body.mode, 'mode', 32); + if (mode !== 'payment' && mode !== 'subscription') { + throw new RequestError(400, 'invalid_mode'); + } + const customerId = optionalString(req.body.customerId, 'customer_id', 255); + if (customerId && !/^cus_[A-Za-z0-9]+$/.test(customerId)) { + throw new RequestError(400, 'invalid_customer_id'); + } + + const catalog = [...requireStdb().db.storeProduct.iter()]; + const items = req.body.items.map((value, index) => { + if (!isRecord(value)) + throw new RequestError(400, `invalid_item_${index}`); + const priceId = requiredString(value.priceId, `price_id_${index}`, 255); + const quantity = value.quantity; + if ( + typeof quantity !== 'number' || + !Number.isSafeInteger(quantity) || + quantity < 1 || + quantity > 99 + ) { + throw new RequestError(400, `invalid_quantity_${index}`); + } + const product = catalog.find( + row => row.active && row.stripePriceId === priceId && row.mode === mode + ); + if (!product) + throw new RequestError(400, `price_not_in_active_catalog_${index}`); + return { priceId, quantity: BigInt(quantity) }; + }); + + const result = await requireStdb().procedures.createStoreCheckoutSession({ + items, + customerId, + mode, + successUrl: returnUrl('purchased'), + cancelUrl: returnUrl('canceled'), + metadataJson: undefined, + subscriptionMetadataJson: undefined, + paymentIntentMetadataJson: undefined, + }); + res.json(result); + } catch (error) { + sendRouteError('checkout', error, res); + } +}); + +app.post('/api/validate-price', async (req: Request, res: Response) => { + try { + providerActionsReady(); + if (!isRecord(req.body)) throw new RequestError(400, 'invalid_body'); + const priceId = requiredString(req.body.priceId, 'price_id', 255); + const inCatalog = [...requireStdb().db.storeProduct.iter()].some( + row => row.active && row.stripePriceId === priceId + ); + if (!inCatalog) throw new RequestError(400, 'price_not_in_active_catalog'); + const result = await requireStdb().procedures.validateStoreStripePrice({ + priceId, + }); + res.json({ + ...result, + unitAmount: + result.unitAmount === undefined ? undefined : Number(result.unitAmount), + }); + } catch (error) { + sendRouteError('validate_price', error, res); + } +}); + +app.get('/api/webhook-event-count', async (_req: Request, res: Response) => { + try { + providerActionsReady(); + const count = await requireStdb().procedures.getStoreWebhookEventCount({}); + res.json({ count: Number(count) }); + } catch (error) { + sendRouteError('webhook_event_count', error, res); + } +}); + +async function seedCatalogIfEmpty(conn: DbConnection): Promise { + await new Promise((resolve, reject) => { + let resolved = false; + conn + .subscriptionBuilder() + .onApplied(() => { + if (resolved) return; + resolved = true; + resolve(); + }) + .onError((ctx: ErrorContext) => { + if (resolved) return; + resolved = true; + reject(new Error(`catalog probe failed: ${ctx.event}`)); + }) + .subscribe([tables.storeProduct]); + }); + + const count = conn.db.storeProduct.count(); + if (count === 0n) { + await conn.reducers.seedDefaultStoreProducts({ force: undefined }); + console.log('Seeded default store products (catalog was empty).'); + } +} + +(async () => { + console.log(`[stdb] connecting to ${STDB_URI} (database=${DB_NAME}) ...`); + try { + const connected = await connect(); + stdb = connected.connection; + grantServerIdentity({ + spacetimeBin: SPACETIME_BIN, + server: STDB_HTTP, + database: DB_NAME, + procedure: 'add_admin_identity', + identity: connected.identity, + }); + grantServerIdentity({ + spacetimeBin: SPACETIME_BIN, + server: STDB_HTTP, + database: DB_NAME, + procedure: 'stripe.add_admin_identity', + identity: connected.identity, + }); + console.log(`[stdb] connected as authorized server ${connected.identity}`); + } catch (err) { + console.error( + `[stdb] connection failed: ${err instanceof Error ? err.message : String(err)}` + ); + console.error( + '[stdb] is the SpacetimeDB host running and the example module published?' + ); + process.exit(1); + } + + try { + await seedCatalogIfEmpty(stdb); + } catch (err) { + console.warn( + `[stdb] could not check/seed catalog: ${err instanceof Error ? err.message : String(err)}` + ); + } + + if (process.env.STRIPE_SECRET_KEY?.trim()) { + try { + await configureStripeFromEnv(); + console.log('[stripe] config loaded from server environment'); + if (SYNC_PRICES_ON_START) { + const results = await syncStripePricesFromCatalog(); + console.log(`[stripe] synchronized ${results.length} catalog prices`); + } + } catch (err) { + console.error( + `[stripe] setup failed: ${err instanceof Error ? err.message : String(err)}` + ); + process.exit(1); + } + } else { + console.warn( + '[stripe] STRIPE_SECRET_KEY is not set; checkout remains unavailable' + ); + } + + app.listen(PORT, HOST, () => { + console.log(`Premium Store example running at http://${HOST}:${PORT}`); + }); +})(); diff --git a/spacetime-stripe-ts/example/spacetimedb/.npmrc b/spacetime-stripe-ts/example/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-stripe-ts/example/spacetimedb/package.json b/spacetime-stripe-ts/example/spacetimedb/package.json new file mode 100644 index 00000000000..096c0ea30f3 --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/package.json @@ -0,0 +1,20 @@ +{ + "name": "spacetime-stripe-example-module", + "version": "0.1.0", + "license": "BUSL-1.1", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-stripe-example", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-stripe-example" + }, + "dependencies": { + "@spacetimedb/stripe": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-stripe-ts/example/spacetimedb/src/index.ts b/spacetime-stripe-ts/example/spacetimedb/src/index.ts new file mode 100644 index 00000000000..330b90a795f --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,4 @@ +export { default, init } from './store/schema'; +export * from './store/operations'; +export { add_admin_identity, remove_admin_identity } from './store/auth'; +export { health, echo, stripe_webhook_handler, router } from './store/webhooks'; diff --git a/spacetime-stripe-ts/example/spacetimedb/src/store/auth.ts b/spacetime-stripe-ts/example/spacetimedb/src/store/auth.ts new file mode 100644 index 00000000000..7ee3028b935 --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/src/store/auth.ts @@ -0,0 +1,79 @@ +import { + spacetimedb, + t, + type ProcedureModuleCtx, + type WriteCtx, +} from './schema'; +import { throwSenderError } from './validation'; + +// Admin gate. Fresh publishes seed the owner via init. Public reducers never +// bootstrap admin state from "first caller wins". +type Sender = WriteCtx['sender']; +type ModuleTimestamp = WriteCtx['timestamp']; + +export type AdminVerdict = 'admin' | 'denied'; + +export function isAdmin(ctx: WriteCtx, sender: Sender): boolean { + return ctx.db.storeAdminIdentity.identity.find(sender) != null; +} + +export function adminVerdict(ctx: WriteCtx, sender: Sender): AdminVerdict { + return isAdmin(ctx, sender) ? 'admin' : 'denied'; +} + +export function denyIfNotAdmin(verdict: AdminVerdict): void { + if (verdict === 'denied') throwSenderError('store.not_authorized'); +} + +export function requireAdmin(ctx: WriteCtx, sender: Sender): void { + if (!isAdmin(ctx, sender)) throwSenderError('store.not_authorized'); +} + +// For owner-gated repair/setup code only. Do not call from a public bootstrap path. +export function seedAdmin( + ctx: WriteCtx, + sender: Sender, + timestamp: ModuleTimestamp +) { + if (ctx.db.storeAdminIdentity.identity.find(sender) != null) return; + ctx.db.storeAdminIdentity.insert({ + identity: sender, + addedAtMicros: timestamp.microsSinceUnixEpoch, + }); +} + +export const add_admin_identity = spacetimedb.procedure( + { identity: t.identity() }, + t.unit(), + (ctx: ProcedureModuleCtx, { identity }) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + ctx.withTx(tx => { + if (tx.db.storeAdminIdentity.identity.find(identity) == null) { + tx.db.storeAdminIdentity.insert({ + identity, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + }); + return {}; + } +); + +export const remove_admin_identity = spacetimedb.procedure( + { identity: t.identity() }, + t.unit(), + (ctx: ProcedureModuleCtx, { identity }) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + ctx.withTx(tx => { + const existing = tx.db.storeAdminIdentity.identity.find(identity); + if (!existing) return; + if (tx.db.storeAdminIdentity.count() <= 1n) { + throwSenderError('store.cannot_remove_last_admin'); + } + tx.db.storeAdminIdentity.delete(existing); + }); + return {}; + } +); diff --git a/spacetime-stripe-ts/example/spacetimedb/src/store/operations.ts b/spacetime-stripe-ts/example/spacetimedb/src/store/operations.ts new file mode 100644 index 00000000000..71cb55bc74c --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/src/store/operations.ts @@ -0,0 +1,590 @@ +import { + Range, + spacetimedb, + t, + type ModuleTimestamp, + type ProcedureModuleCtx, + type WriteCtx, +} from './schema'; +import * as stripe from '@spacetimedb/stripe/submodule'; +import { requireAdmin } from './auth'; +import { stringArrayFromJson, throwSenderError } from './validation'; + +const DEFAULT_STORE_PRODUCTS: Array<{ + productId: string; + name: string; + description: string; + mode: string; + priceLabel: string; + perks: string[]; + sortOrder: bigint; +}> = [ + { + productId: 'orbital-starter-pack', + name: 'Orbital Starter Pack', + description: 'One-time booster crate for first-wave pilots.', + mode: 'payment', + priceLabel: '$19.00', + perks: [ + 'Orbital pilot badge', + 'Nebula ship skin', + 'Priority support queue', + ], + sortOrder: 10n, + }, + { + productId: 'warp-pass', + name: 'Warp Pass', + description: 'Monthly command-tier subscription.', + mode: 'subscription', + priceLabel: '$9.00 / month', + perks: [ + 'Expanded cargo slots', + 'Telemetry dashboard', + 'Beta sector access', + ], + sortOrder: 20n, + }, + { + productId: 'fleet-command-bundle', + name: 'Fleet Command Bundle', + description: 'Multi-seat bundle for your squad.', + mode: 'payment', + priceLabel: '$49.00', + perks: [ + '5 pilot bundle', + 'Faction banner cosmetic', + 'Shared vault upgrade', + ], + sortOrder: 30n, + }, +]; + +const stripeHttpResponse = t.object('StoreStripeHttpResponse', { + status: t.u16(), + body: t.string(), +}); + +const storeValidateStripePriceResult = t.object( + 'StoreValidateStripePriceResult', + { + valid: t.bool(), + status: t.u16(), + active: t.option(t.bool()), + currency: t.option(t.string()), + unitAmount: t.option(t.i64()), + livemode: t.option(t.bool()), + type: t.option(t.string()), + message: t.option(t.string()), + code: t.option(t.string()), + errorType: t.option(t.string()), + } +); + +const storeGetOrCreateCustomerResult = t.object( + 'StoreGetOrCreateCustomerResult', + { + customerId: t.string(), + isNew: t.bool(), + } +); + +const storeCheckoutLineItem = t.object('StoreCheckoutLineItem', { + priceId: t.string(), + quantity: t.i64(), +}); + +const storeCheckoutSessionResult = t.object('StoreCheckoutSessionResult', { + sessionId: t.string(), + url: t.option(t.string()), +}); + +function parseAmountCents(priceLabel: string): bigint { + const match = /(\d+)(?:\.(\d{1,2}))?/.exec(priceLabel); + if (!match) { + throwSenderError(`store.invalid_price_label:${priceLabel}`); + } + const whole = BigInt(Number.parseInt(match[1], 10)); + const fraction = BigInt( + match[2] ? Number.parseInt(match[2].padEnd(2, '0'), 10) : 0 + ); + return whole * 100n + fraction; +} + +function getLookupKey(databaseIdentity: string, productId: string): string { + const safeDb = databaseIdentity.replace(/[^A-Za-z0-9_-]/g, '_'); + const safeProduct = productId.replace(/[^A-Za-z0-9_-]/g, '_'); + return `stdb_${safeDb}_${safeProduct}`; +} + +function encodeFormField(value: string): string { + return encodeURIComponent(value).replace(/%20/g, '+'); +} + +function formBody(pairs: Array<[string, string | undefined]>): string { + return pairs + .filter((pair): pair is [string, string] => pair[1] !== undefined) + .map(([key, value]) => `${encodeFormField(key)}=${encodeFormField(value)}`) + .join('&'); +} + +function callStripe( + ctx: ProcedureModuleCtx, + method: string, + path: string, + body?: string +) { + return stripe.stripe_api_request(ctx.as.stripe, { + method, + path, + formBody: body, + idempotencyKey: undefined, + }) as { status: number; body: string }; +} + +type StripeObject = Record; + +function isObject(value: unknown): value is StripeObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function parseStripeResponse(response: { + status: number; + body: string; +}): StripeObject { + let parsed: unknown; + try { + parsed = JSON.parse(response.body || '{}'); + } catch { + throwSenderError(`store.stripe_invalid_json:${response.status}`); + } + if (!isObject(parsed)) + throwSenderError(`store.stripe_invalid_response:${response.status}`); + if (response.status < 200 || response.status >= 300) { + const error = isObject(parsed.error) ? parsed.error : undefined; + const message = + typeof error?.message === 'string' + ? error.message + : `Stripe API returned ${response.status}`; + throwSenderError(`store.stripe_api_error:${message}`); + } + return parsed; +} + +function stripeObjectId(value: StripeObject, kind: string): string { + if (typeof value.id !== 'string' || value.id.length === 0) { + throwSenderError(`store.stripe_invalid_${kind}`); + } + return value.id; +} + +function findStripePriceByLookupKey( + ctx: ProcedureModuleCtx, + lookupKey: string +): StripeObject | undefined { + const query = formBody([ + ['lookup_keys[]', lookupKey], + ['active', 'true'], + ['limit', '1'], + ]); + const parsed = parseStripeResponse( + callStripe(ctx, 'GET', `/v1/prices?${query}`) + ); + if (!Array.isArray(parsed.data)) return undefined; + const first = parsed.data[0]; + return isObject(first) ? first : undefined; +} + +function createStripeProduct( + ctx: ProcedureModuleCtx, + args: { + productId: string; + name: string; + description: string; + databaseIdentity: string; + } +) { + return parseStripeResponse( + callStripe( + ctx, + 'POST', + '/v1/products', + formBody([ + ['name', args.name], + ['description', args.description], + ['metadata[stdb_product_id]', args.productId], + ['metadata[stdb_db]', args.databaseIdentity], + ]) + ) + ); +} + +function createStripePrice( + ctx: ProcedureModuleCtx, + args: { + stripeProductId: string; + productId: string; + mode: string; + priceLabel: string; + lookupKey: string; + databaseIdentity: string; + } +) { + const pairs: Array<[string, string | undefined]> = [ + ['product', args.stripeProductId], + ['currency', 'usd'], + ['unit_amount', String(parseAmountCents(args.priceLabel))], + ['lookup_key', args.lookupKey], + ['metadata[stdb_product_id]', args.productId], + ['metadata[stdb_db]', args.databaseIdentity], + ]; + if (args.mode === 'subscription') { + pairs.push(['recurring[interval]', 'month']); + } + return parseStripeResponse( + callStripe(ctx, 'POST', '/v1/prices', formBody(pairs)) + ); +} + +function upsertStoreProduct( + ctx: WriteCtx, + now: ModuleTimestamp, + args: { + productId: string; + name: string; + description: string; + mode: string; + priceLabel: string; + stripePriceId: string | undefined; + perksJson: string | undefined; + active: boolean; + sortOrder: bigint; + } +) { + const existing = ctx.db.storeProduct.productId.find(args.productId); + const row = { + productId: args.productId, + name: args.name, + description: args.description, + mode: args.mode, + priceLabel: args.priceLabel, + stripePriceId: args.stripePriceId, + perksJson: args.perksJson, + active: args.active, + sortOrder: args.sortOrder, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + + if (!existing) { + ctx.db.storeProduct.insert(row); + return; + } + if (ctx.db.storeProduct.productId.update) { + ctx.db.storeProduct.productId.update(row); + } else { + ctx.db.storeProduct.delete(existing); + ctx.db.storeProduct.insert(row); + } +} + +export const upsert_store_product = spacetimedb.reducer( + { + productId: t.string(), + name: t.string(), + description: t.string(), + mode: t.string(), + priceLabel: t.string(), + stripePriceId: t.option(t.string()), + perksJson: t.option(t.string()), + active: t.option(t.bool()), + sortOrder: t.option(t.i64()), + }, + (ctx, args) => { + const tx = ctx; + requireAdmin(tx, ctx.sender); + upsertStoreProduct(tx, ctx.timestamp, { + productId: args.productId, + name: args.name, + description: args.description, + mode: args.mode, + priceLabel: args.priceLabel, + stripePriceId: args.stripePriceId, + perksJson: args.perksJson, + active: args.active ?? true, + sortOrder: args.sortOrder ?? 0n, + }); + } +); + +export const seed_default_store_products = spacetimedb.reducer( + { force: t.option(t.bool()) }, + (ctx, args) => { + const force = args.force ?? false; + const tx = ctx; + requireAdmin(tx, ctx.sender); + const hasAnyProducts = tx.db.storeProduct.count() > 0n; + if (hasAnyProducts && !force) return; + + for (const product of DEFAULT_STORE_PRODUCTS) { + upsertStoreProduct(tx, ctx.timestamp, { + productId: product.productId, + name: product.name, + description: product.description, + mode: product.mode, + priceLabel: product.priceLabel, + stripePriceId: undefined, + perksJson: JSON.stringify(product.perks), + active: true, + sortOrder: product.sortOrder, + }); + } + } +); + +export const list_store_products_json = spacetimedb.procedure( + {}, + t.string(), + ctx => + ctx.withTx(tx => { + const rows = [ + ...tx.db.storeProduct.byActiveSort.filter([true, new Range()]), + ]; + const mapped = rows.map(row => ({ + id: row.productId, + name: row.name, + description: row.description, + mode: row.mode, + priceLabel: row.priceLabel, + priceId: row.stripePriceId ?? '', + perks: stringArrayFromJson(row.perksJson), + sortOrder: Number(row.sortOrder), + })); + return JSON.stringify(mapped); + }) +); + +export const configure_stripe = spacetimedb.procedure( + { + secretKey: t.string(), + stripeVersion: t.option(t.string()), + webhookSigningSecret: t.option(t.string()), + }, + t.unit(), + (ctx, args) => { + const verdict = ctx.withTx(tx => { + requireAdmin(tx, ctx.sender); + return true; + }); + void verdict; + return stripe.set_stripe_config(ctx.as.stripe, { + secretKey: args.secretKey, + stripeVersion: args.stripeVersion, + webhookSigningSecret: args.webhookSigningSecret, + }); + } +); + +export const store_stripe_api_request = spacetimedb.procedure( + { + method: t.string(), + path: t.string(), + formBody: t.option(t.string()), + idempotencyKey: t.option(t.string()), + }, + stripeHttpResponse, + (ctx, args) => { + ctx.withTx(tx => requireAdmin(tx, ctx.sender)); + return stripe.stripe_api_request(ctx.as.stripe, { + method: args.method, + path: args.path, + formBody: args.formBody, + idempotencyKey: args.idempotencyKey, + }) as { status: number; body: string }; + } +); + +export const validate_store_stripe_price = spacetimedb.procedure( + { priceId: t.string() }, + storeValidateStripePriceResult, + (ctx, args) => + stripe.validate_stripe_price(ctx.as.stripe, { + priceId: args.priceId, + }) as { + valid: boolean; + status: number; + active: boolean | undefined; + currency: string | undefined; + unitAmount: bigint | undefined; + livemode: boolean | undefined; + type: string | undefined; + message: string | undefined; + code: string | undefined; + errorType: string | undefined; + } +); + +export const get_store_webhook_event_count = spacetimedb.procedure( + {}, + t.i64(), + ctx => stripe.get_webhook_event_count(ctx.as.stripe, {}) as bigint +); + +export const get_or_create_store_customer = spacetimedb.procedure( + { + userId: t.string(), + email: t.option(t.string()), + name: t.option(t.string()), + }, + storeGetOrCreateCustomerResult, + (ctx, args) => + stripe.get_or_create_customer(ctx.as.stripe, { + userId: args.userId, + email: args.email, + name: args.name, + }) as { customerId: string; isNew: boolean } +); + +export const create_store_checkout_session = spacetimedb.procedure( + { + items: t.array(storeCheckoutLineItem), + customerId: t.option(t.string()), + mode: t.string(), + successUrl: t.string(), + cancelUrl: t.string(), + metadataJson: t.option(t.string()), + subscriptionMetadataJson: t.option(t.string()), + paymentIntentMetadataJson: t.option(t.string()), + }, + storeCheckoutSessionResult, + (ctx, args) => + stripe.create_checkout_session(ctx.as.stripe, { + items: args.items, + customerId: args.customerId, + mode: args.mode, + successUrl: args.successUrl, + cancelUrl: args.cancelUrl, + metadataJson: args.metadataJson, + subscriptionMetadataJson: args.subscriptionMetadataJson, + paymentIntentMetadataJson: args.paymentIntentMetadataJson, + }) as { sessionId: string; url: string | undefined } +); + +export const sync_store_products_with_stripe = spacetimedb.procedure( + {}, + t.string(), + ctx => { + ctx.withTx(tx => requireAdmin(tx, ctx.sender)); + const databaseIdentity = ctx.databaseIdentity.toHexString(); + const rows = ctx.withTx(tx => [ + ...tx.db.storeProduct.byActiveSort.filter([true, new Range()]), + ]); + const results: Array<{ + productId: string; + priceId: string; + action: string; + }> = []; + + for (const row of rows) { + if (row.stripePriceId) { + results.push({ + productId: row.productId, + priceId: row.stripePriceId, + action: 'kept', + }); + continue; + } + + const lookupKey = getLookupKey(databaseIdentity, row.productId); + const existing = findStripePriceByLookupKey(ctx, lookupKey); + const price = + existing ?? + (() => { + const product = createStripeProduct(ctx, { + productId: row.productId, + name: row.name, + description: row.description, + databaseIdentity, + }); + return createStripePrice(ctx, { + stripeProductId: stripeObjectId(product, 'product'), + productId: row.productId, + mode: row.mode, + priceLabel: row.priceLabel, + lookupKey, + databaseIdentity, + }); + })(); + + ctx.withTx(tx => { + const current = tx.db.storeProduct.productId.find(row.productId); + if (!current) + throwSenderError(`store.product_not_found:${row.productId}`); + upsertStoreProduct(tx, ctx.timestamp, { + productId: current.productId, + name: current.name, + description: current.description, + mode: current.mode, + priceLabel: current.priceLabel, + stripePriceId: stripeObjectId(price, 'price'), + perksJson: current.perksJson, + active: current.active, + sortOrder: current.sortOrder, + }); + }); + results.push({ + productId: row.productId, + priceId: stripeObjectId(price, 'price'), + action: existing ? 'linked' : 'created', + }); + } + + return JSON.stringify(results); + } +); + +export const set_store_product_price = spacetimedb.reducer( + { productId: t.string(), stripePriceId: t.string() }, + (ctx, args) => { + const tx = ctx; + requireAdmin(tx, ctx.sender); + const existing = tx.db.storeProduct.productId.find(args.productId); + if (!existing) { + throwSenderError(`store.product_not_found:${args.productId}`); + } + upsertStoreProduct(tx, ctx.timestamp, { + productId: existing.productId, + name: existing.name, + description: existing.description, + mode: existing.mode, + priceLabel: existing.priceLabel, + stripePriceId: args.stripePriceId, + perksJson: existing.perksJson, + active: existing.active, + sortOrder: existing.sortOrder, + }); + } +); + +export const clear_store_product_price = spacetimedb.reducer( + { productId: t.string() }, + (ctx, args) => { + const tx = ctx; + requireAdmin(tx, ctx.sender); + const existing = tx.db.storeProduct.productId.find(args.productId); + if (!existing) { + throwSenderError(`store.product_not_found:${args.productId}`); + } + upsertStoreProduct(tx, ctx.timestamp, { + productId: existing.productId, + name: existing.name, + description: existing.description, + mode: existing.mode, + priceLabel: existing.priceLabel, + stripePriceId: undefined, + perksJson: existing.perksJson, + active: existing.active, + sortOrder: existing.sortOrder, + }); + } +); diff --git a/spacetime-stripe-ts/example/spacetimedb/src/store/schema.ts b/spacetime-stripe-ts/example/spacetimedb/src/store/schema.ts new file mode 100644 index 00000000000..f185a45e5bc --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/src/store/schema.ts @@ -0,0 +1,91 @@ +import { + schema, + table, + t, + Range, + SenderError, + type ProcedureCtx, + type ReducerCtx, + type TransactionCtx, +} from 'spacetimedb/server'; +import * as stripe from '@spacetimedb/stripe/submodule'; + +export const storeProductRow = { + productId: t.string().primaryKey(), + name: t.string(), + description: t.string(), + mode: t.string(), + priceLabel: t.string(), + stripePriceId: t.option(t.string()), + perksJson: t.option(t.string()), + active: t.bool(), + sortOrder: t.i64(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +// Identities allowed to mutate the catalog. Fresh publishes seed the owner via init. +export const storeAdminIdentityRow = { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), +}; + +export const storeProductTable = table( + { + name: 'store_product', + public: true, + indexes: [ + { + accessor: 'byActiveSort', + algorithm: 'btree', + columns: ['active', 'sortOrder', 'productId'], + }, + { + accessor: 'byModeSort', + algorithm: 'btree', + columns: ['mode', 'sortOrder', 'productId'], + }, + { + accessor: 'byStripePriceId', + algorithm: 'btree', + columns: ['stripePriceId'], + }, + ], + }, + storeProductRow +); + +export const storeAdminIdentityTable = table( + { name: 'store_admin_identity', public: false, indexes: [] }, + storeAdminIdentityRow +); + +export const spacetimedb = schema({ + stripe, + storeProduct: storeProductTable, + storeAdminIdentity: storeAdminIdentityTable, +}); + +export const init = spacetimedb.init(ctx => { + installStore(ctx); + stripe.installStripe(ctx.as.stripe); +}); + +export default spacetimedb; + +export { Range, SenderError, t }; +export type ReducerModuleCtx = ReducerCtx; +export type ProcedureModuleCtx = ProcedureCtx; +export type TransactionModuleCtx = TransactionCtx< + typeof spacetimedb.schemaType +>; +export type WriteCtx = ReducerModuleCtx | TransactionModuleCtx; +export type ModuleTimestamp = ReducerModuleCtx['timestamp']; + +export function installStore(ctx: ReducerModuleCtx) { + if (ctx.db.storeAdminIdentity.identity.find(ctx.sender) != null) return; + ctx.db.storeAdminIdentity.insert({ + identity: ctx.sender, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); +} diff --git a/spacetime-stripe-ts/example/spacetimedb/src/store/validation.ts b/spacetime-stripe-ts/example/spacetimedb/src/store/validation.ts new file mode 100644 index 00000000000..6dd1c48261c --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/src/store/validation.ts @@ -0,0 +1,24 @@ +import { SenderError } from 'spacetimedb/server'; + +export function throwSenderError(message: string): never { + throw new SenderError(message); +} + +export function safeJsonParse(input: string): unknown { + try { + return JSON.parse(input); + } catch { + return undefined; + } +} + +export function stringArrayFromJson(value: string | undefined): string[] { + if (!value) return []; + const parsed = safeJsonParse(value); + if (!Array.isArray(parsed)) return []; + const out: string[] = []; + for (const item of parsed) { + if (typeof item === 'string') out.push(item); + } + return out; +} diff --git a/spacetime-stripe-ts/example/spacetimedb/src/store/webhooks.ts b/spacetime-stripe-ts/example/spacetimedb/src/store/webhooks.ts new file mode 100644 index 00000000000..8240d8e5266 --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/src/store/webhooks.ts @@ -0,0 +1,39 @@ +import { Router, SyncResponse } from 'spacetimedb/server'; +import { handleStripeWebhook } from '@spacetimedb/stripe/submodule'; +import { spacetimedb } from './schema'; + +export const stripe_webhook_handler = spacetimedb.httpHandler((ctx, req) => + handleStripeWebhook(ctx.as.stripe, req) +); + +export const health = spacetimedb.httpHandler((ctx, _req) => { + const count = ctx.withTx(tx => tx.db.storeProduct.count()); + return new SyncResponse( + JSON.stringify({ + ok: true, + catalogRows: Number(count), + at: ctx.timestamp.microsSinceUnixEpoch.toString(), + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); +}); + +export const echo = spacetimedb.httpHandler((_ctx, req) => { + const body = req.text(); + return new SyncResponse( + JSON.stringify({ + method: req.method, + uri: req.uri, + bodyLength: body.length, + bodyPreview: body.slice(0, 200), + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); +}); + +export const router = spacetimedb.httpRouter( + new Router() + .get('/health', health) + .post('/echo', echo) + .post('/stripe/webhook', stripe_webhook_handler) +); diff --git a/spacetime-stripe-ts/example/spacetimedb/tsconfig.json b/spacetime-stripe-ts/example/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..4b599551afe --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "emitDeclarationOnly": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "isolatedDeclarations": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": false, + "useDefineForClassFields": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist/**/*"] +} diff --git a/spacetime-stripe-ts/example/src/app.ts b/spacetime-stripe-ts/example/src/app.ts new file mode 100644 index 00000000000..f3023c24577 --- /dev/null +++ b/spacetime-stripe-ts/example/src/app.ts @@ -0,0 +1,233 @@ +import { + DbConnection, + tables, + type EventContext, + type ErrorContext, +} from './module_bindings/app'; + +declare global { + interface Window { + stdb?: { + getOrCreateCustomer: (args: { + userId: string; + email?: string; + name?: string; + }) => Promise<{ customerId: string; isNew: boolean }>; + createCheckoutSession: (args: { + items: Array<{ priceId: string; quantity: number }>; + customerId?: string; + mode: 'payment' | 'subscription' | string; + successUrl: string; + cancelUrl: string; + metadataJson?: string; + subscriptionMetadataJson?: string; + paymentIntentMetadataJson?: string; + }) => Promise<{ sessionId: string; url?: string }>; + validatePrice: (priceId: string) => Promise<{ + valid: boolean; + active?: boolean; + message?: string; + code?: string; + errorType?: string; + }>; + getWebhookEventCount: () => Promise; + }; + } +} + +type StoreProductRow = { + productId: string; + name: string; + description: string; + mode: string; + priceLabel: string; + stripePriceId: string | undefined; + perksJson: string | undefined; + active: boolean; + sortOrder: bigint; + createdAt: { microsSinceUnixEpoch: bigint }; + updatedAt: { microsSinceUnixEpoch: bigint }; +}; + +interface ServerConfig { + spacetimeUri: string; + databaseName: string; +} + +const products = new Map(); + +function parsePerks(json: string | undefined): string[] { + if (!json) return []; + try { + const parsed = JSON.parse(json); + return Array.isArray(parsed) + ? parsed.filter(x => typeof x === 'string') + : []; + } catch { + return []; + } +} + +function emitCatalog() { + const sorted = [...products.values()] + .filter(p => p.active) + .sort((a, b) => { + const so = Number(a.sortOrder - b.sortOrder); + return so !== 0 ? so : a.productId.localeCompare(b.productId); + }); + window.dispatchEvent( + new CustomEvent('stdb:catalog', { + detail: { + products: sorted.map(p => ({ + id: p.productId, + name: p.name, + description: p.description, + mode: p.mode, + priceLabel: p.priceLabel, + priceId: p.stripePriceId ?? '', + perks: parsePerks(p.perksJson), + sortOrder: Number(p.sortOrder), + active: p.active, + })), + }, + }) + ); +} + +function updateConnState( + state: 'connecting' | 'connected' | 'error', + detail?: string +) { + window.dispatchEvent( + new CustomEvent('stdb:connState', { detail: { state, detail } }) + ); +} + +async function loadServerConfig(): Promise { + const r = await fetch('/api/config'); + if (!r.ok) throw new Error(`/api/config returned ${r.status}`); + return (await r.json()) as ServerConfig; +} + +function connect(config: ServerConfig): Promise { + return new Promise((resolve, reject) => { + const timeout = window.setTimeout( + () => reject(new Error(`Timed out connecting to ${config.spacetimeUri}`)), + 10000 + ); + DbConnection.builder() + .withUri(config.spacetimeUri) + .withDatabaseName(config.databaseName) + .withCompression('none') + .onConnect(c => { + window.clearTimeout(timeout); + resolve(c); + }) + .onDisconnect((_ctx, err) => { + window.clearTimeout(timeout); + updateConnState('error', err?.message ?? 'disconnected'); + }) + .onConnectError((_ctx, err) => { + window.clearTimeout(timeout); + updateConnState('error', 'connect failed (app)'); + reject(err); + }) + .build(); + }); +} + +async function api(path: string, body?: unknown): Promise { + const response = await fetch(path, { + method: body === undefined ? 'GET' : 'POST', + headers: + body === undefined ? undefined : { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const payload: unknown = await response.json().catch(() => ({})); + if (!response.ok) { + const message = + isRecord(payload) && typeof payload.error === 'string' + ? payload.error + : `${path} returned ${response.status}`; + throw new Error(message); + } + return payload as T; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function registerRowCallbacks(connection: DbConnection): void { + connection.db.storeProduct.onInsert( + (_ctx: EventContext, row: StoreProductRow) => { + products.set(row.productId, row); + emitCatalog(); + } + ); + connection.db.storeProduct.onUpdate( + (_ctx: EventContext, _oldRow: StoreProductRow, row: StoreProductRow) => { + products.set(row.productId, row); + emitCatalog(); + } + ); + connection.db.storeProduct.onDelete( + (_ctx: EventContext, row: StoreProductRow) => { + products.delete(row.productId); + emitCatalog(); + } + ); +} + +function subscribeToTables(connection: DbConnection): void { + connection + .subscriptionBuilder() + .onApplied(() => { + products.clear(); + for (const row of connection.db.storeProduct.iter() as Iterable) { + products.set(row.productId, row); + } + emitCatalog(); + updateConnState('connected'); + window.dispatchEvent(new CustomEvent('stdb:ready')); + }) + .onError((ctx: ErrorContext) => { + console.error('catalog sub error', ctx.event); + updateConnState('error', String(ctx.event)); + }) + .subscribe([tables.storeProduct]); +} + +async function main() { + updateConnState('connecting'); + let conn: DbConnection; + try { + const config = await loadServerConfig(); + conn = await connect(config); + } catch (err) { + console.error('STDB connect failed:', err); + updateConnState('error', err instanceof Error ? err.message : String(err)); + return; + } + + registerRowCallbacks(conn); + + window.stdb = { + getOrCreateCustomer: args => api('/api/customer', args), + createCheckoutSession: args => + api('/api/checkout', { + items: args.items, + customerId: args.customerId, + mode: args.mode, + }), + validatePrice: priceId => api('/api/validate-price', { priceId }), + getWebhookEventCount: () => + api<{ count: number }>('/api/webhook-event-count').then( + result => result.count + ), + }; + + subscribeToTables(conn); +} + +main(); diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/add_admin_identity_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/add_admin_identity_procedure.ts new file mode 100644 index 00000000000..bfd93108ec4 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/add_admin_identity_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + identity: __t.identity(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/clear_store_product_price_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/clear_store_product_price_reducer.ts new file mode 100644 index 00000000000..d564c4111fd --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/clear_store_product_price_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + productId: __t.string(), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/configure_stripe_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/configure_stripe_procedure.ts new file mode 100644 index 00000000000..e9fc16b27f5 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/configure_stripe_procedure.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + secretKey: __t.string(), + stripeVersion: __t.option(__t.string()), + webhookSigningSecret: __t.option(__t.string()), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/create_store_checkout_session_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/create_store_checkout_session_procedure.ts new file mode 100644 index 00000000000..8ea8e71b13d --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/create_store_checkout_session_procedure.ts @@ -0,0 +1,30 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StoreCheckoutLineItem, + StoreCheckoutSessionResult, +} from "./types"; + +export const params = { + get items() { + return __t.array(StoreCheckoutLineItem); + }, + customerId: __t.option(__t.string()), + mode: __t.string(), + successUrl: __t.string(), + cancelUrl: __t.string(), + metadataJson: __t.option(__t.string()), + subscriptionMetadataJson: __t.option(__t.string()), + paymentIntentMetadataJson: __t.option(__t.string()), +}; +export const returnType = StoreCheckoutSessionResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/get_or_create_store_customer_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/get_or_create_store_customer_procedure.ts new file mode 100644 index 00000000000..70e89b7767d --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/get_or_create_store_customer_procedure.ts @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StoreGetOrCreateCustomerResult, +} from "./types"; + +export const params = { + userId: __t.string(), + email: __t.option(__t.string()), + name: __t.option(__t.string()), +}; +export const returnType = StoreGetOrCreateCustomerResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/get_store_webhook_event_count_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/get_store_webhook_event_count_procedure.ts new file mode 100644 index 00000000000..c6724c84a1b --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/get_store_webhook_event_count_procedure.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { +}; +export const returnType = __t.i64() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/index.ts b/spacetime-stripe-ts/example/src/module_bindings/app/index.ts new file mode 100644 index 00000000000..391e636eb1c --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/index.ts @@ -0,0 +1,313 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb cli version 2.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* eslint-disable */ +/* tslint:disable */ +import { + DbConnectionBuilder as __DbConnectionBuilder, + DbConnectionImpl as __DbConnectionImpl, + SubscriptionBuilderImpl as __SubscriptionBuilderImpl, + TypeBuilder as __TypeBuilder, + Uuid as __Uuid, + convertToAccessorMap as __convertToAccessorMap, + makeQueryBuilder as __makeQueryBuilder, + procedureSchema as __procedureSchema, + procedures as __procedures, + reducerSchema as __reducerSchema, + reducers as __reducers, + schema as __schema, + t as __t, + table as __table, + type AlgebraicTypeType as __AlgebraicTypeType, + type DbConnectionConfig as __DbConnectionConfig, + type ErrorContextInterface as __ErrorContextInterface, + type Event as __Event, + type EventContextInterface as __EventContextInterface, + type Infer as __Infer, + type QueryBuilder as __QueryBuilder, + type ReducerEventContextInterface as __ReducerEventContextInterface, + type RemoteModule as __RemoteModule, + type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, + type SubscriptionHandleImpl as __SubscriptionHandleImpl, +} from "spacetimedb"; + +// Import all reducer arg schemas +import ClearStoreProductPriceReducer from "./clear_store_product_price_reducer"; +import SeedDefaultStoreProductsReducer from "./seed_default_store_products_reducer"; +import SetStoreProductPriceReducer from "./set_store_product_price_reducer"; +import UpsertStoreProductReducer from "./upsert_store_product_reducer"; + +// Import all procedure arg schemas +import * as AddAdminIdentityProcedure from "./add_admin_identity_procedure"; +import * as ConfigureStripeProcedure from "./configure_stripe_procedure"; +import * as CreateStoreCheckoutSessionProcedure from "./create_store_checkout_session_procedure"; +import * as GetOrCreateStoreCustomerProcedure from "./get_or_create_store_customer_procedure"; +import * as GetStoreWebhookEventCountProcedure from "./get_store_webhook_event_count_procedure"; +import * as ListStoreProductsJsonProcedure from "./list_store_products_json_procedure"; +import * as RemoveAdminIdentityProcedure from "./remove_admin_identity_procedure"; +import * as StoreStripeApiRequestProcedure from "./store_stripe_api_request_procedure"; +import * as SyncStoreProductsWithStripeProcedure from "./sync_store_products_with_stripe_procedure"; +import * as ValidateStoreStripePriceProcedure from "./validate_store_stripe_price_procedure"; + +// Import all table schema definitions +import StoreProductRow from "./store_product_table"; + +// Import namespace reducer arg schemas +import Stripe_IngestStripeWebhookReducer from "./stripe/ingest_stripe_webhook_reducer"; +import Stripe_ReplayWebhookEventReducer from "./stripe/replay_webhook_event_reducer"; +import Stripe_UpdatePaymentCustomerReducer from "./stripe/update_payment_customer_reducer"; +import Stripe_UpdateSubscriptionQuantityInternalReducer from "./stripe/update_subscription_quantity_internal_reducer"; +import Stripe_UpsertCustomerReducer from "./stripe/upsert_customer_reducer"; +import Stripe_UpsertSubscriptionReducer from "./stripe/upsert_subscription_reducer"; + +// Import namespace procedure arg schemas +import * as Stripe_AddAdminIdentityProcedure from "./stripe/add_admin_identity_procedure"; +import * as Stripe_CancelSubscriptionProcedure from "./stripe/cancel_subscription_procedure"; +import * as Stripe_CreateCheckoutSessionProcedure from "./stripe/create_checkout_session_procedure"; +import * as Stripe_CreateCustomerProcedure from "./stripe/create_customer_procedure"; +import * as Stripe_CreateCustomerPortalSessionProcedure from "./stripe/create_customer_portal_session_procedure"; +import * as Stripe_CreateOrUpdateCustomerProcedure from "./stripe/create_or_update_customer_procedure"; +import * as Stripe_GetCheckoutSessionProcedure from "./stripe/get_checkout_session_procedure"; +import * as Stripe_GetCustomerProcedure from "./stripe/get_customer_procedure"; +import * as Stripe_GetCustomerByEmailProcedure from "./stripe/get_customer_by_email_procedure"; +import * as Stripe_GetCustomerByUserIdProcedure from "./stripe/get_customer_by_user_id_procedure"; +import * as Stripe_GetOrCreateCustomerProcedure from "./stripe/get_or_create_customer_procedure"; +import * as Stripe_GetPaymentProcedure from "./stripe/get_payment_procedure"; +import * as Stripe_GetRemoteCheckoutSessionProcedure from "./stripe/get_remote_checkout_session_procedure"; +import * as Stripe_GetStripeConfigStatusProcedure from "./stripe/get_stripe_config_status_procedure"; +import * as Stripe_GetSubscriptionProcedure from "./stripe/get_subscription_procedure"; +import * as Stripe_GetSubscriptionByOrgIdProcedure from "./stripe/get_subscription_by_org_id_procedure"; +import * as Stripe_GetWebhookEventCountProcedure from "./stripe/get_webhook_event_count_procedure"; +import * as Stripe_ListCheckoutSessionsProcedure from "./stripe/list_checkout_sessions_procedure"; +import * as Stripe_ListInvoicesProcedure from "./stripe/list_invoices_procedure"; +import * as Stripe_ListInvoicesByOrgIdProcedure from "./stripe/list_invoices_by_org_id_procedure"; +import * as Stripe_ListInvoicesByUserIdProcedure from "./stripe/list_invoices_by_user_id_procedure"; +import * as Stripe_ListPaymentsProcedure from "./stripe/list_payments_procedure"; +import * as Stripe_ListPaymentsByOrgIdProcedure from "./stripe/list_payments_by_org_id_procedure"; +import * as Stripe_ListPaymentsByUserIdProcedure from "./stripe/list_payments_by_user_id_procedure"; +import * as Stripe_ListSubscriptionsProcedure from "./stripe/list_subscriptions_procedure"; +import * as Stripe_ListSubscriptionsByOrgIdProcedure from "./stripe/list_subscriptions_by_org_id_procedure"; +import * as Stripe_ListSubscriptionsByUserIdProcedure from "./stripe/list_subscriptions_by_user_id_procedure"; +import * as Stripe_ListSubscriptionsWithCreationTimeProcedure from "./stripe/list_subscriptions_with_creation_time_procedure"; +import * as Stripe_ReactivateSubscriptionProcedure from "./stripe/reactivate_subscription_procedure"; +import * as Stripe_RemoveAdminIdentityProcedure from "./stripe/remove_admin_identity_procedure"; +import * as Stripe_SetStripeConfigProcedure from "./stripe/set_stripe_config_procedure"; +import * as Stripe_SetStripeWebhookSigningSecretProcedure from "./stripe/set_stripe_webhook_signing_secret_procedure"; +import * as Stripe_StripeApiRequestProcedure from "./stripe/stripe_api_request_procedure"; +import * as Stripe_UpdateSubscriptionMetadataProcedure from "./stripe/update_subscription_metadata_procedure"; +import * as Stripe_UpdateSubscriptionQuantityProcedure from "./stripe/update_subscription_quantity_procedure"; +import * as Stripe_ValidateStripePriceProcedure from "./stripe/validate_stripe_price_procedure"; + +/** Type-only namespace exports for generated type groups. */ + +/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */ +const tablesSchema = __schema({ + storeProduct: __table({ + name: 'store_product', + indexes: [ + { accessor: 'byActiveSort', name: 'store_product_active_sort_order_product_id_idx_btree', algorithm: 'btree', columns: [ + 'active', + 'sortOrder', + 'productId', + ] }, + { accessor: 'byModeSort', name: 'store_product_mode_sort_order_product_id_idx_btree', algorithm: 'btree', columns: [ + 'mode', + 'sortOrder', + 'productId', + ] }, + { accessor: 'productId', name: 'store_product_product_id_idx_btree', algorithm: 'btree', columns: [ + 'productId', + ] }, + { accessor: 'byStripePriceId', name: 'store_product_stripe_price_id_idx_btree', algorithm: 'btree', columns: [ + 'stripePriceId', + ] }, + ], + constraints: [ + { name: 'store_product_product_id_key', constraint: 'unique', columns: ['productId'] }, + ], + }, StoreProductRow), +}); + +/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */ +const reducersSchema = __reducers( + __reducerSchema("clear_store_product_price", ClearStoreProductPriceReducer), + __reducerSchema("seed_default_store_products", SeedDefaultStoreProductsReducer), + __reducerSchema("set_store_product_price", SetStoreProductPriceReducer), + __reducerSchema("upsert_store_product", UpsertStoreProductReducer), + __reducerSchema("stripe.ingest_stripe_webhook", Stripe_IngestStripeWebhookReducer), + __reducerSchema("stripe.replay_webhook_event", Stripe_ReplayWebhookEventReducer), + __reducerSchema("stripe.update_payment_customer", Stripe_UpdatePaymentCustomerReducer), + __reducerSchema("stripe.update_subscription_quantity_internal", Stripe_UpdateSubscriptionQuantityInternalReducer), + __reducerSchema("stripe.upsert_customer", Stripe_UpsertCustomerReducer), + __reducerSchema("stripe.upsert_subscription", Stripe_UpsertSubscriptionReducer), +); + +/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ +const proceduresSchema = __procedures( + __procedureSchema("add_admin_identity", AddAdminIdentityProcedure.params, AddAdminIdentityProcedure.returnType), + __procedureSchema("configure_stripe", ConfigureStripeProcedure.params, ConfigureStripeProcedure.returnType), + __procedureSchema("create_store_checkout_session", CreateStoreCheckoutSessionProcedure.params, CreateStoreCheckoutSessionProcedure.returnType), + __procedureSchema("get_or_create_store_customer", GetOrCreateStoreCustomerProcedure.params, GetOrCreateStoreCustomerProcedure.returnType), + __procedureSchema("get_store_webhook_event_count", GetStoreWebhookEventCountProcedure.params, GetStoreWebhookEventCountProcedure.returnType), + __procedureSchema("list_store_products_json", ListStoreProductsJsonProcedure.params, ListStoreProductsJsonProcedure.returnType), + __procedureSchema("remove_admin_identity", RemoveAdminIdentityProcedure.params, RemoveAdminIdentityProcedure.returnType), + __procedureSchema("store_stripe_api_request", StoreStripeApiRequestProcedure.params, StoreStripeApiRequestProcedure.returnType), + __procedureSchema("sync_store_products_with_stripe", SyncStoreProductsWithStripeProcedure.params, SyncStoreProductsWithStripeProcedure.returnType), + __procedureSchema("validate_store_stripe_price", ValidateStoreStripePriceProcedure.params, ValidateStoreStripePriceProcedure.returnType), + __procedureSchema("stripe.add_admin_identity", Stripe_AddAdminIdentityProcedure.params, Stripe_AddAdminIdentityProcedure.returnType), + __procedureSchema("stripe.cancel_subscription", Stripe_CancelSubscriptionProcedure.params, Stripe_CancelSubscriptionProcedure.returnType), + __procedureSchema("stripe.create_checkout_session", Stripe_CreateCheckoutSessionProcedure.params, Stripe_CreateCheckoutSessionProcedure.returnType), + __procedureSchema("stripe.create_customer", Stripe_CreateCustomerProcedure.params, Stripe_CreateCustomerProcedure.returnType), + __procedureSchema("stripe.create_customer_portal_session", Stripe_CreateCustomerPortalSessionProcedure.params, Stripe_CreateCustomerPortalSessionProcedure.returnType), + __procedureSchema("stripe.create_or_update_customer", Stripe_CreateOrUpdateCustomerProcedure.params, Stripe_CreateOrUpdateCustomerProcedure.returnType), + __procedureSchema("stripe.get_checkout_session", Stripe_GetCheckoutSessionProcedure.params, Stripe_GetCheckoutSessionProcedure.returnType), + __procedureSchema("stripe.get_customer", Stripe_GetCustomerProcedure.params, Stripe_GetCustomerProcedure.returnType), + __procedureSchema("stripe.get_customer_by_email", Stripe_GetCustomerByEmailProcedure.params, Stripe_GetCustomerByEmailProcedure.returnType), + __procedureSchema("stripe.get_customer_by_user_id", Stripe_GetCustomerByUserIdProcedure.params, Stripe_GetCustomerByUserIdProcedure.returnType), + __procedureSchema("stripe.get_or_create_customer", Stripe_GetOrCreateCustomerProcedure.params, Stripe_GetOrCreateCustomerProcedure.returnType), + __procedureSchema("stripe.get_payment", Stripe_GetPaymentProcedure.params, Stripe_GetPaymentProcedure.returnType), + __procedureSchema("stripe.get_remote_checkout_session", Stripe_GetRemoteCheckoutSessionProcedure.params, Stripe_GetRemoteCheckoutSessionProcedure.returnType), + __procedureSchema("stripe.get_stripe_config_status", Stripe_GetStripeConfigStatusProcedure.params, Stripe_GetStripeConfigStatusProcedure.returnType), + __procedureSchema("stripe.get_subscription", Stripe_GetSubscriptionProcedure.params, Stripe_GetSubscriptionProcedure.returnType), + __procedureSchema("stripe.get_subscription_by_org_id", Stripe_GetSubscriptionByOrgIdProcedure.params, Stripe_GetSubscriptionByOrgIdProcedure.returnType), + __procedureSchema("stripe.get_webhook_event_count", Stripe_GetWebhookEventCountProcedure.params, Stripe_GetWebhookEventCountProcedure.returnType), + __procedureSchema("stripe.list_checkout_sessions", Stripe_ListCheckoutSessionsProcedure.params, Stripe_ListCheckoutSessionsProcedure.returnType), + __procedureSchema("stripe.list_invoices", Stripe_ListInvoicesProcedure.params, Stripe_ListInvoicesProcedure.returnType), + __procedureSchema("stripe.list_invoices_by_org_id", Stripe_ListInvoicesByOrgIdProcedure.params, Stripe_ListInvoicesByOrgIdProcedure.returnType), + __procedureSchema("stripe.list_invoices_by_user_id", Stripe_ListInvoicesByUserIdProcedure.params, Stripe_ListInvoicesByUserIdProcedure.returnType), + __procedureSchema("stripe.list_payments", Stripe_ListPaymentsProcedure.params, Stripe_ListPaymentsProcedure.returnType), + __procedureSchema("stripe.list_payments_by_org_id", Stripe_ListPaymentsByOrgIdProcedure.params, Stripe_ListPaymentsByOrgIdProcedure.returnType), + __procedureSchema("stripe.list_payments_by_user_id", Stripe_ListPaymentsByUserIdProcedure.params, Stripe_ListPaymentsByUserIdProcedure.returnType), + __procedureSchema("stripe.list_subscriptions", Stripe_ListSubscriptionsProcedure.params, Stripe_ListSubscriptionsProcedure.returnType), + __procedureSchema("stripe.list_subscriptions_by_org_id", Stripe_ListSubscriptionsByOrgIdProcedure.params, Stripe_ListSubscriptionsByOrgIdProcedure.returnType), + __procedureSchema("stripe.list_subscriptions_by_user_id", Stripe_ListSubscriptionsByUserIdProcedure.params, Stripe_ListSubscriptionsByUserIdProcedure.returnType), + __procedureSchema("stripe.list_subscriptions_with_creation_time", Stripe_ListSubscriptionsWithCreationTimeProcedure.params, Stripe_ListSubscriptionsWithCreationTimeProcedure.returnType), + __procedureSchema("stripe.reactivate_subscription", Stripe_ReactivateSubscriptionProcedure.params, Stripe_ReactivateSubscriptionProcedure.returnType), + __procedureSchema("stripe.remove_admin_identity", Stripe_RemoveAdminIdentityProcedure.params, Stripe_RemoveAdminIdentityProcedure.returnType), + __procedureSchema("stripe.set_stripe_config", Stripe_SetStripeConfigProcedure.params, Stripe_SetStripeConfigProcedure.returnType), + __procedureSchema("stripe.set_stripe_webhook_signing_secret", Stripe_SetStripeWebhookSigningSecretProcedure.params, Stripe_SetStripeWebhookSigningSecretProcedure.returnType), + __procedureSchema("stripe.stripe_api_request", Stripe_StripeApiRequestProcedure.params, Stripe_StripeApiRequestProcedure.returnType), + __procedureSchema("stripe.update_subscription_metadata", Stripe_UpdateSubscriptionMetadataProcedure.params, Stripe_UpdateSubscriptionMetadataProcedure.returnType), + __procedureSchema("stripe.update_subscription_quantity", Stripe_UpdateSubscriptionQuantityProcedure.params, Stripe_UpdateSubscriptionQuantityProcedure.returnType), + __procedureSchema("stripe.validate_stripe_price", Stripe_ValidateStripePriceProcedure.params, Stripe_ValidateStripePriceProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" as const, + }, + tables: tablesSchema.schemaType.tables, + reducers: reducersSchema.reducersType.reducers, + ...proceduresSchema, +} satisfies __RemoteModule< + typeof tablesSchema.schemaType, + typeof reducersSchema.reducersType, + typeof proceduresSchema +>; + +/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */ +export const tables: __QueryBuilder = __makeQueryBuilder(tablesSchema.schemaType); + +/** The reducers available in this remote SpacetimeDB module. */ +const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); +export const reducers = { + clearStoreProductPrice: __reducerAccessors.clearStoreProductPrice, + seedDefaultStoreProducts: __reducerAccessors.seedDefaultStoreProducts, + setStoreProductPrice: __reducerAccessors.setStoreProductPrice, + upsertStoreProduct: __reducerAccessors.upsertStoreProduct, + stripe: { + ingestStripeWebhook: __reducerAccessors["stripe.ingestStripeWebhook"], + replayWebhookEvent: __reducerAccessors["stripe.replayWebhookEvent"], + updatePaymentCustomer: __reducerAccessors["stripe.updatePaymentCustomer"], + updateSubscriptionQuantityInternal: __reducerAccessors["stripe.updateSubscriptionQuantityInternal"], + upsertCustomer: __reducerAccessors["stripe.upsertCustomer"], + upsertSubscription: __reducerAccessors["stripe.upsertSubscription"], + }, +} as const; + +/** The procedures available in this remote SpacetimeDB module. */ +const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); +export const procedures = { + addAdminIdentity: __procedureAccessors.addAdminIdentity, + configureStripe: __procedureAccessors.configureStripe, + createStoreCheckoutSession: __procedureAccessors.createStoreCheckoutSession, + getOrCreateStoreCustomer: __procedureAccessors.getOrCreateStoreCustomer, + getStoreWebhookEventCount: __procedureAccessors.getStoreWebhookEventCount, + listStoreProductsJson: __procedureAccessors.listStoreProductsJson, + removeAdminIdentity: __procedureAccessors.removeAdminIdentity, + storeStripeApiRequest: __procedureAccessors.storeStripeApiRequest, + syncStoreProductsWithStripe: __procedureAccessors.syncStoreProductsWithStripe, + validateStoreStripePrice: __procedureAccessors.validateStoreStripePrice, + stripe: { + addAdminIdentity: __procedureAccessors["stripe.addAdminIdentity"], + cancelSubscription: __procedureAccessors["stripe.cancelSubscription"], + createCheckoutSession: __procedureAccessors["stripe.createCheckoutSession"], + createCustomer: __procedureAccessors["stripe.createCustomer"], + createCustomerPortalSession: __procedureAccessors["stripe.createCustomerPortalSession"], + createOrUpdateCustomer: __procedureAccessors["stripe.createOrUpdateCustomer"], + getCheckoutSession: __procedureAccessors["stripe.getCheckoutSession"], + getCustomer: __procedureAccessors["stripe.getCustomer"], + getCustomerByEmail: __procedureAccessors["stripe.getCustomerByEmail"], + getCustomerByUserId: __procedureAccessors["stripe.getCustomerByUserId"], + getOrCreateCustomer: __procedureAccessors["stripe.getOrCreateCustomer"], + getPayment: __procedureAccessors["stripe.getPayment"], + getRemoteCheckoutSession: __procedureAccessors["stripe.getRemoteCheckoutSession"], + getStripeConfigStatus: __procedureAccessors["stripe.getStripeConfigStatus"], + getSubscription: __procedureAccessors["stripe.getSubscription"], + getSubscriptionByOrgId: __procedureAccessors["stripe.getSubscriptionByOrgId"], + getWebhookEventCount: __procedureAccessors["stripe.getWebhookEventCount"], + listCheckoutSessions: __procedureAccessors["stripe.listCheckoutSessions"], + listInvoices: __procedureAccessors["stripe.listInvoices"], + listInvoicesByOrgId: __procedureAccessors["stripe.listInvoicesByOrgId"], + listInvoicesByUserId: __procedureAccessors["stripe.listInvoicesByUserId"], + listPayments: __procedureAccessors["stripe.listPayments"], + listPaymentsByOrgId: __procedureAccessors["stripe.listPaymentsByOrgId"], + listPaymentsByUserId: __procedureAccessors["stripe.listPaymentsByUserId"], + listSubscriptions: __procedureAccessors["stripe.listSubscriptions"], + listSubscriptionsByOrgId: __procedureAccessors["stripe.listSubscriptionsByOrgId"], + listSubscriptionsByUserId: __procedureAccessors["stripe.listSubscriptionsByUserId"], + listSubscriptionsWithCreationTime: __procedureAccessors["stripe.listSubscriptionsWithCreationTime"], + reactivateSubscription: __procedureAccessors["stripe.reactivateSubscription"], + removeAdminIdentity: __procedureAccessors["stripe.removeAdminIdentity"], + setStripeConfig: __procedureAccessors["stripe.setStripeConfig"], + setStripeWebhookSigningSecret: __procedureAccessors["stripe.setStripeWebhookSigningSecret"], + stripeApiRequest: __procedureAccessors["stripe.stripeApiRequest"], + updateSubscriptionMetadata: __procedureAccessors["stripe.updateSubscriptionMetadata"], + updateSubscriptionQuantity: __procedureAccessors["stripe.updateSubscriptionQuantity"], + validateStripePrice: __procedureAccessors["stripe.validateStripePrice"], + }, +} as const; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ +export class DbConnection extends __DbConnectionImpl { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/list_store_products_json_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/list_store_products_json_procedure.ts new file mode 100644 index 00000000000..d6933140f3b --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/list_store_products_json_procedure.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { +}; +export const returnType = __t.string() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/remove_admin_identity_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/remove_admin_identity_procedure.ts new file mode 100644 index 00000000000..bfd93108ec4 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/remove_admin_identity_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + identity: __t.identity(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/seed_default_store_products_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/seed_default_store_products_reducer.ts new file mode 100644 index 00000000000..c338c0c1984 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/seed_default_store_products_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + force: __t.option(__t.bool()), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/set_store_product_price_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/set_store_product_price_reducer.ts new file mode 100644 index 00000000000..ed82e1f1eb8 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/set_store_product_price_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + productId: __t.string(), + stripePriceId: __t.string(), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/store_product_table.ts b/spacetime-stripe-ts/example/src/module_bindings/app/store_product_table.ts new file mode 100644 index 00000000000..a566792da32 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/store_product_table.ts @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + productId: __t.string().primaryKey().name("product_id"), + name: __t.string(), + description: __t.string(), + mode: __t.string(), + priceLabel: __t.string().name("price_label"), + stripePriceId: __t.option(__t.string()).name("stripe_price_id"), + perksJson: __t.option(__t.string()).name("perks_json"), + active: __t.bool(), + sortOrder: __t.i64().name("sort_order"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/store_stripe_api_request_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/store_stripe_api_request_procedure.ts new file mode 100644 index 00000000000..403004799f9 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/store_stripe_api_request_procedure.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StoreStripeHttpResponse, +} from "./types"; + +export const params = { + method: __t.string(), + path: __t.string(), + formBody: __t.option(__t.string()), + idempotencyKey: __t.option(__t.string()), +}; +export const returnType = StoreStripeHttpResponse \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/add_admin_identity_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/add_admin_identity_procedure.ts new file mode 100644 index 00000000000..bfd93108ec4 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/add_admin_identity_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + identity: __t.identity(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/cancel_subscription_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/cancel_subscription_procedure.ts new file mode 100644 index 00000000000..d59e794b433 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/cancel_subscription_procedure.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + stripeSubscriptionId: __t.string(), + cancelAtPeriodEnd: __t.option(__t.bool()), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_checkout_session_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_checkout_session_procedure.ts new file mode 100644 index 00000000000..b3e52b631f8 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_checkout_session_procedure.ts @@ -0,0 +1,30 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + CheckoutLineItem, + CheckoutSessionResult, +} from "./types"; + +export const params = { + get items() { + return __t.array(CheckoutLineItem); + }, + customerId: __t.option(__t.string()), + mode: __t.string(), + successUrl: __t.string(), + cancelUrl: __t.string(), + metadataJson: __t.option(__t.string()), + subscriptionMetadataJson: __t.option(__t.string()), + paymentIntentMetadataJson: __t.option(__t.string()), +}; +export const returnType = CheckoutSessionResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_customer_portal_session_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_customer_portal_session_procedure.ts new file mode 100644 index 00000000000..0fc0f271832 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_customer_portal_session_procedure.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + PortalSessionResult, +} from "./types"; + +export const params = { + customerId: __t.string(), + returnUrl: __t.string(), +}; +export const returnType = PortalSessionResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_customer_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_customer_procedure.ts new file mode 100644 index 00000000000..ce18664af55 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_customer_procedure.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + CreateCustomerResult, +} from "./types"; + +export const params = { + email: __t.option(__t.string()), + name: __t.option(__t.string()), + metadataJson: __t.option(__t.string()), + idempotencyKey: __t.option(__t.string()), +}; +export const returnType = CreateCustomerResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_or_update_customer_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_or_update_customer_procedure.ts new file mode 100644 index 00000000000..6f40113aa3c --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_or_update_customer_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + stripeCustomerId: __t.string(), + email: __t.option(__t.string()), + name: __t.option(__t.string()), + metadataJson: __t.option(__t.string()), +}; +export const returnType = __t.string() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_checkout_session_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_checkout_session_procedure.ts new file mode 100644 index 00000000000..7cfe49f0fef --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_checkout_session_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeCheckoutSession, +} from "./types"; + +export const params = { + stripeCheckoutSessionId: __t.string(), +}; +export const returnType = __t.option(StripeCheckoutSession) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_by_email_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_by_email_procedure.ts new file mode 100644 index 00000000000..c385e289a71 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_by_email_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeCustomer, +} from "./types"; + +export const params = { + email: __t.string(), +}; +export const returnType = __t.option(StripeCustomer) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_by_user_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_by_user_id_procedure.ts new file mode 100644 index 00000000000..11e4cd18c67 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_by_user_id_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeCustomer, +} from "./types"; + +export const params = { + userId: __t.string(), +}; +export const returnType = __t.option(StripeCustomer) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_procedure.ts new file mode 100644 index 00000000000..74f769984fa --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeCustomer, +} from "./types"; + +export const params = { + stripeCustomerId: __t.string(), +}; +export const returnType = __t.option(StripeCustomer) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_or_create_customer_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_or_create_customer_procedure.ts new file mode 100644 index 00000000000..1f6ece8f9e7 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_or_create_customer_procedure.ts @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + GetOrCreateCustomerResult, +} from "./types"; + +export const params = { + userId: __t.string(), + email: __t.option(__t.string()), + name: __t.option(__t.string()), +}; +export const returnType = GetOrCreateCustomerResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_payment_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_payment_procedure.ts new file mode 100644 index 00000000000..4468f783e36 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_payment_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripePayment, +} from "./types"; + +export const params = { + stripePaymentIntentId: __t.string(), +}; +export const returnType = __t.option(StripePayment) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_remote_checkout_session_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_remote_checkout_session_procedure.ts new file mode 100644 index 00000000000..8b01ada5924 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_remote_checkout_session_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + RemoteCheckoutSessionResult, +} from "./types"; + +export const params = { + sessionId: __t.string(), +}; +export const returnType = RemoteCheckoutSessionResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_stripe_config_status_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_stripe_config_status_procedure.ts new file mode 100644 index 00000000000..38405892656 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_stripe_config_status_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeConfigStatus, +} from "./types"; + +export const params = { +}; +export const returnType = StripeConfigStatus \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_subscription_by_org_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_subscription_by_org_id_procedure.ts new file mode 100644 index 00000000000..ed0cbdf5244 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_subscription_by_org_id_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeSubscription, +} from "./types"; + +export const params = { + orgId: __t.string(), +}; +export const returnType = __t.option(StripeSubscription) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_subscription_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_subscription_procedure.ts new file mode 100644 index 00000000000..fe90f17b760 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_subscription_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeSubscription, +} from "./types"; + +export const params = { + stripeSubscriptionId: __t.string(), +}; +export const returnType = __t.option(StripeSubscription) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_webhook_event_count_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_webhook_event_count_procedure.ts new file mode 100644 index 00000000000..c6724c84a1b --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_webhook_event_count_procedure.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { +}; +export const returnType = __t.i64() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/ingest_stripe_webhook_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/ingest_stripe_webhook_reducer.ts new file mode 100644 index 00000000000..e27fe18a79e --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/ingest_stripe_webhook_reducer.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + eventId: __t.string(), + eventType: __t.string(), + livemode: __t.bool(), + payloadJson: __t.string(), + signatureHeader: __t.option(__t.string()), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_checkout_sessions_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_checkout_sessions_procedure.ts new file mode 100644 index 00000000000..a378145917a --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_checkout_sessions_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeCheckoutSession, +} from "./types"; + +export const params = { + stripeCustomerId: __t.string(), +}; +export const returnType = __t.array(StripeCheckoutSession) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_by_org_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_by_org_id_procedure.ts new file mode 100644 index 00000000000..e4c68004285 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_by_org_id_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeInvoice, +} from "./types"; + +export const params = { + orgId: __t.string(), +}; +export const returnType = __t.array(StripeInvoice) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_by_user_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_by_user_id_procedure.ts new file mode 100644 index 00000000000..2c263236bc2 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_by_user_id_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeInvoice, +} from "./types"; + +export const params = { + userId: __t.string(), +}; +export const returnType = __t.array(StripeInvoice) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_procedure.ts new file mode 100644 index 00000000000..76a084f415f --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeInvoice, +} from "./types"; + +export const params = { + stripeCustomerId: __t.string(), +}; +export const returnType = __t.array(StripeInvoice) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_by_org_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_by_org_id_procedure.ts new file mode 100644 index 00000000000..67186316774 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_by_org_id_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripePayment, +} from "./types"; + +export const params = { + orgId: __t.string(), +}; +export const returnType = __t.array(StripePayment) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_by_user_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_by_user_id_procedure.ts new file mode 100644 index 00000000000..e1f5a7244f9 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_by_user_id_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripePayment, +} from "./types"; + +export const params = { + userId: __t.string(), +}; +export const returnType = __t.array(StripePayment) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_procedure.ts new file mode 100644 index 00000000000..aa6f5dde97b --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripePayment, +} from "./types"; + +export const params = { + stripeCustomerId: __t.string(), +}; +export const returnType = __t.array(StripePayment) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_by_org_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_by_org_id_procedure.ts new file mode 100644 index 00000000000..0a31e4dc4a0 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_by_org_id_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeSubscription, +} from "./types"; + +export const params = { + orgId: __t.string(), +}; +export const returnType = __t.array(StripeSubscription) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_by_user_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_by_user_id_procedure.ts new file mode 100644 index 00000000000..5c71c9018aa --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_by_user_id_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeSubscription, +} from "./types"; + +export const params = { + userId: __t.string(), +}; +export const returnType = __t.array(StripeSubscription) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_procedure.ts new file mode 100644 index 00000000000..cd3f6d99e0a --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeSubscription, +} from "./types"; + +export const params = { + stripeCustomerId: __t.string(), +}; +export const returnType = __t.array(StripeSubscription) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_with_creation_time_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_with_creation_time_procedure.ts new file mode 100644 index 00000000000..01b17ab972b --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_with_creation_time_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + SubscriptionWithCreationTime, +} from "./types"; + +export const params = { + stripeCustomerId: __t.string(), +}; +export const returnType = __t.array(SubscriptionWithCreationTime) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/reactivate_subscription_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/reactivate_subscription_procedure.ts new file mode 100644 index 00000000000..c4f6413f649 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/reactivate_subscription_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + stripeSubscriptionId: __t.string(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/remove_admin_identity_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/remove_admin_identity_procedure.ts new file mode 100644 index 00000000000..bfd93108ec4 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/remove_admin_identity_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + identity: __t.identity(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/replay_webhook_event_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/replay_webhook_event_reducer.ts new file mode 100644 index 00000000000..590a453de82 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/replay_webhook_event_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + eventId: __t.string(), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/set_stripe_config_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/set_stripe_config_procedure.ts new file mode 100644 index 00000000000..e9fc16b27f5 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/set_stripe_config_procedure.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + secretKey: __t.string(), + stripeVersion: __t.option(__t.string()), + webhookSigningSecret: __t.option(__t.string()), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/set_stripe_webhook_signing_secret_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/set_stripe_webhook_signing_secret_procedure.ts new file mode 100644 index 00000000000..1ea293a0540 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/set_stripe_webhook_signing_secret_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + webhookSigningSecret: __t.string(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/stripe_api_request_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/stripe_api_request_procedure.ts new file mode 100644 index 00000000000..84d6bbac6f4 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/stripe_api_request_procedure.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeHttpResponse, +} from "./types"; + +export const params = { + method: __t.string(), + path: __t.string(), + formBody: __t.option(__t.string()), + idempotencyKey: __t.option(__t.string()), +}; +export const returnType = StripeHttpResponse \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/types.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/types.ts new file mode 100644 index 00000000000..8ba127bc1c1 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/types.ts @@ -0,0 +1,202 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const CheckoutLineItem = __t.object("CheckoutLineItem", { + priceId: __t.string(), + quantity: __t.i64(), +}); +export type CheckoutLineItem = __Infer; + +export const CheckoutSessionResult = __t.object("CheckoutSessionResult", { + sessionId: __t.string(), + url: __t.option(__t.string()), +}); +export type CheckoutSessionResult = __Infer; + +export const CreateCustomerResult = __t.object("CreateCustomerResult", { + customerId: __t.string(), +}); +export type CreateCustomerResult = __Infer; + +export const GetOrCreateCustomerResult = __t.object("GetOrCreateCustomerResult", { + customerId: __t.string(), + isNew: __t.bool(), +}); +export type GetOrCreateCustomerResult = __Infer; + +export const PortalSessionResult = __t.object("PortalSessionResult", { + url: __t.string(), +}); +export type PortalSessionResult = __Infer; + +export const RemoteCheckoutSessionResult = __t.object("RemoteCheckoutSessionResult", { + ok: __t.bool(), + status: __t.u16(), + sessionId: __t.option(__t.string()), + paymentStatus: __t.option(__t.string()), + sessionStatus: __t.option(__t.string()), + mode: __t.option(__t.string()), + amountTotal: __t.option(__t.i64()), + currency: __t.option(__t.string()), + customerId: __t.option(__t.string()), + paymentIntentId: __t.option(__t.string()), + message: __t.option(__t.string()), + code: __t.option(__t.string()), + errorType: __t.option(__t.string()), +}); +export type RemoteCheckoutSessionResult = __Infer; + +export const StripeAdminIdentity = __t.object("StripeAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type StripeAdminIdentity = __Infer; + +export const StripeCheckoutSession = __t.object("StripeCheckoutSession", { + stripeCheckoutSessionId: __t.string(), + stripeCustomerId: __t.option(__t.string()), + status: __t.string(), + mode: __t.string(), + metadataJson: __t.option(__t.string()), + insertedAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type StripeCheckoutSession = __Infer; + +export const StripeConfig = __t.object("StripeConfig", { + singleton: __t.bool(), + secretKey: __t.string(), + stripeVersion: __t.option(__t.string()), + webhookSigningSecret: __t.option(__t.string()), + updatedAt: __t.timestamp(), +}); +export type StripeConfig = __Infer; + +export const StripeConfigStatus = __t.object("StripeConfigStatus", { + isConfigured: __t.bool(), + hasWebhookSecret: __t.bool(), + stripeVersion: __t.option(__t.string()), + secretKeyLength: __t.u16(), +}); +export type StripeConfigStatus = __Infer; + +export const StripeCustomer = __t.object("StripeCustomer", { + stripeCustomerId: __t.string(), + appUserId: __t.option(__t.string()), + email: __t.option(__t.string()), + name: __t.option(__t.string()), + metadataJson: __t.option(__t.string()), + userId: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type StripeCustomer = __Infer; + +export const StripeHttpResponse = __t.object("StripeHttpResponse", { + status: __t.u16(), + body: __t.string(), +}); +export type StripeHttpResponse = __Infer; + +export const StripeInvoice = __t.object("StripeInvoice", { + stripeInvoiceId: __t.string(), + stripeCustomerId: __t.string(), + stripeSubscriptionId: __t.option(__t.string()), + status: __t.string(), + amountDue: __t.i64(), + amountPaid: __t.i64(), + createdUnix: __t.i64(), + orgId: __t.option(__t.string()), + userId: __t.option(__t.string()), + insertedAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type StripeInvoice = __Infer; + +export const StripePayment = __t.object("StripePayment", { + stripePaymentIntentId: __t.string(), + stripeCustomerId: __t.option(__t.string()), + amount: __t.i64(), + currency: __t.string(), + status: __t.string(), + createdUnix: __t.i64(), + metadataJson: __t.option(__t.string()), + orgId: __t.option(__t.string()), + userId: __t.option(__t.string()), + insertedAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type StripePayment = __Infer; + +export const StripeSubscription = __t.object("StripeSubscription", { + stripeSubscriptionId: __t.string(), + stripeCustomerId: __t.string(), + status: __t.string(), + currentPeriodEndUnix: __t.i64(), + cancelAtPeriodEnd: __t.bool(), + cancelAtUnix: __t.option(__t.i64()), + quantity: __t.option(__t.i64()), + priceId: __t.option(__t.string()), + metadataJson: __t.option(__t.string()), + orgId: __t.option(__t.string()), + userId: __t.option(__t.string()), + insertedAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type StripeSubscription = __Infer; + +export const StripeWebhookEvent = __t.object("StripeWebhookEvent", { + eventId: __t.string(), + eventType: __t.string(), + livemode: __t.bool(), + signatureHeader: __t.option(__t.string()), + payloadJson: __t.string(), + get status() { + return WebhookEventStatus; + }, + errorMessage: __t.option(__t.string()), + receivedAt: __t.timestamp(), + processedAt: __t.option(__t.timestamp()), +}); +export type StripeWebhookEvent = __Infer; + +export const SubscriptionWithCreationTime = __t.object("SubscriptionWithCreationTime", { + insertedAtMicros: __t.i64(), + stripeSubscriptionId: __t.string(), + stripeCustomerId: __t.string(), + status: __t.string(), +}); +export type SubscriptionWithCreationTime = __Infer; + +export const ValidateStripePriceResult = __t.object("ValidateStripePriceResult", { + valid: __t.bool(), + status: __t.u16(), + active: __t.option(__t.bool()), + currency: __t.option(__t.string()), + unitAmount: __t.option(__t.i64()), + livemode: __t.option(__t.bool()), + type: __t.option(__t.string()), + message: __t.option(__t.string()), + code: __t.option(__t.string()), + errorType: __t.option(__t.string()), +}); +export type ValidateStripePriceResult = __Infer; + +// The tagged union or sum type for the algebraic type `WebhookEventStatus`. +export const WebhookEventStatus = __t.enum("WebhookEventStatus", { + Received: __t.unit(), + Processed: __t.unit(), + Ignored: __t.unit(), + Failed: __t.unit(), +}); +export type WebhookEventStatus = __Infer; + diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_payment_customer_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_payment_customer_reducer.ts new file mode 100644 index 00000000000..474aa9ddead --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_payment_customer_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + stripePaymentIntentId: __t.string(), + stripeCustomerId: __t.string(), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_metadata_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_metadata_procedure.ts new file mode 100644 index 00000000000..519234571e9 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_metadata_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + stripeSubscriptionId: __t.string(), + metadataJson: __t.string(), + orgId: __t.option(__t.string()), + userId: __t.option(__t.string()), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_quantity_internal_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_quantity_internal_reducer.ts new file mode 100644 index 00000000000..88e2ca53849 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_quantity_internal_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + stripeSubscriptionId: __t.string(), + quantity: __t.i64(), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_quantity_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_quantity_procedure.ts new file mode 100644 index 00000000000..3db95ceaa68 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_quantity_procedure.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + stripeSubscriptionId: __t.string(), + quantity: __t.i64(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/upsert_customer_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/upsert_customer_reducer.ts new file mode 100644 index 00000000000..d572ac2d9ac --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/upsert_customer_reducer.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + stripeCustomerId: __t.string(), + appUserId: __t.option(__t.string()), + email: __t.option(__t.string()), + name: __t.option(__t.string()), + metadataJson: __t.option(__t.string()), + userId: __t.option(__t.string()), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/upsert_subscription_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/upsert_subscription_reducer.ts new file mode 100644 index 00000000000..bf4b5c3741b --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/upsert_subscription_reducer.ts @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + stripeSubscriptionId: __t.string(), + stripeCustomerId: __t.string(), + status: __t.string(), + currentPeriodEndUnix: __t.i64(), + cancelAtPeriodEnd: __t.bool(), + cancelAtUnix: __t.option(__t.i64()), + quantity: __t.option(__t.i64()), + priceId: __t.option(__t.string()), + metadataJson: __t.option(__t.string()), + orgId: __t.option(__t.string()), + userId: __t.option(__t.string()), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/validate_stripe_price_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/validate_stripe_price_procedure.ts new file mode 100644 index 00000000000..c6e5ab54119 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/validate_stripe_price_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + ValidateStripePriceResult, +} from "./types"; + +export const params = { + priceId: __t.string(), +}; +export const returnType = ValidateStripePriceResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/sync_store_products_with_stripe_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/sync_store_products_with_stripe_procedure.ts new file mode 100644 index 00000000000..d6933140f3b --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/sync_store_products_with_stripe_procedure.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { +}; +export const returnType = __t.string() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/types.ts b/spacetime-stripe-ts/example/src/module_bindings/app/types.ts new file mode 100644 index 00000000000..c3985719fc6 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/types.ts @@ -0,0 +1,71 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const StoreAdminIdentity = __t.object("StoreAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type StoreAdminIdentity = __Infer; + +export const StoreCheckoutLineItem = __t.object("StoreCheckoutLineItem", { + priceId: __t.string(), + quantity: __t.i64(), +}); +export type StoreCheckoutLineItem = __Infer; + +export const StoreCheckoutSessionResult = __t.object("StoreCheckoutSessionResult", { + sessionId: __t.string(), + url: __t.option(__t.string()), +}); +export type StoreCheckoutSessionResult = __Infer; + +export const StoreGetOrCreateCustomerResult = __t.object("StoreGetOrCreateCustomerResult", { + customerId: __t.string(), + isNew: __t.bool(), +}); +export type StoreGetOrCreateCustomerResult = __Infer; + +export const StoreProduct = __t.object("StoreProduct", { + productId: __t.string(), + name: __t.string(), + description: __t.string(), + mode: __t.string(), + priceLabel: __t.string(), + stripePriceId: __t.option(__t.string()), + perksJson: __t.option(__t.string()), + active: __t.bool(), + sortOrder: __t.i64(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type StoreProduct = __Infer; + +export const StoreStripeHttpResponse = __t.object("StoreStripeHttpResponse", { + status: __t.u16(), + body: __t.string(), +}); +export type StoreStripeHttpResponse = __Infer; + +export const StoreValidateStripePriceResult = __t.object("StoreValidateStripePriceResult", { + valid: __t.bool(), + status: __t.u16(), + active: __t.option(__t.bool()), + currency: __t.option(__t.string()), + unitAmount: __t.option(__t.i64()), + livemode: __t.option(__t.bool()), + type: __t.option(__t.string()), + message: __t.option(__t.string()), + code: __t.option(__t.string()), + errorType: __t.option(__t.string()), +}); +export type StoreValidateStripePriceResult = __Infer; + diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/types/procedures.ts b/spacetime-stripe-ts/example/src/module_bindings/app/types/procedures.ts new file mode 100644 index 00000000000..d734e416b32 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/types/procedures.ts @@ -0,0 +1,40 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all procedure arg schemas +import * as AddAdminIdentityProcedure from "../add_admin_identity_procedure"; +import * as ConfigureStripeProcedure from "../configure_stripe_procedure"; +import * as CreateStoreCheckoutSessionProcedure from "../create_store_checkout_session_procedure"; +import * as GetOrCreateStoreCustomerProcedure from "../get_or_create_store_customer_procedure"; +import * as GetStoreWebhookEventCountProcedure from "../get_store_webhook_event_count_procedure"; +import * as ListStoreProductsJsonProcedure from "../list_store_products_json_procedure"; +import * as RemoveAdminIdentityProcedure from "../remove_admin_identity_procedure"; +import * as StoreStripeApiRequestProcedure from "../store_stripe_api_request_procedure"; +import * as SyncStoreProductsWithStripeProcedure from "../sync_store_products_with_stripe_procedure"; +import * as ValidateStoreStripePriceProcedure from "../validate_store_stripe_price_procedure"; + +export type AddAdminIdentityArgs = __Infer; +export type AddAdminIdentityResult = __Infer; +export type ConfigureStripeArgs = __Infer; +export type ConfigureStripeResult = __Infer; +export type CreateStoreCheckoutSessionArgs = __Infer; +export type CreateStoreCheckoutSessionResult = __Infer; +export type GetOrCreateStoreCustomerArgs = __Infer; +export type GetOrCreateStoreCustomerResult = __Infer; +export type GetStoreWebhookEventCountArgs = __Infer; +export type GetStoreWebhookEventCountResult = __Infer; +export type ListStoreProductsJsonArgs = __Infer; +export type ListStoreProductsJsonResult = __Infer; +export type RemoveAdminIdentityArgs = __Infer; +export type RemoveAdminIdentityResult = __Infer; +export type StoreStripeApiRequestArgs = __Infer; +export type StoreStripeApiRequestResult = __Infer; +export type SyncStoreProductsWithStripeArgs = __Infer; +export type SyncStoreProductsWithStripeResult = __Infer; +export type ValidateStoreStripePriceArgs = __Infer; +export type ValidateStoreStripePriceResult = __Infer; + diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/types/reducers.ts b/spacetime-stripe-ts/example/src/module_bindings/app/types/reducers.ts new file mode 100644 index 00000000000..3e4ef971003 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/types/reducers.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all reducer arg schemas +import ClearStoreProductPriceReducer from "../clear_store_product_price_reducer"; +import SeedDefaultStoreProductsReducer from "../seed_default_store_products_reducer"; +import SetStoreProductPriceReducer from "../set_store_product_price_reducer"; +import UpsertStoreProductReducer from "../upsert_store_product_reducer"; + +export type ClearStoreProductPriceParams = __Infer; +export type SeedDefaultStoreProductsParams = __Infer; +export type SetStoreProductPriceParams = __Infer; +export type UpsertStoreProductParams = __Infer; + diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/upsert_store_product_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/upsert_store_product_reducer.ts new file mode 100644 index 00000000000..fb8d352441e --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/upsert_store_product_reducer.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + productId: __t.string(), + name: __t.string(), + description: __t.string(), + mode: __t.string(), + priceLabel: __t.string(), + stripePriceId: __t.option(__t.string()), + perksJson: __t.option(__t.string()), + active: __t.option(__t.bool()), + sortOrder: __t.option(__t.i64()), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/validate_store_stripe_price_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/validate_store_stripe_price_procedure.ts new file mode 100644 index 00000000000..fe8916221d8 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/validate_store_stripe_price_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StoreValidateStripePriceResult, +} from "./types"; + +export const params = { + priceId: __t.string(), +}; +export const returnType = StoreValidateStripePriceResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/tsconfig.json b/spacetime-stripe-ts/example/tsconfig.json new file mode 100644 index 00000000000..9f5cdd8aac1 --- /dev/null +++ b/spacetime-stripe-ts/example/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "isolatedModules": true, + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src/**/*.ts", "server.ts"], + "exclude": ["node_modules", "public", "dist"] +} diff --git a/spacetime-stripe-ts/package.json b/spacetime-stripe-ts/package.json new file mode 100644 index 00000000000..863f97620eb --- /dev/null +++ b/spacetime-stripe-ts/package.json @@ -0,0 +1,73 @@ +{ + "name": "@spacetimedb/stripe", + "description": "Stripe catalog, checkout, customer, subscription, and webhook primitives for SpacetimeDB TypeScript modules.", + "version": "0.1.0", + "license": "BUSL-1.1", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./submodule": { + "types": "./src/submodule.ts", + "default": "./src/submodule.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-stripe-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-stripe-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "stripe", + "payments", + "webhooks" + ], + "scripts": { + "build": "spacetime build", + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "tsx scripts/test-unit.ts", + "spacetime:generate": "spacetime generate --lang typescript --out-dir ts-codegen", + "publish:module": "spacetime publish", + "publish:local": "spacetime publish --server local --yes spacetime-stripe", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-stripe", + "test:smoke": "tsx scripts/test-stripe-smoke.ts", + "test:stripe:e2e": "tsx scripts/test-stripe-e2e.ts", + "test:gate:local": "spacetime build && spacetime publish --server local --yes spacetime-stripe && tsx scripts/test-stripe-e2e.ts --skip-build-publish --skip-checkout-trigger --events customer.created --kill-existing-stripe-listeners" + }, + "dependencies": { + "@spacetimedb/crypto": "workspace:^", + "valibot": "^1.4.2" + }, + "peerDependencies": { + "spacetimedb": "workspace:^" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^22.10.2", + "spacetimedb": "workspace:*", + "stripe": "^22.1.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-stripe-ts/scripts/test-stripe-e2e.ts b/spacetime-stripe-ts/scripts/test-stripe-e2e.ts new file mode 100644 index 00000000000..4e9540086a1 --- /dev/null +++ b/spacetime-stripe-ts/scripts/test-stripe-e2e.ts @@ -0,0 +1,763 @@ +import * as http from 'node:http'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { existsSync } from 'node:fs'; + +type Options = { + server: string; + database: string; + relayPort: number; + stripeCliPath: string; + stripeProjectName?: string; + stripeApiKey?: string; + events: string[]; + deliveryWaitSeconds: number; + listenerWarmupSeconds: number; + skipBuildPublish: boolean; + skipCheckoutTrigger: boolean; + killExistingStripeListeners: boolean; +}; + +type CmdResult = { + code: number; + stdout: string; + stderr: string; +}; + +type ProcessedRelayEvent = { + eventId: string; + eventType: string; + objectId?: string; +}; + +const DEFAULT_EVENTS = [ + 'customer.created', + 'customer.subscription.created', + 'payment_intent.succeeded', + 'checkout.session.completed', + 'invoice.paid', +]; +const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; + +function step(name: string) { + process.stdout.write(`\n==> ${name}\n`); +} + +function info(line: string) { + process.stdout.write(` ${line}\n`); +} + +function tailLines(text: string, count: number) { + return text.split(/\r?\n/).filter(Boolean).slice(-count); +} + +function normalizeFlag(flag: string) { + return flag.replace(/^-+/, '').toLowerCase(); +} + +function parseArgs(argv: string[]): Options { + const opts: Options = { + server: 'local', + database: 'stripe-ts-e2e', + relayPort: 12111, + stripeCliPath: 'stripe', + events: [...DEFAULT_EVENTS], + deliveryWaitSeconds: 20, + listenerWarmupSeconds: 3, + skipBuildPublish: false, + skipCheckoutTrigger: false, + killExistingStripeListeners: false, + }; + + const takeValue = (i: number, flag: string) => { + if (i + 1 >= argv.length) { + throw new Error(`Missing value for --${flag}`); + } + return argv[i + 1]!; + }; + + for (let i = 0; i < argv.length; i++) { + const raw = argv[i]!; + if (raw === '--') continue; + if (!raw.startsWith('-')) continue; + const flag = normalizeFlag(raw); + + if (flag === 'help' || flag === 'h') { + process.stdout.write(`Usage: + pnpm run test:stripe:e2e -- [options] + +Options: + --server + --database + --relay-port + --stripe-cli-path + --stripe-project-name + --stripe-api-key + --events + --delivery-wait-seconds + --listener-warmup-seconds + --skip-build-publish + --skip-checkout-trigger + --kill-existing-stripe-listeners +`); + process.exit(0); + } + + if (flag === 'skipbuildpublish' || flag === 'skip-build-publish') { + opts.skipBuildPublish = true; + continue; + } + if (flag === 'skipcheckouttrigger' || flag === 'skip-checkout-trigger') { + opts.skipCheckoutTrigger = true; + continue; + } + if ( + flag === 'killexistingstripelisteners' || + flag === 'kill-existing-stripe-listeners' + ) { + opts.killExistingStripeListeners = true; + continue; + } + + const value = takeValue(i, flag); + i++; + + switch (flag) { + case 'server': + opts.server = value; + break; + case 'database': + opts.database = value; + break; + case 'relayport': + case 'relay-port': + opts.relayPort = Number.parseInt(value, 10); + break; + case 'stripeclipath': + case 'stripe-cli-path': + opts.stripeCliPath = value; + break; + case 'stripeprojectname': + case 'stripe-project-name': + opts.stripeProjectName = value; + break; + case 'stripeapikey': + case 'stripe-api-key': + opts.stripeApiKey = value; + break; + case 'events': + opts.events = value + .split(',') + .map(v => v.trim()) + .filter(Boolean); + break; + case 'deliverywaitseconds': + case 'delivery-wait-seconds': + opts.deliveryWaitSeconds = Number.parseInt(value, 10); + break; + case 'listenerwarmupseconds': + case 'listener-warmup-seconds': + opts.listenerWarmupSeconds = Number.parseInt(value, 10); + break; + default: + throw new Error(`Unknown flag: ${raw}`); + } + } + + if (!Number.isFinite(opts.relayPort) || opts.relayPort <= 0) { + throw new Error('relay port must be a positive integer'); + } + if ( + !Number.isFinite(opts.deliveryWaitSeconds) || + opts.deliveryWaitSeconds <= 0 + ) { + throw new Error('delivery wait seconds must be a positive integer'); + } + if ( + !Number.isFinite(opts.listenerWarmupSeconds) || + opts.listenerWarmupSeconds < 0 + ) { + throw new Error('listener warmup seconds must be >= 0'); + } + if (opts.events.length === 0) { + throw new Error('at least one event is required'); + } + + return opts; +} + +function spawnCapture( + file: string, + args: string[], + options?: { cwd?: string; env?: NodeJS.ProcessEnv } +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(file, args, { + cwd: options?.cwd, + env: options?.env, + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32' && file.endsWith('.cmd'), + windowsHide: true, + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => { + stdout += chunk.toString(); + }); + child.stderr.on('data', chunk => { + stderr += chunk.toString(); + }); + child.on('error', reject); + child.on('close', code => { + resolve({ code: code ?? -1, stdout, stderr }); + }); + }); +} + +function spawnInherit( + file: string, + args: string[], + options?: { cwd?: string; env?: NodeJS.ProcessEnv } +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(file, args, { + cwd: options?.cwd, + env: options?.env, + stdio: 'inherit', + shell: process.platform === 'win32' && file.endsWith('.cmd'), + windowsHide: false, + }); + child.on('error', reject); + child.on('close', code => resolve(code ?? -1)); + }); +} + +async function runChecked( + file: string, + args: string[], + options?: { cwd?: string; env?: NodeJS.ProcessEnv } +) { + info([file, ...args].join(' ')); + const code = await spawnInherit(file, args, options); + if (code !== 0) { + throw new Error(`Command failed with exit code ${code}: ${file}`); + } +} + +async function checkCommand(file: string, args: string[]) { + const out = await spawnCapture(file, args); + if (out.code !== 0) { + throw new Error(`Required command not runnable: ${file}`); + } +} + +async function getWebhookEventCount(server: string, database: string) { + return getSqlCount( + server, + database, + 'select count(*) as c from stripe_webhook_event' + ); +} + +async function getSqlCount(server: string, database: string, query: string) { + const result = await spawnCapture('spacetime', [ + 'sql', + '--server', + server, + database, + query, + ]); + if (result.code !== 0) { + throw new Error( + `Failed SQL query.\n${query}\n${result.stderr || result.stdout}` + ); + } + const merged = `${result.stdout}\n${result.stderr}`; + const lines = merged.split(/\r?\n/); + const numberLine = lines.find(line => /^\s*\d+\s*$/.test(line)); + if (!numberLine) { + throw new Error( + `Could not parse SQL count output.\nQuery: ${query}\n${merged}` + ); + } + return Number.parseInt(numberLine.trim(), 10); +} + +function sqlStringLiteral(value: string) { + return `'${value.replace(/'/g, "''")}'`; +} + +async function assertCountAtLeast( + server: string, + database: string, + query: string, + min: number, + label: string +) { + const count = await getSqlCount(server, database, query); + if (count < min) { + throw new Error( + `Assertion failed: ${label}. Expected >= ${min}, got ${count}. Query: ${query}` + ); + } +} + +async function assertEventWorkflowState( + server: string, + database: string, + event: ProcessedRelayEvent +) { + const eventIdLiteral = sqlStringLiteral(event.eventId); + await assertCountAtLeast( + server, + database, + `select count(*) as c from stripe_webhook_event where event_id = ${eventIdLiteral}`, + 1, + `webhook event row exists for ${event.eventId}` + ); + + if (!event.objectId) return; + const objectIdLiteral = sqlStringLiteral(event.objectId); + switch (event.eventType) { + case 'customer.created': + case 'customer.updated': + await assertCountAtLeast( + server, + database, + `select count(*) as c from stripe_customer where stripe_customer_id = ${objectIdLiteral}`, + 1, + `customer row exists for ${event.objectId}` + ); + return; + case 'customer.subscription.created': + case 'customer.subscription.updated': + case 'customer.subscription.deleted': + await assertCountAtLeast( + server, + database, + `select count(*) as c from stripe_subscription where stripe_subscription_id = ${objectIdLiteral}`, + 1, + `subscription row exists for ${event.objectId}` + ); + return; + case 'checkout.session.completed': + await assertCountAtLeast( + server, + database, + `select count(*) as c from stripe_checkout_session where stripe_checkout_session_id = ${objectIdLiteral}`, + 1, + `checkout session row exists for ${event.objectId}` + ); + return; + case 'payment_intent.succeeded': + // Invoice-attached and recent-subscription payment intents are + // ignored to avoid duplicate or orphan payment rows. The + // aggregate assertion in main verifies that at least one standalone + // trigger produced a payment row. + return; + case 'invoice.created': + case 'invoice.finalized': + case 'invoice.paid': + case 'invoice.payment_succeeded': + case 'invoice.payment_failed': + await assertCountAtLeast( + server, + database, + `select count(*) as c from stripe_invoice where stripe_invoice_id = ${objectIdLiteral}`, + 1, + `invoice row exists for ${event.objectId}` + ); + return; + default: + return; + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const moduleRoot = process.cwd(); + const relayOutput: string[] = []; + let stripeListen: ReturnType | undefined; + let stripeStdout = ''; + let stripeStderr = ''; + let relayServer: http.Server | undefined; + let relayHits = 0; + const processedEvents: ProcessedRelayEvent[] = []; + let triggeredEvents: string[] = []; + + try { + step('Check required CLI tools'); + await checkCommand('spacetime', ['--help']); + if (!options.skipBuildPublish) { + await checkCommand(pnpmCommand, ['--version']); + } + if (options.stripeCliPath === 'stripe') { + await checkCommand('stripe', ['version']); + } else if (!existsSync(options.stripeCliPath)) { + throw new Error(`Stripe CLI not found at path: ${options.stripeCliPath}`); + } + + step('Clean stale Stripe listeners'); + if (options.killExistingStripeListeners) { + const kill = await spawnCapture('taskkill', [ + '/F', + '/IM', + 'stripe.exe', + '/T', + ]); + if (kill.code === 0) { + info('stopped existing stripe.exe processes.'); + } else { + info('no existing stripe.exe processes to stop (or no permission).'); + } + } else { + info('skipped (pass --kill-existing-stripe-listeners to force cleanup).'); + } + + if (!options.skipBuildPublish) { + step('Build module'); + await runChecked(pnpmCommand, ['run', 'build'], { cwd: moduleRoot }); + + step('Publish module locally'); + await runChecked( + 'spacetime', + ['publish', '--server', options.server, '--yes', options.database], + { cwd: moduleRoot } + ); + } + + step('Read current webhook event count'); + const beforeCount = await getWebhookEventCount( + options.server, + options.database + ); + const beforePaymentCount = await getSqlCount( + options.server, + options.database, + 'select count(*) as c from stripe_payment' + ); + info(`Current webhook rows: ${beforeCount}`); + + step('Start local relay endpoint'); + relayServer = http.createServer( + async (req: http.IncomingMessage, res: http.ServerResponse) => { + if (req.method !== 'POST' || req.url !== '/stripe-webhook/') { + res.statusCode = 404; + res.end('not found'); + return; + } + + const chunks: Buffer[] = []; + const signatureHeader = req.headers['stripe-signature']; + req.on('data', (chunk: Buffer) => chunks.push(Buffer.from(chunk))); + await once(req, 'end'); + relayHits++; + + let event: Record; + let rawBody: string; + try { + rawBody = Buffer.concat(chunks).toString('utf8'); + const parsed: unknown = JSON.parse(rawBody); + if (typeof parsed !== 'object' || parsed === null) { + throw new Error('event must be an object'); + } + event = parsed as Record; + } catch (err) { + const msg = `relay error status=400 msg=invalid-json: ${String(err)}`; + relayOutput.push(msg); + res.statusCode = 400; + res.end('invalid json'); + return; + } + + if (typeof event.id !== 'string' || typeof event.type !== 'string') { + const msg = 'relay error status=400 msg=missing-event-id-or-type'; + relayOutput.push(msg); + res.statusCode = 400; + res.end('invalid event'); + return; + } + if (typeof signatureHeader !== 'string' || !signatureHeader) { + relayOutput.push( + `relay event id=${event.id} type=${event.type} status=400 missing-signature` + ); + res.statusCode = 400; + res.end('missing signature'); + return; + } + + const callArgs = [ + 'call', + '--server', + options.server, + options.database, + 'ingest_stripe_webhook', + JSON.stringify(String(event.id)), + JSON.stringify(String(event.type)), + event.livemode === true ? 'true' : 'false', + JSON.stringify(rawBody), + JSON.stringify({ some: signatureHeader }), + ]; + + const ingest = await spawnCapture('spacetime', callArgs); + if (ingest.code !== 0) { + const msg = `relay event id=${event.id} type=${event.type} status=500 ingest-failed`; + relayOutput.push(msg); + relayOutput.push( + tailLines(`${ingest.stdout}\n${ingest.stderr}`, 20).join('\n') + ); + res.statusCode = 500; + res.end('ingest failed'); + return; + } + + processedEvents.push({ + eventId: String(event.id), + eventType: String(event.type), + objectId: (() => { + const data = + typeof event.data === 'object' && event.data !== null + ? (event.data as Record) + : undefined; + const object = + typeof data?.object === 'object' && data.object !== null + ? (data.object as Record) + : undefined; + return typeof object?.id === 'string' ? object.id : undefined; + })(), + }); + const msg = `relay event id=${event.id} type=${event.type} status=200`; + relayOutput.push(msg); + res.statusCode = 200; + res.end('ok'); + } + ); + + await new Promise((resolve, reject) => { + relayServer!.once('error', reject); + relayServer!.listen(options.relayPort, '127.0.0.1', () => resolve()); + }); + info( + `Relay listening on http://127.0.0.1:${options.relayPort}/stripe-webhook/` + ); + + step('Start stripe listener process'); + const eventsCsv = options.events.join(','); + const forwardUrl = `http://127.0.0.1:${options.relayPort}/stripe-webhook/`; + const listenArgs = [ + 'listen', + '--events', + eventsCsv, + '--forward-to', + forwardUrl, + ]; + if (options.stripeProjectName) { + listenArgs.push('--project-name', options.stripeProjectName); + } + if (options.stripeApiKey) { + listenArgs.push('--api-key', options.stripeApiKey); + } + + stripeListen = spawn(options.stripeCliPath, listenArgs, { + stdio: ['ignore', 'pipe', 'pipe'], + shell: false, + windowsHide: true, + }); + if (!stripeListen.stdout || !stripeListen.stderr) { + throw new Error('Failed to capture stripe listen stdout/stderr streams.'); + } + stripeListen.stdout.on('data', chunk => { + stripeStdout += chunk.toString(); + }); + stripeListen.stderr.on('data', chunk => { + stripeStderr += chunk.toString(); + }); + + const readyDeadline = Date.now() + 30_000; + while (Date.now() < readyDeadline) { + if (stripeListen.exitCode !== null) { + throw new Error( + `stripe listen exited early.\nSTDOUT:\n${stripeStdout}\nSTDERR:\n${stripeStderr}` + ); + } + if ( + stripeStdout.includes('Ready!') || + stripeStdout.includes('webhook signing secret') || + stripeStderr.includes('Ready!') || + stripeStderr.includes('webhook signing secret') + ) { + break; + } + await sleep(250); + } + if ( + !stripeStdout.includes('Ready!') && + !stripeStderr.includes('Ready!') && + !stripeStdout.includes('webhook signing secret') && + !stripeStderr.includes('webhook signing secret') + ) { + throw new Error( + `stripe listen did not become ready.\nSTDOUT:\n${stripeStdout}\nSTDERR:\n${stripeStderr}` + ); + } + + info(`stripe listen PID: ${stripeListen.pid ?? 'unknown'}`); + info(`forwarding to: ${forwardUrl}`); + info(`relay port: ${options.relayPort}`); + const listenerOutput = `${stripeStdout}\n${stripeStderr}`; + const signingSecret = listenerOutput.match(/whsec_[A-Za-z0-9]+/)?.[0]; + if (!signingSecret) { + throw new Error( + 'stripe listen became ready without reporting a webhook signing secret' + ); + } + step('Configure the listener webhook secret in the module'); + let configureSecret = await spawnCapture('spacetime', [ + 'call', + '--server', + options.server, + options.database, + 'set_stripe_webhook_signing_secret', + JSON.stringify(signingSecret), + ]); + const configureOutput = `${configureSecret.stdout}\n${configureSecret.stderr}`; + if ( + configureSecret.code !== 0 && + configureOutput.includes('config_not_set') + ) { + configureSecret = await spawnCapture('spacetime', [ + 'call', + '--server', + options.server, + options.database, + 'set_stripe_config', + JSON.stringify('sk_test_e2e_webhook_only'), + 'null', + JSON.stringify({ some: signingSecret }), + ]); + } + if (configureSecret.code !== 0) { + throw new Error( + `could not configure the Stripe listener secret: ${tailLines(`${configureSecret.stdout}\n${configureSecret.stderr}`, 10).join('\n')}` + ); + } + info('listener signing secret configured (value redacted).'); + if (options.listenerWarmupSeconds > 0) { + info(`warming listener for ${options.listenerWarmupSeconds}s...`); + await sleep(options.listenerWarmupSeconds * 1000); + } + + const preflight = await fetch(forwardUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }); + info(`relay preflight HTTP status: ${preflight.status}`); + + step('Trigger test events through Stripe CLI'); + const eventsToRun = options.skipCheckoutTrigger + ? options.events.filter( + eventName => eventName !== 'checkout.session.completed' + ) + : options.events; + triggeredEvents = [...eventsToRun]; + for (const eventName of eventsToRun) { + const triggerArgs = ['trigger', eventName]; + if (options.stripeProjectName) { + triggerArgs.push('--project-name', options.stripeProjectName); + } + if (options.stripeApiKey) { + triggerArgs.push('--api-key', options.stripeApiKey); + } + await runChecked(options.stripeCliPath, triggerArgs); + await sleep(1000); + } + + step('Validate webhook rows increased'); + if (relayOutput.length > 0) { + process.stdout.write('\n'); + info('relay output (tail):'); + for (const line of relayOutput.slice(-60)) { + info(` ${line}`); + } + } else { + info('relay output: no inbound webhook requests observed.'); + } + + const waitDeadline = Date.now() + options.deliveryWaitSeconds * 1000; + let afterCount = beforeCount; + while (Date.now() < waitDeadline) { + await sleep(1000); + afterCount = await getWebhookEventCount(options.server, options.database); + if (afterCount > beforeCount) break; + } + const delta = afterCount - beforeCount; + info(`Before: ${beforeCount}`); + info(`After: ${afterCount}`); + info(`Delta: ${delta}`); + + if (delta <= 0) { + process.stdout.write('\n'); + info('stripe listen stdout (tail):'); + for (const line of tailLines(stripeStdout, 80)) info(` ${line}`); + process.stdout.write('\n'); + info('stripe listen stderr (tail):'); + for (const line of tailLines(stripeStderr, 80)) info(` ${line}`); + process.stdout.write('\n'); + info(`relay request count: ${relayHits}`); + throw new Error( + 'No new webhook events were ingested. Expected delta > 0.' + ); + } + + for (const eventType of triggeredEvents) { + if (!processedEvents.some(event => event.eventType === eventType)) { + throw new Error( + `Assertion failed: did not observe forwarded event type ${eventType} in relay output.` + ); + } + } + for (const event of processedEvents) { + await assertEventWorkflowState(options.server, options.database, event); + } + if (triggeredEvents.includes('payment_intent.succeeded')) { + const afterPaymentCount = await getSqlCount( + options.server, + options.database, + 'select count(*) as c from stripe_payment' + ); + if (afterPaymentCount <= beforePaymentCount) { + throw new Error( + 'Assertion failed: no standalone payment_intent.succeeded event produced a payment row.' + ); + } + } + info( + `workflow assertions passed for ${processedEvents.length} forwarded event(s).` + ); + + process.stdout.write('\nStripe E2E test passed.\n'); + } finally { + if (stripeListen && stripeListen.exitCode === null && stripeListen.pid) { + try { + await spawnCapture('taskkill', [ + '/PID', + String(stripeListen.pid), + '/T', + '/F', + ]); + } catch { + // Ignore cleanup failures. + } + } + if (relayServer) { + await new Promise(resolve => relayServer!.close(() => resolve())); + } + } +} + +main().catch(err => { + process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); +}); diff --git a/spacetime-stripe-ts/scripts/test-stripe-smoke.ts b/spacetime-stripe-ts/scripts/test-stripe-smoke.ts new file mode 100644 index 00000000000..e1bc02a985e --- /dev/null +++ b/spacetime-stripe-ts/scripts/test-stripe-smoke.ts @@ -0,0 +1,398 @@ +// Synthetic-payload smoke test; locks in webhook behavior without Stripe CLI/keys. + +import { spawn } from 'node:child_process'; +import { createHmac } from 'node:crypto'; + +type Options = { + server: string; + database: string; + skipBuildPublish: boolean; +}; + +function parseArgs(argv: string[]): Options { + const opts: Options = { + server: 'local', + // Dedicated database so the smoke test never overwrites dev module config with placeholders. + database: 'stripe-ts-smoke-test', + skipBuildPublish: false, + }; + for (let i = 0; i < argv.length; i++) { + const raw = argv[i]!; + const flag = raw.replace(/^-+/, '').toLowerCase(); + if (flag === 'skip-build-publish') opts.skipBuildPublish = true; + if (flag === 'server') opts.server = argv[++i]!; + if (flag === 'database') opts.database = argv[++i]!; + } + return opts; +} + +function step(name: string) { + process.stdout.write(`\n==> ${name}\n`); +} + +function run( + cmd: string, + args: string[] +): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise(resolve => { + const child = spawn(cmd, args, { shell: false }); + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', d => (stdout += String(d))); + child.stderr?.on('data', d => (stderr += String(d))); + child.on('close', code => resolve({ code: code ?? 1, stdout, stderr })); + child.on('error', err => + resolve({ code: 1, stdout, stderr: stderr + String(err) }) + ); + }); +} + +async function call( + opts: Options, + name: string, + args: string[] +): Promise { + const result = await run('spacetime', [ + 'call', + '--server', + opts.server, + opts.database, + name, + ...args, + ]); + if (result.code !== 0) { + throw new Error( + `spacetime call ${name} failed: code=${result.code}\nstderr: ${result.stderr}\nstdout: ${result.stdout}` + ); + } + return result.stdout; +} + +// Expects the call to fail. Returns combined stderr/stdout for assertion. +async function expectCallFails( + opts: Options, + name: string, + args: string[], + anonymous = false +): Promise { + const result = await run('spacetime', [ + 'call', + ...(anonymous ? ['--anonymous'] : []), + '--server', + opts.server, + opts.database, + name, + ...args, + ]); + if (result.code === 0) { + throw new Error( + `expected ${name} to fail but it succeeded:\nstdout: ${result.stdout}` + ); + } + return result.stderr + result.stdout; +} + +const q = (s: string) => JSON.stringify(s); +const some = (s: string) => JSON.stringify({ some: s }); +const STRIPE_WEBHOOK_SECRET = 'whsec_smoke_test_secret'; + +function stripeSignature(rawBody: string): string { + const ts = Math.floor(Date.now() / 1000); + const digest = createHmac('sha256', STRIPE_WEBHOOK_SECRET) + .update(`${ts}.${rawBody}`) + .digest('hex'); + return `t=${ts},v1=${digest}`; +} + +async function ingest( + opts: Options, + args: { + eventId: string; + eventType: string; + livemode?: boolean; + payload: object; + } +) { + const payloadJson = JSON.stringify(args.payload); + await call(opts, 'ingest_stripe_webhook', [ + q(args.eventId), + q(args.eventType), + String(args.livemode ?? false), + q(payloadJson), + some(stripeSignature(payloadJson)), + ]); +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + + if (!opts.skipBuildPublish) { + step('spacetime build'); + const build = await run('spacetime', ['build']); + if (build.code !== 0) { + process.stderr.write(build.stderr); + throw new Error('build failed'); + } + + step(`spacetime publish --server ${opts.server} ${opts.database}`); + const publish = await run('spacetime', [ + 'publish', + '--server', + opts.server, + '--yes', + '--delete-data', + opts.database, + ]); + if (publish.code !== 0) { + process.stderr.write(publish.stderr); + throw new Error('publish failed'); + } + } + + // Procedures needing Stripe secret should refuse cleanly before config. + step('negative: get_or_create_customer before config, expect failure'); + const preBootstrap = await expectCallFails(opts, 'get_or_create_customer', [ + q('u_no_config_yet'), + 'null', + 'null', + ]); + if (!preBootstrap.toLowerCase().includes('config')) { + throw new Error( + `expected error to mention config; got: ${preBootstrap.slice(0, 400)}` + ); + } + + step('set_stripe_config'); + await call(opts, 'set_stripe_config', [ + q('sk_test_smoke_placeholder'), + 'null', + some(STRIPE_WEBHOOK_SECRET), + ]); + + step('negative: anonymous callers cannot read or mutate Stripe state'); + for (const [name, args] of [ + ['get_customer', [q('cus_smoke_1')]], + ['create_customer', ['null', 'null', 'null', 'null']], + [ + 'upsert_customer', + [q('cus_anonymous'), 'null', 'null', 'null', 'null', 'null'], + ], + ] as const) { + const unauthorized = await expectCallFails(opts, name, [...args], true); + if (!unauthorized.toLowerCase().includes('not_authorized')) { + throw new Error( + `expected ${name} to reject a non-admin caller: ${unauthorized.slice(0, 400)}` + ); + } + } + + step('negative: signed webhook metadata must match supplied metadata'); + const mismatchPayload = JSON.stringify({ + id: 'evt_smoke_signed_id', + type: 'customer.created', + data: { object: { id: 'cus_should_not_exist' } }, + }); + const mismatch = await expectCallFails(opts, 'ingest_stripe_webhook', [ + q('evt_smoke_forged_id'), + q('customer.deleted'), + 'false', + q(mismatchPayload), + some(stripeSignature(mismatchPayload)), + ]); + if (!mismatch.toLowerCase().includes('metadata')) { + throw new Error( + `expected signed metadata mismatch failure: ${mismatch.slice(0, 400)}` + ); + } + + step('ingest customer.created, expect customer row'); + await ingest(opts, { + eventId: 'evt_smoke_cust_1', + eventType: 'customer.created', + payload: { + id: 'evt_smoke_cust_1', + type: 'customer.created', + data: { + object: { + id: 'cus_smoke_1', + email: 'smoke@example.com', + name: 'Smoke Test', + metadata: { userId: 'u_smoke_1' }, + }, + }, + }, + }); + const customer = await call(opts, 'get_customer', [q('cus_smoke_1')]); + if (!customer.includes('smoke@example.com')) { + throw new Error( + `customer.created did not produce expected row: ${customer}` + ); + } + + step( + 'ingest customer.subscription.created, expect subscription row + metadata' + ); + await ingest(opts, { + eventId: 'evt_smoke_sub_1', + eventType: 'customer.subscription.created', + payload: { + id: 'evt_smoke_sub_1', + type: 'customer.subscription.created', + data: { + object: { + id: 'sub_smoke_1', + customer: 'cus_smoke_1', + status: 'active', + current_period_end: 1735000000, + cancel_at_period_end: false, + items: { + data: [ + { + current_period_end: 1735000000, + quantity: 1, + price: { id: 'price_smoke_1' }, + }, + ], + }, + metadata: { userId: 'u_smoke_1', orgId: 'o_smoke_1' }, + }, + }, + }, + }); + const sub = await call(opts, 'get_subscription', [q('sub_smoke_1')]); + if (!sub.includes('"active"') || !sub.includes('o_smoke_1')) { + throw new Error( + `subscription.created did not produce expected row: ${sub}` + ); + } + + step('ingest checkout.session.completed, expect session row'); + await ingest(opts, { + eventId: 'evt_smoke_chk_1', + eventType: 'checkout.session.completed', + payload: { + id: 'evt_smoke_chk_1', + type: 'checkout.session.completed', + data: { + object: { + id: 'cs_smoke_1', + mode: 'subscription', + customer: 'cus_smoke_1', + metadata: { userId: 'u_smoke_1' }, + }, + }, + }, + }); + const session = await call(opts, 'get_checkout_session', [q('cs_smoke_1')]); + if (!session.includes('"complete"') || !session.includes('subscription')) { + throw new Error( + `checkout.session.completed did not produce expected row: ${session}` + ); + } + + step('ingest invoice.created, expect invoice row'); + await ingest(opts, { + eventId: 'evt_smoke_inv_1', + eventType: 'invoice.created', + payload: { + id: 'evt_smoke_inv_1', + type: 'invoice.created', + data: { + object: { + id: 'in_smoke_1', + customer: 'cus_smoke_1', + subscription: 'sub_smoke_1', + status: 'open', + amount_due: 999, + amount_paid: 0, + created: 1735000000, + }, + }, + }, + }); + let invoice = await call(opts, 'list_invoices', [q('cus_smoke_1')]); + if (!invoice.includes('in_smoke_1')) { + throw new Error(`invoice.created did not produce row: ${invoice}`); + } + + step('ingest invoice.paid, expect status=paid + carry-over fields'); + await ingest(opts, { + eventId: 'evt_smoke_inv_2', + eventType: 'invoice.paid', + payload: { + id: 'evt_smoke_inv_2', + type: 'invoice.paid', + data: { + object: { + id: 'in_smoke_1', + customer: 'cus_smoke_1', + status: 'paid', + amount_paid: 999, + }, + }, + }, + }); + invoice = await call(opts, 'list_invoices', [q('cus_smoke_1')]); + if (!invoice.includes('"paid"')) { + throw new Error(`invoice.paid did not flip status: ${invoice}`); + } + + step('ingest payment_intent.succeeded standalone, expect payment row'); + await ingest(opts, { + eventId: 'evt_smoke_pay_1', + eventType: 'payment_intent.succeeded', + payload: { + id: 'evt_smoke_pay_1', + type: 'payment_intent.succeeded', + data: { + object: { + id: 'pi_smoke_1', + customer: 'cus_smoke_2', + amount: 1999, + currency: 'usd', + status: 'succeeded', + created: 1735000100, + metadata: { userId: 'u_smoke_2' }, + }, + }, + }, + }); + const payment = await call(opts, 'get_payment', [q('pi_smoke_1')]); + if (!payment.includes('1999') || !payment.includes('"usd"')) { + throw new Error(`payment_intent.succeeded did not produce row: ${payment}`); + } + + step('negative: replay unknown event_id, expect failure'); + const replayMissing = await expectCallFails(opts, 'replay_webhook_event', [ + q('evt_does_not_exist_xyz'), + ]); + if (!replayMissing.toLowerCase().includes('not_found')) { + throw new Error( + `expected not_found error; got: ${replayMissing.slice(0, 400)}` + ); + } + + step('verify idempotency: re-ingest evt_smoke_cust_1, row unchanged'); + await ingest(opts, { + eventId: 'evt_smoke_cust_1', + eventType: 'customer.created', + payload: { + id: 'evt_smoke_cust_1', + type: 'customer.created', + data: { object: { id: 'cus_smoke_1', email: 'CHANGED@example.com' } }, + }, + }); + const customerAfter = await call(opts, 'get_customer', [q('cus_smoke_1')]); + if (!customerAfter.includes('smoke@example.com')) { + throw new Error(`replay broke customer row: ${customerAfter}`); + } + + step('done: stripe smoke test passed'); +} + +main().catch(err => { + process.stderr.write( + `\nSMOKE TEST FAILED: ${err instanceof Error ? err.message : String(err)}\n` + ); + process.exit(1); +}); diff --git a/spacetime-stripe-ts/scripts/test-unit.ts b/spacetime-stripe-ts/scripts/test-unit.ts new file mode 100644 index 00000000000..1c6645a8cc0 --- /dev/null +++ b/spacetime-stripe-ts/scripts/test-unit.ts @@ -0,0 +1,100 @@ +import * as assert from 'node:assert/strict'; +import { buildStripeHttpRequest } from '../src/submodule/http.ts'; +import { parseStripeEventMetadata } from '../src/submodule/webhook-metadata.ts'; +import { + validateWebhookRequestBody, + validateWebhookRequestHeaders, +} from '../src/submodule/webhook-request.ts'; + +const customerEvent = { + id: 'evt_1', + type: 'customer.created', + livemode: false, + data: { + object: { + id: 'cus_1', + email: 'customer@example.com', + name: 'Customer', + metadata: { userId: 'user-1' }, + }, + }, +}; + +assert.deepEqual(parseStripeEventMetadata(JSON.stringify(customerEvent)), { + eventId: 'evt_1', + eventType: 'customer.created', + livemode: false, +}); +assert.equal(parseStripeEventMetadata('{bad json'), undefined); +assert.equal(parseStripeEventMetadata('{"id":"evt_1"}'), undefined); + +const request = buildStripeHttpRequest({ + method: 'post', + path: '/v1/customers', + secretKey: 'sk_test_secret', + stripeVersion: '2025-01-01', + formBody: 'email=user%40example.com', + idempotencyKey: 'customer-user-1', +}); +assert.equal(request.url, 'https://api.stripe.com/v1/customers'); +assert.equal(request.method, 'POST'); +assert.equal(request.headers.Authorization, 'Bearer sk_test_secret'); +assert.throws( + () => + buildStripeHttpRequest({ + method: 'GET', + path: 'https://attacker.example/collect', + secretKey: 'sk_test_secret', + stripeVersion: undefined, + formBody: undefined, + idempotencyKey: undefined, + }), + /stripe\.request_path_invalid/ +); +assert.throws( + () => + buildStripeHttpRequest({ + method: 'GET', + path: '/v1/customers\u007fblocked', + secretKey: 'sk_test_secret', + stripeVersion: undefined, + formBody: undefined, + idempotencyKey: undefined, + }), + /stripe\.request_path_invalid/ +); +assert.throws( + () => + buildStripeHttpRequest({ + method: 'TRACE', + path: '/v1/customers', + secretKey: 'sk_test_secret', + stripeVersion: undefined, + formBody: undefined, + idempotencyKey: undefined, + }), + /stripe\.request_method_invalid/ +); + +assert.deepEqual(validateWebhookRequestHeaders('GET', null, undefined), { + status: 405, + error: 'method not allowed', +}); +assert.equal( + validateWebhookRequestHeaders('POST', String(1024 * 1024 + 1), undefined) + ?.status, + 413 +); +assert.equal( + validateWebhookRequestHeaders('POST', null, 'x'.repeat(8193))?.status, + 431 +); +assert.equal(validateWebhookRequestHeaders('POST', null, 'short'), undefined); +assert.equal(validateWebhookRequestBody('')?.status, 400); +assert.equal( + validateWebhookRequestBody('x'.repeat(1024 * 1024 + 1))?.status, + 413 +); +assert.equal(validateWebhookRequestBody('{}'), undefined); + +console.log('stripe unit tests passed'); diff --git a/spacetime-stripe-ts/scripts/type-alignment.ts b/spacetime-stripe-ts/scripts/type-alignment.ts new file mode 100644 index 00000000000..3d5af0facc3 --- /dev/null +++ b/spacetime-stripe-ts/scripts/type-alignment.ts @@ -0,0 +1,40 @@ +import type Stripe from 'stripe'; +import type * as v from 'valibot'; +import type { + ParsedStripeEvent, + vStripeBillingPortalSessionResponse, + vStripeCheckoutSessionResponse, +} from '../src/submodule/schema.ts'; + +type Assert = T; +type IsAssignable = [From] extends [To] ? true : false; + +type _StripeEvents = Assert< + IsAssignable< + | Stripe.CustomerCreatedEvent + | Stripe.CustomerUpdatedEvent + | Stripe.CustomerSubscriptionCreatedEvent + | Stripe.CustomerSubscriptionUpdatedEvent + | Stripe.CustomerSubscriptionDeletedEvent + | Stripe.CheckoutSessionCompletedEvent + | Stripe.InvoiceCreatedEvent + | Stripe.InvoiceFinalizedEvent + | Stripe.InvoicePaidEvent + | Stripe.InvoicePaymentSucceededEvent + | Stripe.InvoicePaymentFailedEvent + | Stripe.PaymentIntentSucceededEvent, + ParsedStripeEvent + > +>; +type _CheckoutSessionResponse = Assert< + IsAssignable< + Stripe.Checkout.Session, + v.InferOutput + > +>; +type _BillingPortalSessionResponse = Assert< + IsAssignable< + Stripe.BillingPortal.Session, + v.InferOutput + > +>; diff --git a/spacetime-stripe-ts/src/index.ts b/spacetime-stripe-ts/src/index.ts new file mode 100644 index 00000000000..db9d5fe318f --- /dev/null +++ b/spacetime-stripe-ts/src/index.ts @@ -0,0 +1,53 @@ +export { default, init } from './submodule/schema'; +export { + upsert_customer, + upsert_subscription, + update_payment_customer, + update_subscription_quantity_internal, + ingest_stripe_webhook, + replay_webhook_event, +} from './submodule/operations'; +export { + validate_stripe_price, + get_remote_checkout_session, + get_webhook_event_count, + stripe_api_request, + create_customer, + create_or_update_customer, + update_subscription_metadata, + get_or_create_customer, + create_checkout_session, + create_customer_portal_session, + cancel_subscription, + reactivate_subscription, + update_subscription_quantity, +} from './submodule/operations/billing'; +export { + get_customer, + get_customer_by_email, + get_customer_by_user_id, + get_subscription, + list_subscriptions, + list_subscriptions_with_creation_time, + get_subscription_by_org_id, + list_subscriptions_by_org_id, + list_subscriptions_by_user_id, + get_payment, + list_payments, + list_payments_by_user_id, + list_payments_by_org_id, + list_invoices, + list_invoices_by_org_id, + list_invoices_by_user_id, + get_checkout_session, + list_checkout_sessions, +} from './submodule/operations/queries'; +export { stripe_webhook_handler } from './submodule/operations/webhook'; + +export { + set_stripe_config, + set_stripe_webhook_signing_secret, + get_stripe_config_status, +} from './submodule/config'; +export { add_admin_identity, remove_admin_identity } from './submodule/auth'; +export { stripeWebhookRouter } from './submodule/router'; diff --git a/spacetime-stripe-ts/src/submodule.ts b/spacetime-stripe-ts/src/submodule.ts new file mode 100644 index 00000000000..8c615a899dd --- /dev/null +++ b/spacetime-stripe-ts/src/submodule.ts @@ -0,0 +1,23 @@ +export { default } from './submodule/schema'; +export { installStripe } from './submodule/install'; +export { + upsert_customer, + upsert_subscription, + update_payment_customer, + update_subscription_quantity_internal, + ingest_stripe_webhook, + replay_webhook_event, +} from './submodule/operations'; +export * from './submodule/operations/billing'; +export * from './submodule/operations/queries'; +export { + handleStripeWebhook, + stripe_webhook_handler, +} from './submodule/operations/webhook'; + +export { + set_stripe_config, + set_stripe_webhook_signing_secret, + get_stripe_config_status, +} from './submodule/config'; +export { add_admin_identity, remove_admin_identity } from './submodule/auth'; diff --git a/spacetime-stripe-ts/src/submodule/auth.ts b/spacetime-stripe-ts/src/submodule/auth.ts new file mode 100644 index 00000000000..00397bccaed --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/auth.ts @@ -0,0 +1,79 @@ +import { + spacetimedb, + t, + type ProcedureModuleCtx, + type WriteCtx, +} from './schema'; +import { throwSenderError } from './validation'; + +// Admin gate. Fresh publishes seed the owner via init. Public submodule calls +// never bootstrap admin state from "first caller wins". +type Sender = WriteCtx['sender']; +type ModuleTimestamp = WriteCtx['timestamp']; + +export type AdminVerdict = 'admin' | 'denied'; + +export function isAdmin(ctx: WriteCtx, sender: Sender): boolean { + return ctx.db.stripeAdminIdentity.identity.find(sender) != null; +} + +export function adminVerdict(ctx: WriteCtx, sender: Sender): AdminVerdict { + return isAdmin(ctx, sender) ? 'admin' : 'denied'; +} + +export function denyIfNotAdmin(verdict: AdminVerdict): void { + if (verdict === 'denied') throwSenderError('stripe.not_authorized'); +} + +export function requireAdmin(ctx: WriteCtx, sender: Sender): void { + if (!isAdmin(ctx, sender)) throwSenderError('stripe.not_authorized'); +} + +// For owner-gated repair/setup code only. Do not call from a public bootstrap path. +export function seedAdmin( + ctx: WriteCtx, + sender: Sender, + timestamp: ModuleTimestamp +) { + if (ctx.db.stripeAdminIdentity.identity.find(sender) != null) return; + ctx.db.stripeAdminIdentity.insert({ + identity: sender, + addedAtMicros: timestamp.microsSinceUnixEpoch, + }); +} + +export const add_admin_identity = spacetimedb.procedure( + { identity: t.identity() }, + t.unit(), + (ctx: ProcedureModuleCtx, { identity }) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + ctx.withTx(tx => { + if (tx.db.stripeAdminIdentity.identity.find(identity) == null) { + tx.db.stripeAdminIdentity.insert({ + identity, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + }); + return {}; + } +); + +export const remove_admin_identity = spacetimedb.procedure( + { identity: t.identity() }, + t.unit(), + (ctx: ProcedureModuleCtx, { identity }) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + ctx.withTx(tx => { + const existing = tx.db.stripeAdminIdentity.identity.find(identity); + if (!existing) return; + if (tx.db.stripeAdminIdentity.count() <= 1n) { + throwSenderError('stripe.cannot_remove_last_admin'); + } + tx.db.stripeAdminIdentity.delete(existing); + }); + return {}; + } +); diff --git a/spacetime-stripe-ts/src/submodule/config.ts b/spacetime-stripe-ts/src/submodule/config.ts new file mode 100644 index 00000000000..d20131632f8 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/config.ts @@ -0,0 +1,140 @@ +import { + spacetimedb, + t, + type ProcedureModuleCtx, + type WriteCtx, +} from './schema'; +import { adminVerdict, denyIfNotAdmin } from './auth'; +import { throwSenderError } from './validation'; + +export type StripeConfig = { + secretKey: string; + stripeVersion: string | undefined; + webhookSigningSecret: string | undefined; +}; + +export function loadConfigOrThrow(ctx: WriteCtx): StripeConfig { + const row = ctx.db.stripeConfig.singleton.find(true); + if (!row) { + throwSenderError( + 'stripe.config_not_set: call set_stripe_config(...) first' + ); + } + return { + secretKey: row.secretKey, + stripeVersion: row.stripeVersion, + webhookSigningSecret: row.webhookSigningSecret, + }; +} + +export function loadConfigOrThrowFromProcedure( + ctx: ProcedureModuleCtx +): StripeConfig { + return ctx.withTx(tx => loadConfigOrThrow(tx)); +} + +function upsertConfig( + ctx: WriteCtx, + args: { + secretKey: string; + stripeVersion: string | undefined; + webhookSigningSecret: string | undefined; + } +) { + const existing = ctx.db.stripeConfig.singleton.find(true); + const row = { + singleton: true, + secretKey: args.secretKey, + stripeVersion: args.stripeVersion ?? existing?.stripeVersion, + webhookSigningSecret: + args.webhookSigningSecret ?? existing?.webhookSigningSecret, + updatedAt: ctx.timestamp, + }; + if (!existing) { + ctx.db.stripeConfig.insert(row); + return; + } + if (ctx.db.stripeConfig.singleton.update) { + ctx.db.stripeConfig.singleton.update(row); + } else { + ctx.db.stripeConfig.delete(existing); + ctx.db.stripeConfig.insert(row); + } +} + +export const set_stripe_config = spacetimedb.procedure( + { + secretKey: t.string(), + stripeVersion: t.option(t.string()), + webhookSigningSecret: t.option(t.string()), + }, + t.unit(), + (ctx, args) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + ctx.withTx(tx => { + upsertConfig(tx, { + secretKey: args.secretKey, + stripeVersion: args.stripeVersion, + webhookSigningSecret: args.webhookSigningSecret, + }); + }); + return {}; + } +); + +export const set_stripe_webhook_signing_secret = spacetimedb.procedure( + { webhookSigningSecret: t.string() }, + t.unit(), + (ctx, { webhookSigningSecret }) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + const secret = webhookSigningSecret.trim(); + if (!secret) throwSenderError('stripe.invalid_webhook_signing_secret'); + ctx.withTx(tx => { + const existing = tx.db.stripeConfig.singleton.find(true); + if (!existing) { + throwSenderError( + 'stripe.config_not_set: call set_stripe_config(...) first' + ); + } + tx.db.stripeConfig.singleton.update({ + ...existing, + webhookSigningSecret: secret, + updatedAt: ctx.timestamp, + }); + }); + return {}; + } +); + +export const get_stripe_config_status = spacetimedb.procedure( + {}, + t.object('StripeConfigStatus', { + isConfigured: t.bool(), + hasWebhookSecret: t.bool(), + stripeVersion: t.option(t.string()), + secretKeyLength: t.u16(), + }), + ctx => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + return ctx.withTx(tx => { + const row = tx.db.stripeConfig.singleton.find(true); + if (!row) { + return { + isConfigured: false, + hasWebhookSecret: false, + stripeVersion: undefined, + secretKeyLength: 0, + }; + } + return { + isConfigured: true, + hasWebhookSecret: row.webhookSigningSecret !== undefined, + stripeVersion: row.stripeVersion, + secretKeyLength: row.secretKey.length, + }; + }); + } +); diff --git a/spacetime-stripe-ts/src/submodule/http.ts b/spacetime-stripe-ts/src/submodule/http.ts new file mode 100644 index 00000000000..3ce4ba8ca98 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/http.ts @@ -0,0 +1,79 @@ +const STRIPE_API_ORIGIN = 'https://api.stripe.com'; +const ALLOWED_METHODS = new Set(['GET', 'POST', 'DELETE']); +const MAX_PATH_LENGTH = 2048; +const MAX_FORM_BODY_LENGTH = 64 * 1024; +const MAX_IDEMPOTENCY_KEY_LENGTH = 255; + +function hasControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +export type StripeHttpRequest = { + method: string; + url: string; + headers: Record; + body: string | undefined; +}; + +function validatePath(path: string): string { + const normalized = path.trim(); + if (!normalized.startsWith('/v1/')) { + throw new Error('stripe.request_path_invalid'); + } + if ( + normalized.startsWith('//') || + normalized.includes('\\') || + normalized.includes('#') + ) { + throw new Error('stripe.request_path_invalid'); + } + if (normalized.length > MAX_PATH_LENGTH || hasControlCharacter(normalized)) { + throw new Error('stripe.request_path_invalid'); + } + return normalized; +} + +export function buildStripeHttpRequest(args: { + method: string; + path: string; + secretKey: string; + stripeVersion: string | undefined; + formBody: string | undefined; + idempotencyKey: string | undefined; +}): StripeHttpRequest { + const method = args.method.trim().toUpperCase(); + if (!ALLOWED_METHODS.has(method)) { + throw new Error('stripe.request_method_invalid'); + } + + const path = validatePath(args.path); + const body = args.formBody?.length ? args.formBody : undefined; + if (body !== undefined && body.length > MAX_FORM_BODY_LENGTH) { + throw new Error('stripe.request_body_too_large'); + } + if ( + args.idempotencyKey && + args.idempotencyKey.length > MAX_IDEMPOTENCY_KEY_LENGTH + ) { + throw new Error('stripe.idempotency_key_too_long'); + } + + const headers: Record = { + Authorization: `Bearer ${args.secretKey}`, + }; + if (args.stripeVersion) headers['Stripe-Version'] = args.stripeVersion; + if (args.idempotencyKey) headers['Idempotency-Key'] = args.idempotencyKey; + if (body !== undefined) + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + + return { + method, + url: `${STRIPE_API_ORIGIN}${path}`, + headers, + body, + }; +} diff --git a/spacetime-stripe-ts/src/submodule/install.ts b/spacetime-stripe-ts/src/submodule/install.ts new file mode 100644 index 00000000000..80e67faf4a8 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/install.ts @@ -0,0 +1,9 @@ +import type { ReducerModuleCtx } from './schema'; + +export function installStripe(ctx: ReducerModuleCtx) { + if (ctx.db.stripeAdminIdentity.identity.find(ctx.sender) != null) return; + ctx.db.stripeAdminIdentity.insert({ + identity: ctx.sender, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); +} diff --git a/spacetime-stripe-ts/src/submodule/limits.ts b/spacetime-stripe-ts/src/submodule/limits.ts new file mode 100644 index 00000000000..daa5fd01a6c --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/limits.ts @@ -0,0 +1,3 @@ +export const MAX_WEBHOOK_BODY_LENGTH = 1024 * 1024; +export const MAX_WEBHOOK_HEADER_LENGTH = 8192; +export const MAX_WEBHOOK_METADATA_LENGTH = 255; diff --git a/spacetime-stripe-ts/src/submodule/operations.ts b/spacetime-stripe-ts/src/submodule/operations.ts new file mode 100644 index 00000000000..ddd68cd0f6b --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/operations.ts @@ -0,0 +1,936 @@ +import * as v from 'valibot'; +import { + SenderError, + t, + WebhookEventStatus, + type WebhookEventStatusValue, + spacetimedb, + vStripeEvent, + vStripeIdResponse, + extractExpandableId, + extractExpandableIdOrNull, + type ParsedStripeEvent, + type ProcedureModuleCtx, + type ReducerModuleCtx, + type TransactionModuleCtx, + type WriteCtx, + type JsonRecord, + type ModuleTimestamp, +} from './schema'; +import { verifyStripeSignature } from '@spacetimedb/crypto'; +import { adminVerdict, denyIfNotAdmin, requireAdmin } from './auth'; +import { parseStripeEventMetadata } from './webhook-metadata'; +import { buildStripeHttpRequest } from './http'; +import { + MAX_WEBHOOK_BODY_LENGTH, + MAX_WEBHOOK_HEADER_LENGTH, + MAX_WEBHOOK_METADATA_LENGTH, +} from './limits'; +import { + assertExhaustive, + safeJsonParse, + summarizeIssues, + throwSenderError, +} from './validation'; + +export function requireProcedureAdmin(ctx: ProcedureModuleCtx): void { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); +} + +export function withAdminTx( + ctx: ProcedureModuleCtx, + read: (tx: TransactionModuleCtx) => T +): T { + requireProcedureAdmin(ctx); + return ctx.withTx(read); +} + +const MAX_QUERY_ROWS = 1000; + +export function takeRows(rows: Iterable, limit = MAX_QUERY_ROWS): T[] { + const out: T[] = []; + for (const row of rows) { + if (out.length >= limit) break; + out.push(row); + } + return out; +} + +export function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null; +} + +export function maybeString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +export function maybeBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + +export function maybeNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) + ? value + : undefined; +} + +export function maybeInt(value: unknown): number | undefined { + const parsed = maybeNumber(value); + return parsed === undefined || !Number.isInteger(parsed) ? undefined : parsed; +} + +export function maybeBigIntFromUnknown(value: unknown): bigint | undefined { + const parsed = maybeInt(value); + return parsed === undefined ? undefined : BigInt(parsed); +} + +export function maybeId(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (!isRecord(value)) return undefined; + return maybeString(value.id); +} + +export function stripeErrorSuffix(body: string): string { + const parsed = safeJsonParse(body); + if (isRecord(parsed)) { + const errorPayload = isRecord(parsed.error) ? parsed.error : undefined; + if (errorPayload) { + const type = maybeString(errorPayload.type); + const code = maybeString(errorPayload.code); + const message = maybeString(errorPayload.message); + const requestLogUrl = maybeString(errorPayload.request_log_url); + const parts: string[] = []; + if (type) parts.push(`type=${type}`); + if (code) parts.push(`code=${code}`); + if (message) + parts.push(`msg=${message.replace(/\s+/g, ' ').slice(0, 240)}`); + if (requestLogUrl) parts.push(`log=${requestLogUrl}`); + if (parts.length > 0) return `:${parts.join('|')}`; + } + } + + const compact = body.replace(/\s+/g, ' ').trim(); + if (!compact) return ''; + return `:body=${compact.slice(0, 240)}`; +} + +export function toJsonString(value: unknown): string | undefined { + if (value === undefined) return undefined; + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} + +export function metadataInfo(metadata: unknown): { + metadataJson: string | undefined; + orgId: string | undefined; + userId: string | undefined; +} { + if (!isRecord(metadata)) { + return { metadataJson: undefined, orgId: undefined, userId: undefined }; + } + return { + metadataJson: toJsonString(metadata), + orgId: maybeString(metadata.orgId), + userId: maybeString(metadata.userId), + }; +} + +export function coerceMetadataFromJson(metadataJson: string | undefined) { + if (!metadataJson) { + return { metadataJson: undefined, orgId: undefined, userId: undefined }; + } + const parsed = safeJsonParse(metadataJson); + const details = metadataInfo(parsed); + return { + metadataJson: details.metadataJson ?? metadataJson, + orgId: details.orgId, + userId: details.userId, + }; +} + +export function deriveCancelAtPeriodEnd( + cancelAtUnix: bigint | undefined, + currentPeriodEndUnix: bigint +): boolean { + if (cancelAtUnix === undefined || currentPeriodEndUnix <= 0n) return false; + const tolerance = 5n * 60n; + const delta = + cancelAtUnix > currentPeriodEndUnix + ? cancelAtUnix - currentPeriodEndUnix + : currentPeriodEndUnix - cancelAtUnix; + return delta <= tolerance; +} + +export function formPairsToBody( + pairs: Array<[string, string | undefined]> +): string { + const encoded: string[] = []; + for (const [key, value] of pairs) { + if (value === undefined) continue; + encoded.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); + } + return encoded.join('&'); +} + +export function metadataJsonToFormPairs( + keyPrefix: string, + metadataJson: string | undefined +) { + if (!metadataJson) return [] as Array<[string, string]>; + const parsed = safeJsonParse(metadataJson); + if (!isRecord(parsed)) return [] as Array<[string, string]>; + + const out: Array<[string, string]> = []; + for (const [k, raw] of Object.entries(parsed)) { + if (raw === undefined || raw === null) continue; + out.push([`${keyPrefix}[${k}]`, String(raw)]); + } + return out; +} + +export function upsertCustomer( + ctx: WriteCtx, + now: ModuleTimestamp, + args: { + stripeCustomerId: string; + appUserId: string | undefined; + email: string | undefined; + name: string | undefined; + metadataJson: string | undefined; + userId: string | undefined; + } +) { + const existing = ctx.db.stripeCustomer.stripeCustomerId.find( + args.stripeCustomerId + ); + const row = { + stripeCustomerId: args.stripeCustomerId, + appUserId: args.appUserId ?? existing?.appUserId, + email: args.email ?? existing?.email, + name: args.name ?? existing?.name, + metadataJson: args.metadataJson ?? existing?.metadataJson, + userId: args.userId ?? existing?.userId, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + + if (!existing) { + ctx.db.stripeCustomer.insert(row); + return; + } + if (ctx.db.stripeCustomer.stripeCustomerId.update) { + ctx.db.stripeCustomer.stripeCustomerId.update(row); + } else { + ctx.db.stripeCustomer.delete(existing); + ctx.db.stripeCustomer.insert(row); + } +} + +export function upsertSubscription( + ctx: WriteCtx, + now: ModuleTimestamp, + args: { + stripeSubscriptionId: string; + stripeCustomerId: string; + status: string; + currentPeriodEndUnix: bigint; + cancelAtPeriodEnd: boolean; + cancelAtUnix: bigint | undefined; + quantity: bigint | undefined; + priceId: string | undefined; + metadataJson: string | undefined; + orgId: string | undefined; + userId: string | undefined; + } +) { + const existing = ctx.db.stripeSubscription.stripeSubscriptionId.find( + args.stripeSubscriptionId + ); + const row = { + stripeSubscriptionId: args.stripeSubscriptionId, + stripeCustomerId: args.stripeCustomerId, + status: args.status, + currentPeriodEndUnix: args.currentPeriodEndUnix, + cancelAtPeriodEnd: args.cancelAtPeriodEnd, + cancelAtUnix: args.cancelAtUnix, + quantity: args.quantity, + priceId: args.priceId ?? existing?.priceId, + metadataJson: args.metadataJson ?? existing?.metadataJson, + orgId: args.orgId ?? existing?.orgId, + userId: args.userId ?? existing?.userId, + insertedAt: existing?.insertedAt ?? now, + updatedAt: now, + }; + + if (!existing) { + ctx.db.stripeSubscription.insert(row); + return; + } + if (ctx.db.stripeSubscription.stripeSubscriptionId.update) { + ctx.db.stripeSubscription.stripeSubscriptionId.update(row); + } else { + ctx.db.stripeSubscription.delete(existing); + ctx.db.stripeSubscription.insert(row); + } +} + +export function upsertCheckoutSession( + ctx: WriteCtx, + now: ModuleTimestamp, + args: { + stripeCheckoutSessionId: string; + stripeCustomerId: string | undefined; + status: string; + mode: string; + metadataJson: string | undefined; + } +) { + const existing = ctx.db.stripeCheckoutSession.stripeCheckoutSessionId.find( + args.stripeCheckoutSessionId + ); + const row = { + stripeCheckoutSessionId: args.stripeCheckoutSessionId, + stripeCustomerId: args.stripeCustomerId ?? existing?.stripeCustomerId, + status: args.status, + mode: args.mode, + metadataJson: args.metadataJson ?? existing?.metadataJson, + insertedAt: existing?.insertedAt ?? now, + updatedAt: now, + }; + + if (!existing) { + ctx.db.stripeCheckoutSession.insert(row); + return; + } + if (ctx.db.stripeCheckoutSession.stripeCheckoutSessionId.update) { + ctx.db.stripeCheckoutSession.stripeCheckoutSessionId.update(row); + } else { + ctx.db.stripeCheckoutSession.delete(existing); + ctx.db.stripeCheckoutSession.insert(row); + } +} + +export function upsertPayment( + ctx: WriteCtx, + now: ModuleTimestamp, + args: { + stripePaymentIntentId: string; + stripeCustomerId: string | undefined; + amount: bigint; + currency: string; + status: string; + createdUnix: bigint; + metadataJson: string | undefined; + orgId: string | undefined; + userId: string | undefined; + } +) { + const existing = ctx.db.stripePayment.stripePaymentIntentId.find( + args.stripePaymentIntentId + ); + const row = { + stripePaymentIntentId: args.stripePaymentIntentId, + stripeCustomerId: args.stripeCustomerId ?? existing?.stripeCustomerId, + amount: args.amount, + currency: args.currency, + status: args.status, + createdUnix: args.createdUnix, + metadataJson: args.metadataJson ?? existing?.metadataJson, + orgId: args.orgId ?? existing?.orgId, + userId: args.userId ?? existing?.userId, + insertedAt: existing?.insertedAt ?? now, + updatedAt: now, + }; + + if (!existing) { + ctx.db.stripePayment.insert(row); + return; + } + if (ctx.db.stripePayment.stripePaymentIntentId.update) { + ctx.db.stripePayment.stripePaymentIntentId.update(row); + } else { + ctx.db.stripePayment.delete(existing); + ctx.db.stripePayment.insert(row); + } +} + +export function upsertInvoice( + ctx: WriteCtx, + now: ModuleTimestamp, + args: { + stripeInvoiceId: string; + stripeCustomerId: string; + stripeSubscriptionId: string | undefined; + status: string; + amountDue: bigint; + amountPaid: bigint; + createdUnix: bigint; + orgId: string | undefined; + userId: string | undefined; + } +) { + const existing = ctx.db.stripeInvoice.stripeInvoiceId.find( + args.stripeInvoiceId + ); + const row = { + stripeInvoiceId: args.stripeInvoiceId, + stripeCustomerId: args.stripeCustomerId, + stripeSubscriptionId: + args.stripeSubscriptionId ?? existing?.stripeSubscriptionId, + status: args.status, + amountDue: args.amountDue, + amountPaid: args.amountPaid, + createdUnix: args.createdUnix, + orgId: args.orgId ?? existing?.orgId, + userId: args.userId ?? existing?.userId, + insertedAt: existing?.insertedAt ?? now, + updatedAt: now, + }; + + if (!existing) { + ctx.db.stripeInvoice.insert(row); + return; + } + if (ctx.db.stripeInvoice.stripeInvoiceId.update) { + ctx.db.stripeInvoice.stripeInvoiceId.update(row); + } else { + ctx.db.stripeInvoice.delete(existing); + ctx.db.stripeInvoice.insert(row); + } +} + +export function updateWebhookStatus( + ctx: ReducerModuleCtx, + eventId: string, + status: WebhookEventStatusValue, + errorMessage: string | undefined +) { + const existing = ctx.db.stripeWebhookEvent.eventId.find(eventId); + if (!existing) return; + + const isTerminal = + status.tag === 'Processed' || + status.tag === 'Ignored' || + status.tag === 'Failed'; + const updated = { + ...existing, + status, + errorMessage, + processedAt: isTerminal ? ctx.timestamp : existing.processedAt, + }; + + if (ctx.db.stripeWebhookEvent.eventId.update) { + ctx.db.stripeWebhookEvent.eventId.update(updated); + } else { + ctx.db.stripeWebhookEvent.delete(existing); + ctx.db.stripeWebhookEvent.insert(updated); + } +} + +export function metadataInfoFromRecord( + metadata: Record | null | undefined +): { + metadataJson: string | undefined; + orgId: string | undefined; + userId: string | undefined; +} { + if (!metadata) + return { metadataJson: undefined, orgId: undefined, userId: undefined }; + return { + metadataJson: toJsonString(metadata), + orgId: metadata.orgId, + userId: metadata.userId, + }; +} + +export function toBigIntOrZero(n: number | undefined | null): bigint { + return n === undefined || n === null ? 0n : BigInt(n); +} + +export function toBigIntOrUndefined( + n: number | undefined | null +): bigint | undefined { + return n === undefined || n === null ? undefined : BigInt(n); +} + +const HANDLED_EVENT_TYPES: ReadonlySet = new Set([ + 'customer.created', + 'customer.updated', + 'customer.subscription.created', + 'customer.subscription.updated', + 'customer.subscription.deleted', + 'checkout.session.completed', + 'invoice.created', + 'invoice.finalized', + 'invoice.paid', + 'invoice.payment_succeeded', + 'invoice.payment_failed', + 'payment_intent.succeeded', +]); + +export function applyStripeEvent( + ctx: ReducerModuleCtx, + payloadJson: string +): { status: WebhookEventStatusValue; error: string | undefined } { + const parsedJson = safeJsonParse(payloadJson); + if (parsedJson === undefined) { + return { status: WebhookEventStatus.Failed, error: 'invalid JSON payload' }; + } + + const result = v.safeParse(vStripeEvent, parsedJson); + if (!result.success) { + // Distinguish unhandled type (ignore) from handled-but-malformed (fail). + const eventTypeRaw = + typeof parsedJson === 'object' && parsedJson !== null + ? (parsedJson as Record).type + : undefined; + const isHandledType = + typeof eventTypeRaw === 'string' && HANDLED_EVENT_TYPES.has(eventTypeRaw); + if (!isHandledType) { + return { status: WebhookEventStatus.Ignored, error: undefined }; + } + return { + status: WebhookEventStatus.Failed, + error: `payload validation failed: ${summarizeIssues(result.issues)}`, + }; + } + + return { status: dispatchEvent(ctx, result.output), error: undefined }; +} + +type ParsedInvoiceObject = Extract< + ParsedStripeEvent, + { type: 'invoice.paid' } +>['data']['object']; + +function syncInvoiceEvent( + ctx: ReducerModuleCtx, + obj: ParsedInvoiceObject, + status: string +): WebhookEventStatusValue { + const existing = ctx.db.stripeInvoice.stripeInvoiceId.find(obj.id); + const payloadCustomerId = extractExpandableIdOrNull(obj.customer); + const customerId = existing?.stripeCustomerId ?? payloadCustomerId; + if (customerId === null) return WebhookEventStatus.Failed; + const payloadSubscriptionId = + obj.subscription === undefined + ? undefined + : (extractExpandableIdOrNull(obj.subscription) ?? undefined); + const subscriptionId = + existing?.stripeSubscriptionId ?? payloadSubscriptionId; + const subscription = subscriptionId + ? ctx.db.stripeSubscription.stripeSubscriptionId.find(subscriptionId) + : undefined; + upsertInvoice(ctx, ctx.timestamp, { + stripeInvoiceId: obj.id, + stripeCustomerId: customerId, + stripeSubscriptionId: subscriptionId, + status, + amountDue: toBigIntOrUndefined(obj.amount_due) ?? existing?.amountDue ?? 0n, + amountPaid: + toBigIntOrUndefined(obj.amount_paid) ?? existing?.amountPaid ?? 0n, + createdUnix: + toBigIntOrUndefined(obj.created) ?? existing?.createdUnix ?? 0n, + orgId: existing?.orgId ?? subscription?.orgId, + userId: existing?.userId ?? subscription?.userId, + }); + return WebhookEventStatus.Processed; +} + +function dispatchEvent( + ctx: ReducerModuleCtx, + event: ParsedStripeEvent +): WebhookEventStatusValue { + switch (event.type) { + case 'customer.created': + case 'customer.updated': { + const obj = event.data.object; + const meta = metadataInfoFromRecord(obj.metadata); + upsertCustomer(ctx, ctx.timestamp, { + stripeCustomerId: obj.id, + appUserId: undefined, + email: obj.email ?? undefined, + name: obj.name ?? undefined, + metadataJson: meta.metadataJson, + userId: meta.userId, + }); + return WebhookEventStatus.Processed; + } + case 'customer.subscription.created': + case 'customer.subscription.updated': + case 'customer.subscription.deleted': { + const obj = event.data.object; + const status = + event.type === 'customer.subscription.deleted' + ? 'canceled' + : obj.status; + const customerId = extractExpandableId(obj.customer); + const firstItem = obj.items?.data[0]; + const currentPeriodEnd = + toBigIntOrUndefined(firstItem?.current_period_end) ?? + toBigIntOrUndefined(obj.current_period_end) ?? + 0n; + const cancelAtUnix = toBigIntOrUndefined(obj.cancel_at); + const cancelAtPeriodEnd = + obj.cancel_at_period_end ?? + deriveCancelAtPeriodEnd(cancelAtUnix, currentPeriodEnd); + const meta = metadataInfoFromRecord(obj.metadata); + upsertSubscription(ctx, ctx.timestamp, { + stripeSubscriptionId: obj.id, + stripeCustomerId: customerId, + status, + currentPeriodEndUnix: currentPeriodEnd, + cancelAtPeriodEnd, + cancelAtUnix, + quantity: toBigIntOrUndefined(firstItem?.quantity), + priceId: firstItem?.price?.id, + metadataJson: meta.metadataJson, + orgId: meta.orgId, + userId: meta.userId, + }); + return WebhookEventStatus.Processed; + } + case 'checkout.session.completed': { + const obj = event.data.object; + const meta = metadataInfoFromRecord(obj.metadata); + const customerId = + obj.customer === undefined + ? undefined + : (extractExpandableIdOrNull(obj.customer) ?? undefined); + upsertCheckoutSession(ctx, ctx.timestamp, { + stripeCheckoutSessionId: obj.id, + stripeCustomerId: customerId, + status: 'complete', + mode: obj.mode ?? 'payment', + metadataJson: meta.metadataJson, + }); + return WebhookEventStatus.Processed; + } + case 'invoice.created': + case 'invoice.finalized': { + const obj = event.data.object; + return syncInvoiceEvent(ctx, obj, obj.status ?? 'open'); + } + case 'invoice.paid': + case 'invoice.payment_succeeded': { + return syncInvoiceEvent(ctx, event.data.object, 'paid'); + } + case 'invoice.payment_failed': { + return syncInvoiceEvent(ctx, event.data.object, 'open'); + } + case 'payment_intent.succeeded': { + const obj = event.data.object; + // Invoice events own invoice-attached payment state. + const invoiceId = + obj.invoice === undefined + ? null + : extractExpandableIdOrNull(obj.invoice); + if (invoiceId !== null && invoiceId !== undefined) + return WebhookEventStatus.Ignored; + + const customerId = + obj.customer === undefined + ? null + : extractExpandableIdOrNull(obj.customer); + const meta = metadataInfoFromRecord(obj.metadata); + upsertPayment(ctx, ctx.timestamp, { + stripePaymentIntentId: obj.id, + stripeCustomerId: customerId ?? undefined, + amount: toBigIntOrZero(obj.amount), + currency: obj.currency ?? 'unknown', + status: obj.status ?? 'succeeded', + createdUnix: toBigIntOrZero(obj.created), + metadataJson: meta.metadataJson, + orgId: meta.orgId, + userId: meta.userId, + }); + return WebhookEventStatus.Processed; + } + default: + return assertExhaustive(event); + } +} + +export function callStripe( + ctx: ProcedureModuleCtx, + args: { + method: string; + path: string; + secretKey: string; + stripeVersion: string | undefined; + formBody: string | undefined; + idempotencyKey: string | undefined; + } +) { + let request; + try { + request = buildStripeHttpRequest(args); + } catch (error) { + throw new SenderError( + error instanceof Error ? error.message : 'stripe.request_invalid' + ); + } + const response = ctx.http.fetch(request.url, { + method: request.method, + headers: request.headers, + body: request.body, + }); + return { + status: response.status, + body: response.text(), + }; +} + +export function createCustomerInStripeAndSync( + ctx: ProcedureModuleCtx, + args: { + secretKey: string; + stripeVersion: string | undefined; + email: string | undefined; + name: string | undefined; + metadataJson: string | undefined; + idempotencyKey: string | undefined; + } +): string { + const formPairs: Array<[string, string | undefined]> = [ + ['email', args.email], + ['name', args.name], + ]; + for (const [k, v] of metadataJsonToFormPairs('metadata', args.metadataJson)) { + formPairs.push([k, v]); + } + + const result = callStripe(ctx, { + method: 'POST', + path: '/v1/customers', + secretKey: args.secretKey, + stripeVersion: args.stripeVersion, + idempotencyKey: args.idempotencyKey + ? `create_customer_${args.idempotencyKey}` + : undefined, + formBody: formPairsToBody(formPairs), + }); + if (result.status < 200 || result.status >= 300) { + throwSenderError( + `stripe.create_customer_failed:${result.status}${stripeErrorSuffix(result.body)}` + ); + } + + const parsedBody = safeJsonParse(result.body); + const idResult = v.safeParse(vStripeIdResponse, parsedBody); + if (!idResult.success) { + throwSenderError( + `stripe.create_customer_invalid_response:${summarizeIssues(idResult.issues)}` + ); + } + const customerId = idResult.output.id; + + const details = coerceMetadataFromJson(args.metadataJson); + ctx.withTx(tx => { + upsertCustomer(tx, ctx.timestamp, { + stripeCustomerId: customerId, + appUserId: undefined, + email: args.email, + name: args.name, + metadataJson: details.metadataJson, + userId: details.userId, + }); + }); + return customerId; +} + +export const upsert_customer = spacetimedb.reducer( + { + stripeCustomerId: t.string(), + appUserId: t.option(t.string()), + email: t.option(t.string()), + name: t.option(t.string()), + metadataJson: t.option(t.string()), + userId: t.option(t.string()), + }, + (ctx, args) => { + requireAdmin(ctx, ctx.sender); + upsertCustomer(ctx, ctx.timestamp, { + stripeCustomerId: args.stripeCustomerId, + appUserId: args.appUserId, + email: args.email, + name: args.name, + metadataJson: args.metadataJson, + userId: args.userId, + }); + } +); + +export const upsert_subscription = spacetimedb.reducer( + { + stripeSubscriptionId: t.string(), + stripeCustomerId: t.string(), + status: t.string(), + currentPeriodEndUnix: t.i64(), + cancelAtPeriodEnd: t.bool(), + cancelAtUnix: t.option(t.i64()), + quantity: t.option(t.i64()), + priceId: t.option(t.string()), + metadataJson: t.option(t.string()), + orgId: t.option(t.string()), + userId: t.option(t.string()), + }, + (ctx, args) => { + requireAdmin(ctx, ctx.sender); + upsertSubscription(ctx, ctx.timestamp, { + stripeSubscriptionId: args.stripeSubscriptionId, + stripeCustomerId: args.stripeCustomerId, + status: args.status, + currentPeriodEndUnix: args.currentPeriodEndUnix, + cancelAtPeriodEnd: args.cancelAtPeriodEnd, + cancelAtUnix: args.cancelAtUnix, + quantity: args.quantity, + priceId: args.priceId, + metadataJson: args.metadataJson, + orgId: args.orgId, + userId: args.userId, + }); + } +); + +export const update_payment_customer = spacetimedb.reducer( + { + stripePaymentIntentId: t.string(), + stripeCustomerId: t.string(), + }, + (ctx, { stripePaymentIntentId, stripeCustomerId }) => { + requireAdmin(ctx, ctx.sender); + const existing = ctx.db.stripePayment.stripePaymentIntentId.find( + stripePaymentIntentId + ); + if (!existing || existing.stripeCustomerId) return; + upsertPayment(ctx, ctx.timestamp, { + stripePaymentIntentId: existing.stripePaymentIntentId, + stripeCustomerId, + amount: existing.amount, + currency: existing.currency, + status: existing.status, + createdUnix: existing.createdUnix, + metadataJson: existing.metadataJson, + orgId: existing.orgId, + userId: existing.userId, + }); + } +); + +export const update_subscription_quantity_internal = spacetimedb.reducer( + { + stripeSubscriptionId: t.string(), + quantity: t.i64(), + }, + (ctx, { stripeSubscriptionId, quantity }) => { + requireAdmin(ctx, ctx.sender); + const existing = + ctx.db.stripeSubscription.stripeSubscriptionId.find(stripeSubscriptionId); + if (!existing) return; + upsertSubscription(ctx, ctx.timestamp, { + stripeSubscriptionId: existing.stripeSubscriptionId, + stripeCustomerId: existing.stripeCustomerId, + status: existing.status, + currentPeriodEndUnix: existing.currentPeriodEndUnix, + cancelAtPeriodEnd: existing.cancelAtPeriodEnd, + cancelAtUnix: existing.cancelAtUnix, + quantity, + priceId: existing.priceId, + metadataJson: existing.metadataJson, + orgId: existing.orgId, + userId: existing.userId, + }); + } +); + +export const ingest_stripe_webhook = spacetimedb.reducer( + { + eventId: t.string(), + eventType: t.string(), + livemode: t.bool(), + payloadJson: t.string(), + signatureHeader: t.option(t.string()), + }, + (ctx, { eventId, eventType, livemode, payloadJson, signatureHeader }) => { + if ( + eventId.length === 0 || + eventId.length > MAX_WEBHOOK_METADATA_LENGTH || + eventType.length === 0 || + eventType.length > MAX_WEBHOOK_METADATA_LENGTH + ) { + throwSenderError('stripe.webhook_metadata_invalid'); + } + if (payloadJson.length > MAX_WEBHOOK_BODY_LENGTH) { + throwSenderError('stripe.webhook_payload_too_large'); + } + if ((signatureHeader?.length ?? 0) > MAX_WEBHOOK_HEADER_LENGTH) { + throwSenderError('stripe.webhook_signature_too_large'); + } + + const cfg = ctx.db.stripeConfig.singleton.find(true); + if (!cfg?.webhookSigningSecret) { + throwSenderError('stripe.webhook_secret_not_configured'); + } + const nowSeconds = Number(ctx.timestamp.microsSinceUnixEpoch / 1_000_000n); + const sigOk = verifyStripeSignature({ + rawBody: payloadJson, + signatureHeader: signatureHeader ?? '', + secret: cfg.webhookSigningSecret, + nowSeconds, + }); + if (!sigOk) throwSenderError('stripe.webhook_signature_mismatch'); + + const signedMetadata = parseStripeEventMetadata(payloadJson); + if (!signedMetadata) + throwSenderError('stripe.webhook_payload_missing_metadata'); + if ( + eventId !== signedMetadata.eventId || + eventType !== signedMetadata.eventType || + livemode !== signedMetadata.livemode + ) { + throwSenderError('stripe.webhook_metadata_mismatch'); + } + + const existing = ctx.db.stripeWebhookEvent.eventId.find( + signedMetadata.eventId + ); + if (existing) return; + + ctx.db.stripeWebhookEvent.insert({ + eventId: signedMetadata.eventId, + eventType: signedMetadata.eventType, + livemode: signedMetadata.livemode, + signatureHeader, + payloadJson, + status: WebhookEventStatus.Received, + errorMessage: undefined, + receivedAt: ctx.timestamp, + processedAt: undefined, + }); + + const outcome = applyStripeEvent(ctx, payloadJson); + updateWebhookStatus( + ctx, + signedMetadata.eventId, + outcome.status, + outcome.error + ); + } +); + +export const replay_webhook_event = spacetimedb.reducer( + { eventId: t.string() }, + (ctx, { eventId }) => { + // Administrators may run this operation over stored events. + requireAdmin(ctx, ctx.sender); + const event = ctx.db.stripeWebhookEvent.eventId.find(eventId); + if (!event) throwSenderError(`stripe.webhook_event_not_found:${eventId}`); + const outcome = applyStripeEvent(ctx, event.payloadJson); + updateWebhookStatus(ctx, eventId, outcome.status, outcome.error); + } +); diff --git a/spacetime-stripe-ts/src/submodule/operations/billing.ts b/spacetime-stripe-ts/src/submodule/operations/billing.ts new file mode 100644 index 00000000000..bcdc75592c9 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/operations/billing.ts @@ -0,0 +1,678 @@ +import * as v from 'valibot'; +import { + t, + spacetimedb, + stripeHttpResponse, + checkoutSessionResult, + createCustomerResult, + getOrCreateCustomerResult, + portalSessionResult, + vStripeCheckoutSessionResponse, + vStripeBillingPortalSessionResponse, + type ProcedureModuleCtx, + type JsonRecord, +} from '../schema'; +import { loadConfigOrThrowFromProcedure } from '../config'; +import { adminVerdict, denyIfNotAdmin } from '../auth'; +import { + safeJsonParse, + summarizeIssues, + throwSenderError, +} from '../validation'; + +import { + requireProcedureAdmin, + withAdminTx, + isRecord, + maybeString, + maybeBoolean, + maybeBigIntFromUnknown, + maybeId, + stripeErrorSuffix, + metadataInfo, + coerceMetadataFromJson, + deriveCancelAtPeriodEnd, + formPairsToBody, + metadataJsonToFormPairs, + upsertCustomer, + upsertSubscription, + callStripe, + createCustomerInStripeAndSync, +} from '../operations'; + +export const validate_stripe_price = spacetimedb.procedure( + { priceId: t.string() }, + t.object('ValidateStripePriceResult', { + valid: t.bool(), + status: t.u16(), + active: t.option(t.bool()), + currency: t.option(t.string()), + unitAmount: t.option(t.i64()), + livemode: t.option(t.bool()), + type: t.option(t.string()), + message: t.option(t.string()), + code: t.option(t.string()), + errorType: t.option(t.string()), + }), + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const response = callStripe(ctx, { + method: 'GET', + path: `/v1/prices/${args.priceId}`, + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + idempotencyKey: undefined, + formBody: undefined, + }); + const parsed = safeJsonParse(response.body); + const isOk = response.status >= 200 && response.status < 300; + if (!isOk) { + const err = + isRecord(parsed) && isRecord(parsed.error) ? parsed.error : undefined; + return { + valid: false, + status: response.status, + active: undefined, + currency: undefined, + unitAmount: undefined, + livemode: undefined, + type: undefined, + message: + maybeString(err?.message) ?? `Stripe returned ${response.status}.`, + code: maybeString(err?.code), + errorType: maybeString(err?.type), + }; + } + const data = isRecord(parsed) ? parsed : {}; + return { + valid: true, + status: response.status, + active: maybeBoolean(data.active), + currency: maybeString(data.currency), + unitAmount: maybeBigIntFromUnknown(data.unit_amount), + livemode: maybeBoolean(data.livemode), + type: maybeString(data.type), + message: undefined, + code: undefined, + errorType: undefined, + }; + } +); + +export const get_remote_checkout_session = spacetimedb.procedure( + { sessionId: t.string() }, + t.object('RemoteCheckoutSessionResult', { + ok: t.bool(), + status: t.u16(), + sessionId: t.option(t.string()), + paymentStatus: t.option(t.string()), + sessionStatus: t.option(t.string()), + mode: t.option(t.string()), + amountTotal: t.option(t.i64()), + currency: t.option(t.string()), + customerId: t.option(t.string()), + paymentIntentId: t.option(t.string()), + message: t.option(t.string()), + code: t.option(t.string()), + errorType: t.option(t.string()), + }), + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const response = callStripe(ctx, { + method: 'GET', + path: `/v1/checkout/sessions/${args.sessionId}`, + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + idempotencyKey: undefined, + formBody: undefined, + }); + const parsed = safeJsonParse(response.body); + const isOk = response.status >= 200 && response.status < 300; + if (!isOk) { + const err = + isRecord(parsed) && isRecord(parsed.error) ? parsed.error : undefined; + return { + ok: false, + status: response.status, + sessionId: undefined, + paymentStatus: undefined, + sessionStatus: undefined, + mode: undefined, + amountTotal: undefined, + currency: undefined, + customerId: undefined, + paymentIntentId: undefined, + message: + maybeString(err?.message) ?? `Stripe returned ${response.status}.`, + code: maybeString(err?.code), + errorType: maybeString(err?.type), + }; + } + const data = isRecord(parsed) ? parsed : {}; + return { + ok: true, + status: response.status, + sessionId: maybeString(data.id) ?? args.sessionId, + paymentStatus: maybeString(data.payment_status), + sessionStatus: maybeString(data.status), + mode: maybeString(data.mode), + amountTotal: maybeBigIntFromUnknown(data.amount_total), + currency: maybeString(data.currency), + customerId: maybeId(data.customer), + paymentIntentId: maybeId(data.payment_intent), + message: undefined, + code: undefined, + errorType: undefined, + }; + } +); + +// Cheap count of stripe_webhook_event rows; exposes the metric without leaking payloads. +export const get_webhook_event_count = spacetimedb.procedure({}, t.i64(), ctx => + withAdminTx(ctx, tx => BigInt(tx.db.stripeWebhookEvent.count())) +); + +export const stripe_api_request = spacetimedb.procedure( + { + method: t.string(), + path: t.string(), + formBody: t.option(t.string()), + idempotencyKey: t.option(t.string()), + }, + stripeHttpResponse, + (ctx, args) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + const cfg = loadConfigOrThrowFromProcedure(ctx); + return callStripe(ctx, { + method: args.method, + path: args.path, + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + formBody: args.formBody, + idempotencyKey: args.idempotencyKey, + }); + } +); + +export const create_customer = spacetimedb.procedure( + { + email: t.option(t.string()), + name: t.option(t.string()), + metadataJson: t.option(t.string()), + idempotencyKey: t.option(t.string()), + }, + createCustomerResult, + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const customerId = createCustomerInStripeAndSync(ctx, { + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + email: args.email, + name: args.name, + metadataJson: args.metadataJson, + idempotencyKey: args.idempotencyKey, + }); + return { customerId }; + } +); + +export const create_or_update_customer = spacetimedb.procedure( + { + stripeCustomerId: t.string(), + email: t.option(t.string()), + name: t.option(t.string()), + metadataJson: t.option(t.string()), + }, + t.string(), + (ctx, args) => { + requireProcedureAdmin(ctx); + const details = coerceMetadataFromJson(args.metadataJson); + ctx.withTx(tx => { + upsertCustomer(tx, ctx.timestamp, { + stripeCustomerId: args.stripeCustomerId, + appUserId: undefined, + email: args.email, + name: args.name, + metadataJson: details.metadataJson, + userId: details.userId, + }); + }); + return args.stripeCustomerId; + } +); + +export const update_subscription_metadata = spacetimedb.procedure( + { + stripeSubscriptionId: t.string(), + metadataJson: t.string(), + orgId: t.option(t.string()), + userId: t.option(t.string()), + }, + t.unit(), + (ctx, args) => { + requireProcedureAdmin(ctx); + const parsedDetails = coerceMetadataFromJson(args.metadataJson); + ctx.withTx(tx => { + const existing = tx.db.stripeSubscription.stripeSubscriptionId.find( + args.stripeSubscriptionId + ); + if (!existing) { + throwSenderError( + `stripe.subscription_not_found:${args.stripeSubscriptionId}` + ); + } + upsertSubscription(tx, ctx.timestamp, { + stripeSubscriptionId: existing.stripeSubscriptionId, + stripeCustomerId: existing.stripeCustomerId, + status: existing.status, + currentPeriodEndUnix: existing.currentPeriodEndUnix, + cancelAtPeriodEnd: existing.cancelAtPeriodEnd, + cancelAtUnix: existing.cancelAtUnix, + quantity: existing.quantity, + priceId: existing.priceId, + metadataJson: parsedDetails.metadataJson ?? args.metadataJson, + orgId: args.orgId ?? parsedDetails.orgId ?? existing.orgId, + userId: args.userId ?? parsedDetails.userId ?? existing.userId, + }); + }); + return {}; + } +); +export const get_or_create_customer = spacetimedb.procedure( + { + userId: t.string(), + email: t.option(t.string()), + name: t.option(t.string()), + }, + getOrCreateCustomerResult, + (ctx, args) => { + requireProcedureAdmin(ctx); + const existingByUser = ctx.withTx(tx => { + for (const customer of tx.db.stripeCustomer.byUserId.filter(args.userId)) + return customer; + return undefined; + }); + if (existingByUser) { + return { customerId: existingByUser.stripeCustomerId, isNew: false }; + } + + if (args.email) { + const existingByEmail = ctx.withTx(tx => { + for (const customer of tx.db.stripeCustomer.byEmail.filter(args.email)) + return customer; + return undefined; + }); + if (existingByEmail) { + return { customerId: existingByEmail.stripeCustomerId, isNew: false }; + } + } + + const existingSub = ctx.withTx(tx => { + for (const sub of tx.db.stripeSubscription.byUserId.filter(args.userId)) + return sub; + return undefined; + }); + if (existingSub) { + return { customerId: existingSub.stripeCustomerId, isNew: false }; + } + + const existingPayment = ctx.withTx(tx => { + for (const payment of tx.db.stripePayment.byUserId.filter(args.userId)) { + if (payment.userId === args.userId && payment.stripeCustomerId) + return payment; + } + return undefined; + }); + if (existingPayment?.stripeCustomerId) { + return { customerId: existingPayment.stripeCustomerId, isNew: false }; + } + + const cfg = loadConfigOrThrowFromProcedure(ctx); + const metadataJson = JSON.stringify({ userId: args.userId }); + const customerId = createCustomerInStripeAndSync(ctx, { + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + email: args.email, + name: args.name, + metadataJson, + idempotencyKey: args.userId, + }); + return { customerId, isNew: true }; + } +); + +// Stripe enforces one mode per session; all items must share mode. +export const create_checkout_session = spacetimedb.procedure( + { + items: t.array( + t.object('CheckoutLineItem', { + priceId: t.string(), + quantity: t.i64(), + }) + ), + customerId: t.option(t.string()), + mode: t.string(), + successUrl: t.string(), + cancelUrl: t.string(), + metadataJson: t.option(t.string()), + subscriptionMetadataJson: t.option(t.string()), + paymentIntentMetadataJson: t.option(t.string()), + }, + checkoutSessionResult, + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + if (args.items.length === 0) { + throwSenderError('stripe.checkout_session_requires_items'); + } + const formPairs: Array<[string, string | undefined]> = [ + ['mode', args.mode], + ['success_url', args.successUrl], + ['cancel_url', args.cancelUrl], + ['customer', args.customerId], + ]; + args.items.forEach((item, i) => { + formPairs.push([`line_items[${i}][price]`, item.priceId]); + formPairs.push([`line_items[${i}][quantity]`, String(item.quantity)]); + }); + for (const [k, v] of metadataJsonToFormPairs( + 'metadata', + args.metadataJson + )) { + formPairs.push([k, v]); + } + if (args.mode === 'subscription') { + for (const [k, v] of metadataJsonToFormPairs( + 'subscription_data[metadata]', + args.subscriptionMetadataJson + )) { + formPairs.push([k, v]); + } + } + if (args.mode === 'payment') { + for (const [k, v] of metadataJsonToFormPairs( + 'payment_intent_data[metadata]', + args.paymentIntentMetadataJson + )) { + formPairs.push([k, v]); + } + } + + const response = callStripe(ctx, { + method: 'POST', + path: '/v1/checkout/sessions', + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + idempotencyKey: undefined, + formBody: formPairsToBody(formPairs), + }); + if (response.status < 200 || response.status >= 300) { + throwSenderError( + `stripe.checkout_session_failed:${response.status}${stripeErrorSuffix(response.body)}` + ); + } + + const sessionResult = v.safeParse( + vStripeCheckoutSessionResponse, + safeJsonParse(response.body) + ); + if (!sessionResult.success) { + throwSenderError( + `stripe.checkout_session_invalid_response:${summarizeIssues(sessionResult.issues)}` + ); + } + return { + sessionId: sessionResult.output.id, + url: sessionResult.output.url ?? undefined, + }; + } +); + +export const create_customer_portal_session = spacetimedb.procedure( + { + customerId: t.string(), + returnUrl: t.string(), + }, + portalSessionResult, + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const response = callStripe(ctx, { + method: 'POST', + path: '/v1/billing_portal/sessions', + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + idempotencyKey: undefined, + formBody: formPairsToBody([ + ['customer', args.customerId], + ['return_url', args.returnUrl], + ]), + }); + if (response.status < 200 || response.status >= 300) { + throwSenderError( + `stripe.portal_session_failed:${response.status}${stripeErrorSuffix(response.body)}` + ); + } + + const portalResult = v.safeParse( + vStripeBillingPortalSessionResponse, + safeJsonParse(response.body) + ); + if (!portalResult.success) { + throwSenderError( + `stripe.portal_session_invalid_response:${summarizeIssues(portalResult.issues)}` + ); + } + return { url: portalResult.output.url }; + } +); + +function patchSubscriptionFromStripe( + ctx: ProcedureModuleCtx, + args: { + secretKey: string; + stripeVersion: string | undefined; + stripeSubscriptionId: string; + formBody: string; + } +) { + const response = callStripe(ctx, { + method: 'POST', + path: `/v1/subscriptions/${args.stripeSubscriptionId}`, + secretKey: args.secretKey, + stripeVersion: args.stripeVersion, + idempotencyKey: undefined, + formBody: args.formBody, + }); + if (response.status < 200 || response.status >= 300) { + throwSenderError( + `stripe.subscription_update_failed:${response.status}${stripeErrorSuffix(response.body)}` + ); + } + const parsed = safeJsonParse(response.body); + if (!isRecord(parsed)) + throwSenderError('stripe.subscription_update_invalid_response'); + return parsed; +} + +function syncSubscriptionObjectFromStripe( + ctx: ProcedureModuleCtx, + stripeSubscription: JsonRecord +) { + const subscriptionId = maybeString(stripeSubscription.id); + const customerId = maybeId(stripeSubscription.customer); + const status = maybeString(stripeSubscription.status); + if (!subscriptionId || !customerId || !status) { + throwSenderError('stripe.subscription_payload_missing_fields'); + } + + const items = isRecord(stripeSubscription.items) + ? stripeSubscription.items + : undefined; + const firstItem = Array.isArray(items?.data) ? items.data[0] : undefined; + const first = isRecord(firstItem) ? firstItem : undefined; + const price = isRecord(first?.price) ? first.price : undefined; + + const currentPeriodEnd = + maybeBigIntFromUnknown(first?.current_period_end) ?? + maybeBigIntFromUnknown(stripeSubscription.current_period_end) ?? + 0n; + const cancelAtUnix = maybeBigIntFromUnknown(stripeSubscription.cancel_at); + const cancelAtPeriodEnd = + maybeBoolean(stripeSubscription.cancel_at_period_end) ?? + deriveCancelAtPeriodEnd(cancelAtUnix, currentPeriodEnd); + const quantity = maybeBigIntFromUnknown(first?.quantity); + const meta = metadataInfo(stripeSubscription.metadata); + + ctx.withTx(tx => { + upsertSubscription(tx, ctx.timestamp, { + stripeSubscriptionId: subscriptionId, + stripeCustomerId: customerId, + status, + currentPeriodEndUnix: currentPeriodEnd, + cancelAtPeriodEnd, + cancelAtUnix, + quantity, + priceId: maybeString(price?.id), + metadataJson: meta.metadataJson, + orgId: meta.orgId, + userId: meta.userId, + }); + }); +} + +export const cancel_subscription = spacetimedb.procedure( + { + stripeSubscriptionId: t.string(), + cancelAtPeriodEnd: t.option(t.bool()), + }, + t.unit(), + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const atPeriodEnd = args.cancelAtPeriodEnd ?? true; + const stripeSubscription = atPeriodEnd + ? patchSubscriptionFromStripe(ctx, { + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + stripeSubscriptionId: args.stripeSubscriptionId, + formBody: formPairsToBody([['cancel_at_period_end', 'true']]), + }) + : (() => { + const response = callStripe(ctx, { + method: 'DELETE', + path: `/v1/subscriptions/${args.stripeSubscriptionId}`, + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + idempotencyKey: undefined, + formBody: undefined, + }); + if (response.status < 200 || response.status >= 300) { + throwSenderError( + `stripe.subscription_cancel_failed:${response.status}${stripeErrorSuffix(response.body)}` + ); + } + const parsed = safeJsonParse(response.body); + if (!isRecord(parsed)) { + throwSenderError('stripe.subscription_cancel_invalid_response'); + } + return parsed; + })(); + + syncSubscriptionObjectFromStripe(ctx, stripeSubscription); + return {}; + } +); + +export const reactivate_subscription = spacetimedb.procedure( + { + stripeSubscriptionId: t.string(), + }, + t.unit(), + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const stripeSubscription = patchSubscriptionFromStripe(ctx, { + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + stripeSubscriptionId: args.stripeSubscriptionId, + formBody: formPairsToBody([['cancel_at_period_end', 'false']]), + }); + syncSubscriptionObjectFromStripe(ctx, stripeSubscription); + return {}; + } +); + +export const update_subscription_quantity = spacetimedb.procedure( + { + stripeSubscriptionId: t.string(), + quantity: t.i64(), + }, + t.unit(), + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const getResponse = callStripe(ctx, { + method: 'GET', + path: `/v1/subscriptions/${args.stripeSubscriptionId}`, + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + idempotencyKey: undefined, + formBody: undefined, + }); + if (getResponse.status < 200 || getResponse.status >= 300) { + throwSenderError( + `stripe.subscription_lookup_failed:${getResponse.status}${stripeErrorSuffix(getResponse.body)}` + ); + } + + const existing = safeJsonParse(getResponse.body); + if (!isRecord(existing)) + throwSenderError('stripe.subscription_lookup_invalid_response'); + const items = isRecord(existing.items) ? existing.items : undefined; + const firstItem = Array.isArray(items?.data) ? items.data[0] : undefined; + const firstRecord = isRecord(firstItem) ? firstItem : undefined; + const subscriptionItemId = maybeString(firstRecord?.id); + if (!subscriptionItemId) + throwSenderError('stripe.subscription_missing_line_items'); + + const updateResponse = callStripe(ctx, { + method: 'POST', + path: `/v1/subscription_items/${subscriptionItemId}`, + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + idempotencyKey: undefined, + formBody: formPairsToBody([['quantity', String(args.quantity)]]), + }); + if (updateResponse.status < 200 || updateResponse.status >= 300) { + throwSenderError( + `stripe.subscription_item_update_failed:${updateResponse.status}${stripeErrorSuffix(updateResponse.body)}` + ); + } + + ctx.withTx(tx => { + const localSub = tx.db.stripeSubscription.stripeSubscriptionId.find( + args.stripeSubscriptionId + ); + if (!localSub) return; + upsertSubscription(tx, ctx.timestamp, { + stripeSubscriptionId: localSub.stripeSubscriptionId, + stripeCustomerId: localSub.stripeCustomerId, + status: localSub.status, + currentPeriodEndUnix: localSub.currentPeriodEndUnix, + cancelAtPeriodEnd: localSub.cancelAtPeriodEnd, + cancelAtUnix: localSub.cancelAtUnix, + quantity: args.quantity, + priceId: localSub.priceId, + metadataJson: localSub.metadataJson, + orgId: localSub.orgId, + userId: localSub.userId, + }); + }); + return {}; + } +); diff --git a/spacetime-stripe-ts/src/submodule/operations/queries.ts b/spacetime-stripe-ts/src/submodule/operations/queries.ts new file mode 100644 index 00000000000..83df7cebec7 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/operations/queries.ts @@ -0,0 +1,222 @@ +import { + Range, + t, + spacetimedb, + stripeCustomerTable, + stripeSubscriptionTable, + stripeCheckoutSessionTable, + stripePaymentTable, + stripeInvoiceTable, + subscriptionWithCreationTime, +} from '../schema'; +import { withAdminTx, takeRows } from '../operations'; + +export const get_customer = spacetimedb.procedure( + { stripeCustomerId: t.string() }, + t.option(stripeCustomerTable.rowType), + (ctx, { stripeCustomerId }) => + withAdminTx( + ctx, + tx => + tx.db.stripeCustomer.stripeCustomerId.find(stripeCustomerId) ?? + undefined + ) +); + +export const get_customer_by_email = spacetimedb.procedure( + { email: t.string() }, + t.option(stripeCustomerTable.rowType), + (ctx, { email }) => + withAdminTx(ctx, tx => { + for (const customer of tx.db.stripeCustomer.byEmail.filter(email)) + return customer; + return undefined; + }) +); + +export const get_customer_by_user_id = spacetimedb.procedure( + { userId: t.string() }, + t.option(stripeCustomerTable.rowType), + (ctx, { userId }) => + withAdminTx(ctx, tx => { + for (const customer of tx.db.stripeCustomer.byUserId.filter(userId)) + return customer; + return undefined; + }) +); + +export const get_subscription = spacetimedb.procedure( + { stripeSubscriptionId: t.string() }, + t.option(stripeSubscriptionTable.rowType), + (ctx, { stripeSubscriptionId }) => + withAdminTx( + ctx, + tx => + tx.db.stripeSubscription.stripeSubscriptionId.find( + stripeSubscriptionId + ) ?? undefined + ) +); + +export const list_subscriptions = spacetimedb.procedure( + { stripeCustomerId: t.string() }, + t.array(stripeSubscriptionTable.rowType), + (ctx, { stripeCustomerId }) => + withAdminTx(ctx, tx => + takeRows(tx.db.stripeSubscription.byCustomer.filter(stripeCustomerId)) + ) +); + +export const list_subscriptions_with_creation_time = spacetimedb.procedure( + { stripeCustomerId: t.string() }, + t.array(subscriptionWithCreationTime), + (ctx, { stripeCustomerId }) => + withAdminTx(ctx, tx => + takeRows( + tx.db.stripeSubscription.byCustomerInsertedAt.filter([ + stripeCustomerId, + new Range(), + ]) + ).map(sub => ({ + insertedAtMicros: sub.insertedAt.microsSinceUnixEpoch, + stripeSubscriptionId: sub.stripeSubscriptionId, + stripeCustomerId: sub.stripeCustomerId, + status: sub.status, + })) + ) +); + +export const get_subscription_by_org_id = spacetimedb.procedure( + { orgId: t.string() }, + t.option(stripeSubscriptionTable.rowType), + (ctx, { orgId }) => + withAdminTx(ctx, tx => { + const matches = takeRows( + tx.db.stripeSubscription.byOrgInsertedAt.filter([orgId, new Range()]), + 5000 + ); + let latest = matches[0]; + for (const sub of matches) { + if (!latest) { + latest = sub; + continue; + } + const currentMicros = sub.insertedAt.microsSinceUnixEpoch; + const latestMicros = latest.insertedAt.microsSinceUnixEpoch; + if ( + currentMicros > latestMicros || + (currentMicros === latestMicros && + sub.stripeSubscriptionId > latest.stripeSubscriptionId) + ) { + latest = sub; + } + } + return latest; + }) +); + +export const list_subscriptions_by_org_id = spacetimedb.procedure( + { orgId: t.string() }, + t.array(stripeSubscriptionTable.rowType), + (ctx, { orgId }) => + withAdminTx(ctx, tx => + takeRows( + tx.db.stripeSubscription.byOrgInsertedAt.filter([orgId, new Range()]) + ) + ) +); + +export const list_subscriptions_by_user_id = spacetimedb.procedure( + { userId: t.string() }, + t.array(stripeSubscriptionTable.rowType), + (ctx, { userId }) => + withAdminTx(ctx, tx => + takeRows( + tx.db.stripeSubscription.byUserInsertedAt.filter([userId, new Range()]) + ) + ) +); + +export const get_payment = spacetimedb.procedure( + { stripePaymentIntentId: t.string() }, + t.option(stripePaymentTable.rowType), + (ctx, { stripePaymentIntentId }) => + withAdminTx( + ctx, + tx => + tx.db.stripePayment.stripePaymentIntentId.find(stripePaymentIntentId) ?? + undefined + ) +); + +export const list_payments = spacetimedb.procedure( + { stripeCustomerId: t.string() }, + t.array(stripePaymentTable.rowType), + (ctx, { stripeCustomerId }) => + withAdminTx(ctx, tx => + takeRows(tx.db.stripePayment.byCustomer.filter(stripeCustomerId)) + ) +); + +export const list_payments_by_user_id = spacetimedb.procedure( + { userId: t.string() }, + t.array(stripePaymentTable.rowType), + (ctx, { userId }) => + withAdminTx(ctx, tx => + takeRows(tx.db.stripePayment.byUserId.filter(userId)) + ) +); + +export const list_payments_by_org_id = spacetimedb.procedure( + { orgId: t.string() }, + t.array(stripePaymentTable.rowType), + (ctx, { orgId }) => + withAdminTx(ctx, tx => takeRows(tx.db.stripePayment.byOrgId.filter(orgId))) +); + +export const list_invoices = spacetimedb.procedure( + { stripeCustomerId: t.string() }, + t.array(stripeInvoiceTable.rowType), + (ctx, { stripeCustomerId }) => + withAdminTx(ctx, tx => + takeRows(tx.db.stripeInvoice.byCustomer.filter(stripeCustomerId)) + ) +); + +export const list_invoices_by_org_id = spacetimedb.procedure( + { orgId: t.string() }, + t.array(stripeInvoiceTable.rowType), + (ctx, { orgId }) => + withAdminTx(ctx, tx => takeRows(tx.db.stripeInvoice.byOrgId.filter(orgId))) +); + +export const list_invoices_by_user_id = spacetimedb.procedure( + { userId: t.string() }, + t.array(stripeInvoiceTable.rowType), + (ctx, { userId }) => + withAdminTx(ctx, tx => + takeRows(tx.db.stripeInvoice.byUserId.filter(userId)) + ) +); + +export const get_checkout_session = spacetimedb.procedure( + { stripeCheckoutSessionId: t.string() }, + t.option(stripeCheckoutSessionTable.rowType), + (ctx, { stripeCheckoutSessionId }) => + withAdminTx( + ctx, + tx => + tx.db.stripeCheckoutSession.stripeCheckoutSessionId.find( + stripeCheckoutSessionId + ) ?? undefined + ) +); + +export const list_checkout_sessions = spacetimedb.procedure( + { stripeCustomerId: t.string() }, + t.array(stripeCheckoutSessionTable.rowType), + (ctx, { stripeCustomerId }) => + withAdminTx(ctx, tx => + takeRows(tx.db.stripeCheckoutSession.byCustomer.filter(stripeCustomerId)) + ) +); diff --git a/spacetime-stripe-ts/src/submodule/operations/webhook.ts b/spacetime-stripe-ts/src/submodule/operations/webhook.ts new file mode 100644 index 00000000000..6922e243c23 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/operations/webhook.ts @@ -0,0 +1,110 @@ +import { WebhookEventStatus, spacetimedb } from '../schema'; +import { + SyncResponse, + type HandlerContext, + type Request as StdbRequest, +} from 'spacetimedb/server'; +import { verifyStripeSignature } from '@spacetimedb/crypto'; +import { parseStripeEventMetadata } from '../webhook-metadata'; +import { MAX_WEBHOOK_METADATA_LENGTH } from '../limits'; +import { applyStripeEvent, updateWebhookStatus } from '../operations'; +import { + validateWebhookRequestBody, + validateWebhookRequestHeaders, +} from '../webhook-request'; + +// POST $STDB_URI/v1/database//route/stripe/webhook +function jsonResponse(status: number, body: unknown): SyncResponse { + return new SyncResponse(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +export function handleStripeWebhook( + ctx: HandlerContext, + req: StdbRequest +): SyncResponse { + const signatureHeader = req.headers.get('stripe-signature') ?? undefined; + const headerRejection = validateWebhookRequestHeaders( + req.method, + req.headers.get('content-length'), + signatureHeader + ); + if (headerRejection) { + return jsonResponse(headerRejection.status, { + error: headerRejection.error, + }); + } + + const payloadJson = req.text(); + const bodyRejection = validateWebhookRequestBody(payloadJson); + if (bodyRejection) { + return jsonResponse(bodyRejection.status, { error: bodyRejection.error }); + } + + // Webhook processing requires a configured signing secret. + const webhookSecret = ctx.withTx(tx => { + const cfg = tx.db.stripeConfig.singleton.find(true); + return cfg?.webhookSigningSecret ?? undefined; + }); + if (!webhookSecret) { + return jsonResponse(503, { + error: 'webhook signing secret not configured', + }); + } + const nowSeconds = Number(ctx.timestamp.microsSinceUnixEpoch / 1_000_000n); + const sigOk = verifyStripeSignature({ + rawBody: payloadJson, + signatureHeader: signatureHeader ?? '', + secret: webhookSecret, + nowSeconds, + }); + if (!sigOk) { + return jsonResponse(401, { error: 'signature mismatch' }); + } + + const metadata = parseStripeEventMetadata(payloadJson); + if (!metadata) + return jsonResponse(400, { error: 'missing or invalid event metadata' }); + const { eventId, eventType, livemode } = metadata; + if ( + eventId.length === 0 || + eventId.length > MAX_WEBHOOK_METADATA_LENGTH || + eventType.length === 0 || + eventType.length > MAX_WEBHOOK_METADATA_LENGTH + ) { + return jsonResponse(400, { error: 'invalid event metadata' }); + } + + try { + const outcome = ctx.withTx(tx => { + const existing = tx.db.stripeWebhookEvent.eventId.find(eventId); + if (existing) return { kind: 'duplicate', status: existing.status }; + + tx.db.stripeWebhookEvent.insert({ + eventId, + eventType, + livemode, + signatureHeader, + payloadJson, + status: WebhookEventStatus.Received, + errorMessage: undefined, + receivedAt: tx.timestamp, + processedAt: undefined, + }); + + const result = applyStripeEvent(tx, payloadJson); + updateWebhookStatus(tx, eventId, result.status, result.error); + return { kind: 'applied', status: result.status, error: result.error }; + }); + + return jsonResponse(200, { ok: true, eventId, ...outcome }); + } catch (error) { + console.error(`stripe webhook processing failed for ${eventId}:`, error); + return jsonResponse(500, { error: 'webhook processing failed' }); + } +} + +export const stripe_webhook_handler = + spacetimedb.httpHandler(handleStripeWebhook); diff --git a/spacetime-stripe-ts/src/submodule/router.ts b/spacetime-stripe-ts/src/submodule/router.ts new file mode 100644 index 00000000000..8fd6b3bdfcd --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/router.ts @@ -0,0 +1,7 @@ +import { Router } from 'spacetimedb/server'; +import { spacetimedb } from './schema'; +import { stripe_webhook_handler } from './operations/webhook'; + +export const stripeWebhookRouter = spacetimedb.httpRouter( + new Router().post('/stripe/webhook', stripe_webhook_handler) +); diff --git a/spacetime-stripe-ts/src/submodule/schema.ts b/spacetime-stripe-ts/src/submodule/schema.ts new file mode 100644 index 00000000000..d4188f9a232 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/schema.ts @@ -0,0 +1,457 @@ +import { + schema, + table, + t, + Range, + SenderError, + type ProcedureCtx, + type ReducerCtx, + type TransactionCtx, +} from 'spacetimedb/server'; +import * as v from 'valibot'; +import { installStripe } from './install'; + +// Internal ingest lifecycle for webhook rows. Received = stored. Processed = +// applied to the data model. Ignored = duplicate or unhandled event type. +// Failed = signature/format error. +// +// The other status columns on this schema (subscription, checkout, invoice, +// and payment) stay as t.string() because they reflect Stripe-owned vocabulary +// delivered by webhooks. An open string preserves new provider states. +// Stripe's TS types lift them to literal unions on the SDK side; consumers +// can do `subscription.status === 'active'` directly against the wire value. +export const webhookEventStatus = t.enum('WebhookEventStatus', [ + 'Received', + 'Processed', + 'Ignored', + 'Failed', +]); +export const WebhookEventStatus = { + Received: { tag: 'Received' as const }, + Processed: { tag: 'Processed' as const }, + Ignored: { tag: 'Ignored' as const }, + Failed: { tag: 'Failed' as const }, +}; +export type WebhookEventStatusValue = + (typeof WebhookEventStatus)[keyof typeof WebhookEventStatus]; + +export const stripeCustomerRow = { + stripeCustomerId: t.string().primaryKey(), + appUserId: t.option(t.string()), + email: t.option(t.string()), + name: t.option(t.string()), + metadataJson: t.option(t.string()), + userId: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +export const stripeSubscriptionRow = { + stripeSubscriptionId: t.string().primaryKey(), + stripeCustomerId: t.string(), + status: t.string(), + currentPeriodEndUnix: t.i64(), + cancelAtPeriodEnd: t.bool(), + cancelAtUnix: t.option(t.i64()), + quantity: t.option(t.i64()), + priceId: t.option(t.string()), + metadataJson: t.option(t.string()), + orgId: t.option(t.string()), + userId: t.option(t.string()), + insertedAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +export const stripeCheckoutSessionRow = { + stripeCheckoutSessionId: t.string().primaryKey(), + stripeCustomerId: t.option(t.string()), + status: t.string(), + mode: t.string(), + metadataJson: t.option(t.string()), + insertedAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +export const stripePaymentRow = { + stripePaymentIntentId: t.string().primaryKey(), + stripeCustomerId: t.option(t.string()), + amount: t.i64(), + currency: t.string(), + status: t.string(), + createdUnix: t.i64(), + metadataJson: t.option(t.string()), + orgId: t.option(t.string()), + userId: t.option(t.string()), + insertedAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +export const stripeInvoiceRow = { + stripeInvoiceId: t.string().primaryKey(), + stripeCustomerId: t.string(), + stripeSubscriptionId: t.option(t.string()), + status: t.string(), + amountDue: t.i64(), + amountPaid: t.i64(), + createdUnix: t.i64(), + orgId: t.option(t.string()), + userId: t.option(t.string()), + insertedAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +export const stripeWebhookEventRow = { + eventId: t.string().primaryKey(), + eventType: t.string(), + livemode: t.bool(), + signatureHeader: t.option(t.string()), + payloadJson: t.string(), + status: webhookEventStatus, + errorMessage: t.option(t.string()), + receivedAt: t.timestamp(), + processedAt: t.option(t.timestamp()), +}; + +export const stripeCustomerTable = table( + { + name: 'stripe_customer', + public: false, + indexes: [ + { accessor: 'byEmail', algorithm: 'btree', columns: ['email'] }, + { accessor: 'byUserId', algorithm: 'btree', columns: ['userId'] }, + ], + }, + stripeCustomerRow +); +export const stripeSubscriptionTable = table( + { + name: 'stripe_subscription', + public: false, + indexes: [ + { + accessor: 'byCustomer', + algorithm: 'btree', + columns: ['stripeCustomerId'], + }, + { + accessor: 'byCustomerInsertedAt', + algorithm: 'btree', + columns: ['stripeCustomerId', 'insertedAt'], + }, + { accessor: 'byOrgId', algorithm: 'btree', columns: ['orgId'] }, + { + accessor: 'byOrgInsertedAt', + algorithm: 'btree', + columns: ['orgId', 'insertedAt'], + }, + { accessor: 'byUserId', algorithm: 'btree', columns: ['userId'] }, + { + accessor: 'byUserInsertedAt', + algorithm: 'btree', + columns: ['userId', 'insertedAt'], + }, + ], + }, + stripeSubscriptionRow +); +export const stripeCheckoutSessionTable = table( + { + name: 'stripe_checkout_session', + public: false, + indexes: [ + { + accessor: 'byCustomer', + algorithm: 'btree', + columns: ['stripeCustomerId'], + }, + ], + }, + stripeCheckoutSessionRow +); +export const stripePaymentTable = table( + { + name: 'stripe_payment', + public: false, + indexes: [ + { + accessor: 'byCustomer', + algorithm: 'btree', + columns: ['stripeCustomerId'], + }, + { accessor: 'byOrgId', algorithm: 'btree', columns: ['orgId'] }, + { accessor: 'byUserId', algorithm: 'btree', columns: ['userId'] }, + ], + }, + stripePaymentRow +); +export const stripeInvoiceTable = table( + { + name: 'stripe_invoice', + public: false, + indexes: [ + { + accessor: 'byCustomer', + algorithm: 'btree', + columns: ['stripeCustomerId'], + }, + { + accessor: 'bySubscription', + algorithm: 'btree', + columns: ['stripeSubscriptionId'], + }, + { accessor: 'byOrgId', algorithm: 'btree', columns: ['orgId'] }, + { accessor: 'byUserId', algorithm: 'btree', columns: ['userId'] }, + ], + }, + stripeInvoiceRow +); +export const stripeWebhookEventTable = table( + { + name: 'stripe_webhook_event', + public: false, + indexes: [ + { accessor: 'byStatus', algorithm: 'btree', columns: ['status'] }, + ], + }, + stripeWebhookEventRow +); + +// Singleton row holding deploy-time secrets. Private, never subscribable. +export const stripeConfigRow = { + singleton: t.bool().primaryKey(), + secretKey: t.string(), + stripeVersion: t.option(t.string()), + webhookSigningSecret: t.option(t.string()), + updatedAt: t.timestamp(), +}; + +export const stripeConfigTable = table( + { name: 'stripe_config', public: false, indexes: [] }, + stripeConfigRow +); + +// Allowlist of identities permitted to call privileged procedures. +export const stripeAdminIdentityRow = { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), +}; + +export const stripeAdminIdentityTable = table( + { name: 'stripe_admin_identity', public: false, indexes: [] }, + stripeAdminIdentityRow +); + +export const spacetimedb = schema({ + stripeCustomer: stripeCustomerTable, + stripeSubscription: stripeSubscriptionTable, + stripeCheckoutSession: stripeCheckoutSessionTable, + stripePayment: stripePaymentTable, + stripeInvoice: stripeInvoiceTable, + stripeWebhookEvent: stripeWebhookEventTable, + stripeConfig: stripeConfigTable, + stripeAdminIdentity: stripeAdminIdentityTable, +}); + +export const init = spacetimedb.init(ctx => { + installStripe(ctx); +}); + +export default spacetimedb; + +export type ReducerModuleCtx = ReducerCtx; +export type ProcedureModuleCtx = ProcedureCtx; +export type TransactionModuleCtx = TransactionCtx< + typeof spacetimedb.schemaType +>; +export type WriteCtx = ReducerModuleCtx | TransactionModuleCtx; +export type JsonRecord = Record; +export type ModuleTimestamp = ReducerModuleCtx['timestamp']; + +export const stripeHttpResponse = t.object('StripeHttpResponse', { + status: t.u16(), + body: t.string(), +}); + +export const checkoutSessionResult = t.object('CheckoutSessionResult', { + sessionId: t.string(), + url: t.option(t.string()), +}); + +export const createCustomerResult = t.object('CreateCustomerResult', { + customerId: t.string(), +}); + +export const getOrCreateCustomerResult = t.object('GetOrCreateCustomerResult', { + customerId: t.string(), + isNew: t.bool(), +}); + +export const portalSessionResult = t.object('PortalSessionResult', { + url: t.string(), +}); + +export const subscriptionWithCreationTime = t.object( + 'SubscriptionWithCreationTime', + { + insertedAtMicros: t.i64(), + stripeSubscriptionId: t.string(), + stripeCustomerId: t.string(), + status: t.string(), + } +); + +export { Range, SenderError, t }; + +const vMetadata = v.optional( + v.union([v.record(v.string(), v.string()), v.null()]) +); + +// Stripe "expandable" fields are either a string ID or an object with an id. +const vExpandableId = v.union([v.string(), v.object({ id: v.string() })]); +const vExpandableIdOrNull = v.union([ + v.string(), + v.object({ id: v.string() }), + v.null(), +]); + +export type ExpandableId = v.InferOutput; +export type ExpandableIdOrNull = v.InferOutput; + +export function extractExpandableId(value: ExpandableId): string { + return typeof value === 'string' ? value : value.id; +} + +export function extractExpandableIdOrNull( + value: ExpandableIdOrNull +): string | null { + if (value === null) return null; + return typeof value === 'string' ? value : value.id; +} + +const vCustomerObject = v.object({ + id: v.string(), + email: v.optional(v.union([v.string(), v.null()])), + name: v.optional(v.union([v.string(), v.null()])), + metadata: vMetadata, +}); + +const vSubscriptionItem = v.object({ + current_period_end: v.optional(v.number()), + quantity: v.optional(v.number()), + price: v.optional(v.union([v.object({ id: v.string() }), v.null()])), +}); + +const vSubscriptionObject = v.object({ + id: v.string(), + customer: vExpandableId, + status: v.string(), + current_period_end: v.optional(v.number()), + cancel_at: v.optional(v.union([v.number(), v.null()])), + cancel_at_period_end: v.optional(v.boolean()), + items: v.optional(v.object({ data: v.array(vSubscriptionItem) })), + metadata: vMetadata, +}); + +const vCheckoutSessionObject = v.object({ + id: v.string(), + mode: v.optional(v.string()), + customer: v.optional(vExpandableIdOrNull), + metadata: vMetadata, +}); + +const vInvoiceObject = v.object({ + id: v.string(), + // Stripe.Invoice.customer is nullable; the apply function rejects null. + customer: vExpandableIdOrNull, + subscription: v.optional(vExpandableIdOrNull), + status: v.optional(v.union([v.string(), v.null()])), + amount_due: v.optional(v.number()), + amount_paid: v.optional(v.number()), + created: v.optional(v.number()), +}); + +const vPaymentIntentObject = v.object({ + id: v.string(), + customer: v.optional(vExpandableIdOrNull), + invoice: v.optional(vExpandableIdOrNull), + amount: v.optional(v.number()), + currency: v.optional(v.string()), + status: v.optional(v.string()), + created: v.optional(v.number()), + metadata: vMetadata, +}); + +// Discriminated union over the 12 handled event types; unknown types route to status=failed. +export const vStripeEvent = v.variant('type', [ + v.object({ + type: v.literal('customer.created'), + data: v.object({ object: vCustomerObject }), + }), + v.object({ + type: v.literal('customer.updated'), + data: v.object({ object: vCustomerObject }), + }), + v.object({ + type: v.literal('customer.subscription.created'), + data: v.object({ object: vSubscriptionObject }), + }), + v.object({ + type: v.literal('customer.subscription.updated'), + data: v.object({ object: vSubscriptionObject }), + }), + v.object({ + type: v.literal('customer.subscription.deleted'), + data: v.object({ object: vSubscriptionObject }), + }), + v.object({ + type: v.literal('checkout.session.completed'), + data: v.object({ object: vCheckoutSessionObject }), + }), + v.object({ + type: v.literal('invoice.created'), + data: v.object({ object: vInvoiceObject }), + }), + v.object({ + type: v.literal('invoice.finalized'), + data: v.object({ object: vInvoiceObject }), + }), + v.object({ + type: v.literal('invoice.paid'), + data: v.object({ object: vInvoiceObject }), + }), + v.object({ + type: v.literal('invoice.payment_succeeded'), + data: v.object({ object: vInvoiceObject }), + }), + v.object({ + type: v.literal('invoice.payment_failed'), + data: v.object({ object: vInvoiceObject }), + }), + v.object({ + type: v.literal('payment_intent.succeeded'), + data: v.object({ object: vPaymentIntentObject }), + }), +]); + +export type ParsedStripeEvent = v.InferOutput; + +export const vStripeIdResponse = v.object({ id: v.string() }); + +export const vStripeCheckoutSessionResponse = v.object({ + id: v.string(), + url: v.optional(v.union([v.string(), v.null()])), +}); + +export const vStripeBillingPortalSessionResponse = v.object({ + url: v.string(), +}); + +// Stripe error response: `{ error: { type, code, message, request_log_url } }`. +export const vStripeErrorBody = v.object({ + error: v.object({ + type: v.optional(v.string()), + code: v.optional(v.string()), + message: v.optional(v.string()), + request_log_url: v.optional(v.union([v.string(), v.null()])), + }), +}); diff --git a/spacetime-stripe-ts/src/submodule/validation.ts b/spacetime-stripe-ts/src/submodule/validation.ts new file mode 100644 index 00000000000..181f0f5e9ad --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/validation.ts @@ -0,0 +1,32 @@ +import * as v from 'valibot'; +import { SenderError } from 'spacetimedb/server'; + +export function assertExhaustive(value: never): never { + throw new Error(`Unhandled discriminant: ${value as string}`); +} + +export function throwSenderError(message: string): never { + throw new SenderError(message); +} + +export function safeJsonParse(input: string): unknown { + try { + return JSON.parse(input); + } catch { + return undefined; + } +} + +export function summarizeIssues(issues: v.BaseIssue[]): string { + if (issues.length === 0) return 'no issues'; + const head = issues[0]!; + const path = (head.path ?? []) + .map(p => + typeof p.key === 'string' || typeof p.key === 'number' + ? String(p.key) + : '?' + ) + .join('.'); + const where = path ? ` at ${path}` : ''; + return `${head.message}${where}`; +} diff --git a/spacetime-stripe-ts/src/submodule/webhook-metadata.ts b/spacetime-stripe-ts/src/submodule/webhook-metadata.ts new file mode 100644 index 00000000000..e4bb0db42e9 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/webhook-metadata.ts @@ -0,0 +1,25 @@ +export type StripeEventMetadata = { + eventId: string; + eventType: string; + livemode: boolean; +}; + +export function parseStripeEventMetadata( + payloadJson: string +): StripeEventMetadata | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(payloadJson); + } catch { + return undefined; + } + if (typeof parsed !== 'object' || parsed === null) return undefined; + const record = parsed as Record; + if (typeof record.id !== 'string' || typeof record.type !== 'string') + return undefined; + return { + eventId: record.id, + eventType: record.type, + livemode: typeof record.livemode === 'boolean' ? record.livemode : false, + }; +} diff --git a/spacetime-stripe-ts/src/submodule/webhook-request.ts b/spacetime-stripe-ts/src/submodule/webhook-request.ts new file mode 100644 index 00000000000..a55df3f1df8 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/webhook-request.ts @@ -0,0 +1,35 @@ +import { MAX_WEBHOOK_BODY_LENGTH, MAX_WEBHOOK_HEADER_LENGTH } from './limits'; + +export type WebhookRequestRejection = { + status: number; + error: string; +}; + +export function validateWebhookRequestHeaders( + method: string, + contentLengthHeader: string | null, + signatureHeader: string | undefined +): WebhookRequestRejection | undefined { + if (method !== 'POST') return { status: 405, error: 'method not allowed' }; + const contentLength = Number(contentLengthHeader ?? '0'); + if ( + Number.isFinite(contentLength) && + contentLength > MAX_WEBHOOK_BODY_LENGTH + ) { + return { status: 413, error: 'payload too large' }; + } + if ((signatureHeader?.length ?? 0) > MAX_WEBHOOK_HEADER_LENGTH) { + return { status: 431, error: 'signature header too large' }; + } + return undefined; +} + +export function validateWebhookRequestBody( + payloadJson: string +): WebhookRequestRejection | undefined { + if (payloadJson.length === 0) return { status: 400, error: 'empty body' }; + if (new TextEncoder().encode(payloadJson).length > MAX_WEBHOOK_BODY_LENGTH) { + return { status: 413, error: 'payload too large' }; + } + return undefined; +} diff --git a/spacetime-stripe-ts/tsconfig.json b/spacetime-stripe-ts/tsconfig.json new file mode 100644 index 00000000000..c659d97428a --- /dev/null +++ b/spacetime-stripe-ts/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "emitDeclarationOnly": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "isolatedDeclarations": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": false, + "useDefineForClassFields": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"], + "exclude": ["node_modules", "dist/**/*"] +}