├── .firebaserc ├── .gitignore ├── LICENSE ├── README.md ├── firebase.json ├── firestore.indexes.json ├── firestore.rules ├── functions ├── .env.dev ├── .env.local ├── .env.prod ├── .gitignore ├── index.js ├── package-lock.json ├── package.json └── src │ ├── actions │ ├── order.json │ ├── send-mail.js │ ├── test-send.js │ └── v1_firestore_triggers.js │ ├── calculate-pricing │ ├── index.js │ └── test │ │ ├── index.js │ │ ├── line_items_1.json │ │ ├── test-bulk-ds-games-3-for-99.json │ │ ├── test-order-10-percents-off.json │ │ ├── test-order-fixed-off-order.json │ │ ├── test-regular-all-games.json │ │ ├── test-regular-ps4-games.json │ │ ├── test-regular-switch-games.json │ │ └── test-regular-wii-games.json │ ├── checkout │ ├── complete-checkout │ │ └── index.js │ ├── create-checkout │ │ └── index.js │ ├── index.js │ ├── reserve-products │ │ └── index.js │ ├── utils.js │ └── validate-checkout │ │ └── index.js │ ├── gateways │ ├── index.js │ ├── paypal-standard-checkout.js │ └── paypal-standard-payments │ │ └── index.js │ ├── index.js │ ├── js-docs-types.js │ └── utils.js ├── public └── logo.png └── storage.rules /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "YOUR_PROJECT_ID_HERE" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | firebase-debug.log* 8 | firebase-debug.*.log* 9 | *.DS_Store 10 | # Firebase cache 11 | .firebase/ 12 | 13 | # Firebase config 14 | 15 | # Uncomment this if you'd like others to create their own Firebase project. 16 | # For a team working on the same Firebase project(s), it is recommended to leave 17 | # it commented so all members can deploy to the same project(s) in .firebaserc. 18 | # .firebaserc 19 | 20 | # Runtime data 21 | pids 22 | *.pid 23 | *.seed 24 | *.pid.lock 25 | 26 | # Directory for instrumented libs generated by jscoverage/JSCover 27 | lib-cov 28 | 29 | # Coverage directory used by tools like istanbul 30 | coverage 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (http://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variables file 66 | .env 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shelf Slim Backend 2 | 3 |
4 | 5 |
6 | 7 | > 🥳 [SHELF](https://shelf-cms.io) turns your **Firebase** project into a **HEADLESS** CMS 8 | 9 | # What is this ? 10 | This repository is a complementary simple backend for achieving extra capabilities with `SHELF` under **170kb**: 11 | - Creating Checkouts from Web or Mobile 12 | - Capture / Void / Refund Payments 💳 (Currently PayPal, but more are coming and you can add your own) 13 | - Marketing emails (User signup, Payments) 14 | 15 | ## I want it, how to use it ? 16 | Clone this repository and follow instructions 17 | 18 | # Instructions 19 | ### 1. Create Firebase project 20 | Simply, follow our guide [HERE 📖](https://shelf-cms.io/docs/setup/project) 21 | 22 | > Write `projectId` and your Auth `uid` (when you created yourself as user) 23 | 24 | ### 2. Clone this repository 25 | ```bash 26 | git clone https://github.com/shelf-cms/shelf-slim-backend.git 27 | ``` 28 | 29 | ### 3. Install `firebase CLI` globally 30 | ```bash 31 | npm install -g firebase-tools 32 | ``` 33 | 34 | ### 4. Kick some things 35 | cd into the repo directory 36 | ```bash 37 | firebase login 38 | ``` 39 | 40 | ### 5. Edit `.firebaserc` 41 | Open `.firebaserc` and paste your `projectId` into `YOUR_PROJECT_ID_HERE` 42 | 43 | ### 6. Edit `firestore.rules` 44 | Open `firestore.rules` and paste your `uid` into `YOUR_UID_HERE` 45 | 46 | ### 7. Edit `storage.rules` (Optional, if you prefer other storage services) 47 | Open `storage.rules` and paste your `uid` into `YOUR_UID_HERE` 48 | 49 | ### 8. Deploy Firestore rules and indexes and storage rules 50 | ```bash 51 | firebase deploy --only firestore,storage 52 | ``` 53 | 54 | ### 9. Tinker with `functions` before deploy 55 | ```bash 56 | firebase emulators:start --only functions 57 | ``` 58 | 59 | If, you are ready to deploy the functions 60 | ```bash 61 | firebase deploy --only functions 62 | ``` 63 | 64 | ### 10. Update your backend @ `shelf` 65 | - Login to [SHELF](https://shelf-cms.io) 66 | - **Settings** > Update `Backend URL` with your local or production url. 67 | >💡 For local dev, make sure your browser does not block requests to localhost (Brave browser does it and it can be disabled) 68 | 69 | ### 11. Tinker with the backend 70 | 💡 Few suggestions: 71 | - Edit `functions/src/actions/send-mail.js` with your own: 72 | - `STORE_NAME` 73 | - `STORE_WEBSITE` 74 | - `YOUR_MAIL` 75 | - `SEND_GRID_SECRET` (Be sure to open an account with sendgrid) 76 | - Modify the logic of events at `functions/index.js` 77 | - Add a new payment gateway by inspecting `functions/gateways` folder and reading [The SHELF Gateways Docs 📖](https://www.shelf-cms.io/docs/backend/payments) 78 | 79 | 80 | # 👋 Contribution Guide 81 | Any contribution is welcome. 82 | Here are some, that will be cool to add: 83 | - More Payment Gateways 84 | - Automatic bootstrap script 85 | - Better docs 86 | - Anything you feel is an improvement 87 | 88 | Also, feel free to fork and make it your own, for your own projects 89 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": [ 3 | { 4 | "source": "functions", 5 | "codebase": "default", 6 | "ignore": [ 7 | "node_modules", 8 | ".git", 9 | "firebase-debug.log", 10 | "firebase-debug.*.log" 11 | ], 12 | "predeploy": [] 13 | } 14 | ], 15 | "emulators": { 16 | "functions": { 17 | "port": 5002 18 | }, 19 | "ui": { 20 | "enabled": true 21 | }, 22 | "singleProjectMode": true 23 | }, 24 | "firestore": { 25 | "rules": "firestore.rules", 26 | "indexes": "firestore.indexes.json" 27 | }, 28 | "storage": { 29 | "rules": "storage.rules" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [ 3 | { 4 | "collectionGroup": "collections", 5 | "queryScope": "COLLECTION", 6 | "fields": [ 7 | { 8 | "fieldPath": "search", 9 | "arrayConfig": "CONTAINS" 10 | }, 11 | { 12 | "fieldPath": "updatedAt", 13 | "order": "ASCENDING" 14 | } 15 | ] 16 | }, 17 | { 18 | "collectionGroup": "collections", 19 | "queryScope": "COLLECTION", 20 | "fields": [ 21 | { 22 | "fieldPath": "search", 23 | "arrayConfig": "CONTAINS" 24 | }, 25 | { 26 | "fieldPath": "updatedAt", 27 | "order": "DESCENDING" 28 | } 29 | ] 30 | }, 31 | { 32 | "collectionGroup": "discounts", 33 | "queryScope": "COLLECTION", 34 | "fields": [ 35 | { 36 | "fieldPath": "search", 37 | "arrayConfig": "CONTAINS" 38 | }, 39 | { 40 | "fieldPath": "updatedAt", 41 | "order": "DESCENDING" 42 | } 43 | ] 44 | }, 45 | { 46 | "collectionGroup": "images", 47 | "queryScope": "COLLECTION", 48 | "fields": [ 49 | { 50 | "fieldPath": "search", 51 | "arrayConfig": "CONTAINS" 52 | }, 53 | { 54 | "fieldPath": "updatedAt", 55 | "order": "ASCENDING" 56 | } 57 | ] 58 | }, 59 | { 60 | "collectionGroup": "images", 61 | "queryScope": "COLLECTION", 62 | "fields": [ 63 | { 64 | "fieldPath": "search", 65 | "arrayConfig": "CONTAINS" 66 | }, 67 | { 68 | "fieldPath": "updatedAt", 69 | "order": "DESCENDING" 70 | } 71 | ] 72 | }, 73 | { 74 | "collectionGroup": "notifications", 75 | "queryScope": "COLLECTION", 76 | "fields": [ 77 | { 78 | "fieldPath": "search", 79 | "arrayConfig": "CONTAINS" 80 | }, 81 | { 82 | "fieldPath": "updatedAt", 83 | "order": "ASCENDING" 84 | } 85 | ] 86 | }, 87 | { 88 | "collectionGroup": "notifications", 89 | "queryScope": "COLLECTION", 90 | "fields": [ 91 | { 92 | "fieldPath": "search", 93 | "arrayConfig": "CONTAINS" 94 | }, 95 | { 96 | "fieldPath": "updatedAt", 97 | "order": "DESCENDING" 98 | } 99 | ] 100 | }, 101 | { 102 | "collectionGroup": "orders", 103 | "queryScope": "COLLECTION", 104 | "fields": [ 105 | { 106 | "fieldPath": "search", 107 | "arrayConfig": "CONTAINS" 108 | }, 109 | { 110 | "fieldPath": "updatedAt", 111 | "order": "ASCENDING" 112 | } 113 | ] 114 | }, 115 | { 116 | "collectionGroup": "orders", 117 | "queryScope": "COLLECTION", 118 | "fields": [ 119 | { 120 | "fieldPath": "search", 121 | "arrayConfig": "CONTAINS" 122 | }, 123 | { 124 | "fieldPath": "updatedAt", 125 | "order": "DESCENDING" 126 | } 127 | ] 128 | }, 129 | { 130 | "collectionGroup": "payment_gateways", 131 | "queryScope": "COLLECTION", 132 | "fields": [ 133 | { 134 | "fieldPath": "search", 135 | "arrayConfig": "CONTAINS" 136 | }, 137 | { 138 | "fieldPath": "updatedAt", 139 | "order": "ASCENDING" 140 | } 141 | ] 142 | }, 143 | { 144 | "collectionGroup": "payment_gateways", 145 | "queryScope": "COLLECTION", 146 | "fields": [ 147 | { 148 | "fieldPath": "search", 149 | "arrayConfig": "CONTAINS" 150 | }, 151 | { 152 | "fieldPath": "updatedAt", 153 | "order": "DESCENDING" 154 | } 155 | ] 156 | }, 157 | { 158 | "collectionGroup": "posts", 159 | "queryScope": "COLLECTION", 160 | "fields": [ 161 | { 162 | "fieldPath": "search", 163 | "arrayConfig": "CONTAINS" 164 | }, 165 | { 166 | "fieldPath": "updatedAt", 167 | "order": "ASCENDING" 168 | } 169 | ] 170 | }, 171 | { 172 | "collectionGroup": "posts", 173 | "queryScope": "COLLECTION", 174 | "fields": [ 175 | { 176 | "fieldPath": "search", 177 | "arrayConfig": "CONTAINS" 178 | }, 179 | { 180 | "fieldPath": "updatedAt", 181 | "order": "DESCENDING" 182 | } 183 | ] 184 | }, 185 | { 186 | "collectionGroup": "products", 187 | "queryScope": "COLLECTION", 188 | "fields": [ 189 | { 190 | "fieldPath": "search", 191 | "arrayConfig": "CONTAINS" 192 | }, 193 | { 194 | "fieldPath": "updatedAt", 195 | "order": "ASCENDING" 196 | } 197 | ] 198 | }, 199 | { 200 | "collectionGroup": "products", 201 | "queryScope": "COLLECTION", 202 | "fields": [ 203 | { 204 | "fieldPath": "search", 205 | "arrayConfig": "CONTAINS" 206 | }, 207 | { 208 | "fieldPath": "updatedAt", 209 | "order": "DESCENDING" 210 | } 211 | ] 212 | }, 213 | { 214 | "collectionGroup": "shipping_methods", 215 | "queryScope": "COLLECTION", 216 | "fields": [ 217 | { 218 | "fieldPath": "search", 219 | "arrayConfig": "CONTAINS" 220 | }, 221 | { 222 | "fieldPath": "updatedAt", 223 | "order": "ASCENDING" 224 | } 225 | ] 226 | }, 227 | { 228 | "collectionGroup": "shipping_methods", 229 | "queryScope": "COLLECTION", 230 | "fields": [ 231 | { 232 | "fieldPath": "search", 233 | "arrayConfig": "CONTAINS" 234 | }, 235 | { 236 | "fieldPath": "updatedAt", 237 | "order": "DESCENDING" 238 | } 239 | ] 240 | }, 241 | { 242 | "collectionGroup": "storefronts", 243 | "queryScope": "COLLECTION", 244 | "fields": [ 245 | { 246 | "fieldPath": "search", 247 | "arrayConfig": "CONTAINS" 248 | }, 249 | { 250 | "fieldPath": "updatedAt", 251 | "order": "DESCENDING" 252 | } 253 | ] 254 | }, 255 | { 256 | "collectionGroup": "tags", 257 | "queryScope": "COLLECTION", 258 | "fields": [ 259 | { 260 | "fieldPath": "search", 261 | "arrayConfig": "CONTAINS" 262 | }, 263 | { 264 | "fieldPath": "updatedAt", 265 | "order": "ASCENDING" 266 | } 267 | ] 268 | }, 269 | { 270 | "collectionGroup": "tags", 271 | "queryScope": "COLLECTION", 272 | "fields": [ 273 | { 274 | "fieldPath": "search", 275 | "arrayConfig": "CONTAINS" 276 | }, 277 | { 278 | "fieldPath": "updatedAt", 279 | "order": "DESCENDING" 280 | } 281 | ] 282 | }, 283 | { 284 | "collectionGroup": "test", 285 | "queryScope": "COLLECTION", 286 | "fields": [ 287 | { 288 | "fieldPath": "search", 289 | "arrayConfig": "CONTAINS" 290 | }, 291 | { 292 | "fieldPath": "updatedAt", 293 | "order": "ASCENDING" 294 | } 295 | ] 296 | }, 297 | { 298 | "collectionGroup": "users", 299 | "queryScope": "COLLECTION", 300 | "fields": [ 301 | { 302 | "fieldPath": "search", 303 | "arrayConfig": "CONTAINS" 304 | }, 305 | { 306 | "fieldPath": "updatedAt", 307 | "order": "ASCENDING" 308 | } 309 | ] 310 | }, 311 | { 312 | "collectionGroup": "users", 313 | "queryScope": "COLLECTION", 314 | "fields": [ 315 | { 316 | "fieldPath": "search", 317 | "arrayConfig": "CONTAINS" 318 | }, 319 | { 320 | "fieldPath": "updatedAt", 321 | "order": "DESCENDING" 322 | } 323 | ] 324 | } 325 | ], 326 | "fieldOverrides": [] 327 | } 328 | -------------------------------------------------------------------------------- /firestore.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service cloud.firestore { 3 | match /databases/{database}/documents { 4 | function loggedIn() { 5 | return request.auth!=null 6 | } 7 | function isAdmin() { 8 | return loggedIn() && (request.auth.uid in ['YOUR_UID_HERE']); 9 | } 10 | 11 | 12 | match /{document=**} { 13 | allow read, write: if isAdmin(); 14 | } 15 | 16 | match /{col}/{doc} { 17 | allow read: if col in ['discounts', 'products', 'collections', 'posts', 'shipping_methods', 'store_fronts', 'tags']; 18 | } 19 | 20 | match /users/{userId} { 21 | allow read, write: if loggedIn() && request.auth.uid == userId; 22 | allow create: if loggedIn(); 23 | } 24 | 25 | match /orders/{orderId} { 26 | // always allow if you know the document id 27 | allow get: if true; 28 | // list only your 29 | // allow list: if loggedIn() && (resource.data.contact.uid==request.auth.uid); 30 | allow list: if loggedIn() && (('uid:' + request.auth.uid) in resource.data.search); 31 | } 32 | 33 | 34 | } 35 | } -------------------------------------------------------------------------------- /functions/.env.dev: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shelf-cms/shelf-slim-backend/2b7e7e98b7317554435b4579543ab28ef9709e8c/functions/.env.dev -------------------------------------------------------------------------------- /functions/.env.local: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shelf-cms/shelf-slim-backend/2b7e7e98b7317554435b4579543ab28ef9709e8c/functions/.env.local -------------------------------------------------------------------------------- /functions/.env.prod: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shelf-cms/shelf-slim-backend/2b7e7e98b7317554435b4579543ab28ef9709e8c/functions/.env.prod -------------------------------------------------------------------------------- /functions/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.log 3 | *.DS_Store -------------------------------------------------------------------------------- /functions/index.js: -------------------------------------------------------------------------------- 1 | import { onRequest } from 'firebase-functions/v2/https' 2 | // import * as functionsv1 from 'firebase-functions' 3 | import { 4 | onDocumentWritten, 5 | onDocumentCreated, 6 | onDocumentUpdated, 7 | onDocumentDeleted, 8 | Change, 9 | } from 'firebase-functions/v2/firestore'; 10 | 11 | import admin from 'firebase-admin' 12 | import { Firestore } from 'firebase-admin/firestore' 13 | import { create_backend } from './src/index.js' 14 | import { 15 | CheckoutStatusEnum, FulfillOptionsEnum, 16 | NotificationAction, 17 | NotificationData, 18 | OrderData, UserData } from './src/js-docs-types.js' 19 | import { 20 | send_cancel_mail, send_checkout_mail, 21 | send_shipping_mail, send_welcome_mail } from './src/actions/send-mail.js' 22 | 23 | const firebase = admin.initializeApp() 24 | const db = firebase.firestore() 25 | db.settings( 26 | { 27 | preferRest: true, 28 | ignoreUndefinedProperties: true 29 | } 30 | ) 31 | 32 | /**@type {import('./src/index.js').ShelfContext} */ 33 | const shelf = { 34 | db 35 | } 36 | 37 | export const app = onRequest( 38 | create_backend(shelf) 39 | ) 40 | 41 | /** 42 | * 43 | * @param {string} message 44 | * @param {*} search 45 | * @param {*} author 46 | * @param {NotificationAction} action 47 | */ 48 | const notify = (message, search=[], author='unknown', action) => { 49 | /**@type {NotificationData} */ 50 | const noti = { 51 | message, 52 | search, 53 | author: 'shelf-activity-bot 🤖', 54 | updatedAt: Date.now() 55 | } 56 | } 57 | 58 | // send checkout mails 59 | export const onOrderModified = onDocumentWritten( 60 | { 61 | document: 'orders/{orderId}', 62 | region: 'us-central1', 63 | }, 64 | async event => { 65 | /**@type {OrderData} */ 66 | const order_after = event.data.after.data() 67 | /**@type {OrderData} */ 68 | const order_before = event.data.before.data() 69 | 70 | /**@type {NotificationData} */ 71 | const noti = { 72 | search: ['checkout', 'orders'], 73 | author: 'shelf-backend-bot 🤖', 74 | updatedAt: Date.now(), 75 | actions: [ 76 | { 77 | name: '', 78 | type: 'route', 79 | params: { 80 | collection: 'orders', 81 | document: order_after?.id, 82 | } 83 | } 84 | ] 85 | } 86 | console.log('orders/{orderId}') 87 | 88 | const has_checkout_complete = ( 89 | order_after.status.checkout.id===CheckoutStatusEnum.complete.id && 90 | order_before?.status?.checkout?.id!==order_after.status.checkout.id 91 | ) 92 | 93 | if(has_checkout_complete) { 94 | try { 95 | await send_checkout_mail(db, order_after) 96 | } catch(e) { 97 | console.log(e) 98 | } 99 | 100 | // send mail 101 | // `\n* 🚀 **${count}** \`${c}\` were updated` 102 | const o = order_after 103 | const message = ` 104 | 💰 **Checkout update**\n 105 | * \`${o?.address?.firstname ?? ''}\` has completed checkout. 106 | * 💳 Order total is \`${o?.pricing?.total ?? '-'}\`. 107 | * 📧 Email was sent to ${o?.contact?.email ?? 'no-email'} 108 | ` 109 | await db.collection('notifications').add( 110 | { 111 | ...noti, 112 | message 113 | } 114 | ); 115 | } 116 | 117 | const is_order_cancelled = ( 118 | order_after.status.fulfillment.id===FulfillOptionsEnum.cancelled.id && 119 | order_before?.status?.fulfillment?.id!==order_after.status.fulfillment.id 120 | ) 121 | 122 | if(is_order_cancelled) { 123 | await send_cancel_mail(db, order_after) 124 | } 125 | 126 | const is_order_shipped = ( 127 | order_after.status.fulfillment.id===FulfillOptionsEnum.shipped.id && 128 | order_before?.status?.fulfillment?.id!==order_after.status.fulfillment.id 129 | ) 130 | 131 | if(is_order_shipped) { 132 | try { 133 | await send_shipping_mail(db, order_after); 134 | } catch (e) { 135 | console.log(e) 136 | } 137 | //📦 138 | const o = order_after 139 | const message = ` 140 | 💰 **Order update**\n 141 | * 📦 Shipping email update was sent to \`${o?.address?.firstname ?? ''}\`. 142 | * For order **${o?.pricing?.total ?? '-'}** 💳 143 | ` 144 | await db.collection('notifications').add( 145 | { 146 | ...noti, 147 | message 148 | } 149 | ); 150 | 151 | } 152 | 153 | } 154 | ) 155 | 156 | // send welcome mail 157 | 158 | export const onUserCreated = onDocumentCreated( 159 | 'users/{userId}', 160 | async event => { 161 | /**@type {UserData} */ 162 | const user = event.data.data() 163 | 164 | await send_welcome_mail(db, user) 165 | } 166 | ) 167 | 168 | -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "description": "Cloud Functions for Firebase", 4 | "type": "module", 5 | "scripts": { 6 | "lint": "echo 'hello lint'", 7 | "serve": "firebase emulators:start --only functions", 8 | "shell": "firebase functions:shell", 9 | "start": "npm run shell", 10 | "deploy": "firebase deploy --only functions", 11 | "logs": "firebase functions:log" 12 | }, 13 | "engines": { 14 | "node": "18" 15 | }, 16 | "main": "index.js", 17 | "dependencies": { 18 | "express": "^4.18.2", 19 | "firebase-admin": "^11.9.0", 20 | "firebase-functions": "^4.4.0", 21 | "jose": "^4.14.4", 22 | "mailgen": "^2.0.27", 23 | "nodemailer": "^6.9.2" 24 | }, 25 | "devDependencies": { 26 | "firebase-functions-test": "^3.0.0" 27 | }, 28 | "private": true 29 | } 30 | -------------------------------------------------------------------------------- /functions/src/actions/order.json: -------------------------------------------------------------------------------- 1 | { 2 | "createdAt": 1684337528531, 3 | "id": "b41239e6-b5e3-47a5-a992-ea2253c692fd", 4 | "validation": [], 5 | "line_items": [ 6 | { 7 | "stock_reserved": 0, 8 | "price": 85, 9 | "id": "spiderman-ps4-goty", 10 | "qty": 1, 11 | "data": { 12 | "search": [ 13 | "spiderman-ps4-goty", 14 | "spiderman goty", 15 | "spiderman", 16 | "goty", 17 | "NTSC", 18 | "like-new", 19 | "complete", 20 | "game", 21 | "action", 22 | "ps4", 23 | "used", 24 | "sony", 25 | "filter:region_NTSC", 26 | "filter:condition_like-new", 27 | "filter:condition_complete", 28 | "filter:type_game", 29 | "filter:genre_action", 30 | "filter:console_ps4", 31 | "filter:condition_used", 32 | "company_sony", 33 | "tag:filter:region_NTSC", 34 | "tag:filter:condition_like-new", 35 | "tag:filter:condition_complete", 36 | "tag:filter:type_game", 37 | "tag:filter:genre_action", 38 | "tag:filter:console_ps4", 39 | "tag:filter:condition_used", 40 | "tag:company_sony", 41 | "ps4-games", 42 | "col:ps4-games", 43 | "price:85" 44 | ], 45 | "title": "Spiderman GOTY", 46 | "handle": "spiderman-ps4-goty", 47 | "price": 85, 48 | "related_products": [], 49 | "updatedAt": 1681936495618, 50 | "media": [ 51 | "https://m.media-amazon.com/images/I/815aKWcEkEL._AC_UF1000,1000_QL80_.jpg" 52 | ], 53 | "qty": 1, 54 | "createdAt": 1677243099192, 55 | "video": "https://www.youtube.com/watch?v=eEPG4TMhwtk", 56 | "collections": [ 57 | "ps4-games" 58 | ], 59 | "tags": [ 60 | "filter:region_NTSC", 61 | "filter:condition_like-new", 62 | "filter:condition_complete", 63 | "filter:type_game", 64 | "filter:genre_action", 65 | "filter:console_ps4", 66 | "filter:condition_used", 67 | "company_sony" 68 | ] 69 | } 70 | } 71 | ], 72 | "delivery": { 73 | "id": "fc1221b8-d12d-476d-9d6e-dba712dc057d", 74 | "name": "israel post regular", 75 | "search": [ 76 | "fc1221b8-d12d-476d-9d6e-dba712dc057d", 77 | "fc1221b8", 78 | "israel", 79 | "post", 80 | "regular", 81 | "israel post regular", 82 | "30" 83 | ], 84 | "updatedAt": 1681060497395, 85 | "media": [], 86 | "price": 30, 87 | "attributes": [ 88 | { 89 | "key": "heb_title", 90 | "val": "דואר ישראל - רגיל", 91 | "createdAt": 1680426621729 92 | } 93 | ] 94 | }, 95 | "pricing": { 96 | "subtotal": 85, 97 | "quantity_discounted": 0, 98 | "subtotal_discount": 0, 99 | "total_undiscounted": 115, 100 | "subtotal_undiscounted": 85, 101 | "total": 115, 102 | "evo": [ 103 | { 104 | "quantity_discounted": 0, 105 | "quantity_undiscounted": 1, 106 | "subtotal": 85, 107 | "line_items": [ 108 | { 109 | "price": 85, 110 | "id": "spiderman-ps4-goty", 111 | "stock_reserved": 0, 112 | "qty": 1, 113 | "data": { 114 | "price": 85, 115 | "collections": [ 116 | "ps4-games" 117 | ], 118 | "video": "https://www.youtube.com/watch?v=eEPG4TMhwtk", 119 | "title": "Spiderman GOTY", 120 | "related_products": [], 121 | "qty": 1, 122 | "handle": "spiderman-ps4-goty", 123 | "search": [ 124 | "spiderman-ps4-goty", 125 | "spiderman goty", 126 | "spiderman", 127 | "goty", 128 | "NTSC", 129 | "like-new", 130 | "complete", 131 | "game", 132 | "action", 133 | "ps4", 134 | "used", 135 | "sony", 136 | "filter:region_NTSC", 137 | "filter:condition_like-new", 138 | "filter:condition_complete", 139 | "filter:type_game", 140 | "filter:genre_action", 141 | "filter:console_ps4", 142 | "filter:condition_used", 143 | "company_sony", 144 | "tag:filter:region_NTSC", 145 | "tag:filter:condition_like-new", 146 | "tag:filter:condition_complete", 147 | "tag:filter:type_game", 148 | "tag:filter:genre_action", 149 | "tag:filter:console_ps4", 150 | "tag:filter:condition_used", 151 | "tag:company_sony", 152 | "ps4-games", 153 | "col:ps4-games", 154 | "price:85" 155 | ], 156 | "media": [ 157 | "https://m.media-amazon.com/images/I/815aKWcEkEL._AC_UF1000,1000_QL80_.jpg" 158 | ], 159 | "updatedAt": 1681936495618, 160 | "createdAt": 1677243099192, 161 | "tags": [ 162 | "filter:region_NTSC", 163 | "filter:condition_like-new", 164 | "filter:condition_complete", 165 | "filter:type_game", 166 | "filter:genre_action", 167 | "filter:console_ps4", 168 | "filter:condition_used", 169 | "company_sony" 170 | ] 171 | } 172 | } 173 | ], 174 | "total": 115 175 | }, 176 | { 177 | "line_items": [ 178 | { 179 | "id": "spiderman-ps4-goty", 180 | "data": { 181 | "video": "https://www.youtube.com/watch?v=eEPG4TMhwtk", 182 | "updatedAt": 1681936495618, 183 | "createdAt": 1677243099192, 184 | "qty": 1, 185 | "price": 85, 186 | "title": "Spiderman GOTY", 187 | "search": [ 188 | "spiderman-ps4-goty", 189 | "spiderman goty", 190 | "spiderman", 191 | "goty", 192 | "NTSC", 193 | "like-new", 194 | "complete", 195 | "game", 196 | "action", 197 | "ps4", 198 | "used", 199 | "sony", 200 | "filter:region_NTSC", 201 | "filter:condition_like-new", 202 | "filter:condition_complete", 203 | "filter:type_game", 204 | "filter:genre_action", 205 | "filter:console_ps4", 206 | "filter:condition_used", 207 | "company_sony", 208 | "tag:filter:region_NTSC", 209 | "tag:filter:condition_like-new", 210 | "tag:filter:condition_complete", 211 | "tag:filter:type_game", 212 | "tag:filter:genre_action", 213 | "tag:filter:console_ps4", 214 | "tag:filter:condition_used", 215 | "tag:company_sony", 216 | "ps4-games", 217 | "col:ps4-games", 218 | "price:85" 219 | ], 220 | "media": [ 221 | "https://m.media-amazon.com/images/I/815aKWcEkEL._AC_UF1000,1000_QL80_.jpg" 222 | ], 223 | "handle": "spiderman-ps4-goty", 224 | "collections": [ 225 | "ps4-games" 226 | ], 227 | "tags": [ 228 | "filter:region_NTSC", 229 | "filter:condition_like-new", 230 | "filter:condition_complete", 231 | "filter:type_game", 232 | "filter:genre_action", 233 | "filter:console_ps4", 234 | "filter:condition_used", 235 | "company_sony" 236 | ], 237 | "related_products": [] 238 | }, 239 | "qty": 1, 240 | "price": 85, 241 | "stock_reserved": 0 242 | } 243 | ], 244 | "quantity_undiscounted": 1, 245 | "discount_code": "bulk-ds-selected-3-for-100", 246 | "total_discount": 0, 247 | "total": 115, 248 | "subtotal": 85, 249 | "quantity_discounted": 0, 250 | "discount": { 251 | "search": [ 252 | "bulk-ds-selected-3-for-100", 253 | "מגוון", 254 | "משחקי", 255 | "ds", 256 | "-", 257 | "3", 258 | "ב", 259 | "100", 260 | "automatic", 261 | "app:automatic", 262 | "app:0", 263 | "bulk", 264 | "1", 265 | "type:1", 266 | "type:bulk", 267 | "enabled:true" 268 | ], 269 | "attributes": [ 270 | { 271 | "createdAt": 1680514360492, 272 | "key": "he_title", 273 | "val": "מגוון משחקי DS - שלוש ב 100" 274 | } 275 | ], 276 | "order": 10, 277 | "info": { 278 | "filters": [ 279 | { 280 | "meta": { 281 | "op": "p_in_price_range", 282 | "id": 7, 283 | "type": "product" 284 | }, 285 | "value": { 286 | "to": 45, 287 | "from": 34 288 | } 289 | }, 290 | { 291 | "value": [ 292 | "nintendo-ds-games" 293 | ], 294 | "meta": { 295 | "op": "p-in-collections", 296 | "id": 0, 297 | "type": "product" 298 | } 299 | } 300 | ], 301 | "details": { 302 | "meta": { 303 | "id": 1, 304 | "type": "bulk", 305 | "name": "Bulk Discount" 306 | }, 307 | "extra": { 308 | "qty": 3, 309 | "percent": 100, 310 | "recursive": true, 311 | "fixed": 100 312 | } 313 | } 314 | }, 315 | "code": "bulk-ds-selected-3-for-100", 316 | "application": { 317 | "id": 0, 318 | "name": "Automatic" 319 | }, 320 | "_published": "discount-bulk-ds-selected-3-for-100", 321 | "createdAt": 1680514193946, 322 | "updatedAt": 1683652981844, 323 | "media": [], 324 | "title": "מגוון משחקי DS - 3 ב 100", 325 | "enabled": true 326 | } 327 | }, 328 | { 329 | "total_discount": 0, 330 | "discount_code": "order-above-299", 331 | "total": 115, 332 | "discount": { 333 | "media": [], 334 | "order": 100, 335 | "desc": "הנחה של 25 שקל על כל הזמנה מעל 299 ש\"ח", 336 | "updatedAt": 1681064085111, 337 | "code": "order-above-299", 338 | "title": "order above 299 discounted", 339 | "enabled": true, 340 | "createdAt": 1680457002571, 341 | "application": { 342 | "name": "Automatic", 343 | "id": 0 344 | }, 345 | "info": { 346 | "details": { 347 | "meta": { 348 | "id": 3, 349 | "type": "order", 350 | "name": "Order Discount" 351 | }, 352 | "extra": { 353 | "free_shipping": false, 354 | "percent": 0, 355 | "fixed": -25 356 | } 357 | }, 358 | "filters": [ 359 | { 360 | "value": { 361 | "from": 299, 362 | "to": null 363 | }, 364 | "meta": { 365 | "id": 100, 366 | "op": "o-subtotal-in-range", 367 | "type": "order" 368 | } 369 | } 370 | ] 371 | }, 372 | "search": [ 373 | "order-above-299", 374 | "order", 375 | "above", 376 | "299", 377 | "discounted", 378 | "automatic", 379 | "app:automatic", 380 | "app:0", 381 | "order", 382 | "3", 383 | "type:3", 384 | "type:order", 385 | "enabled:true" 386 | ] 387 | }, 388 | "subtotal": 85, 389 | "line_items": [ 390 | { 391 | "price": 85, 392 | "stock_reserved": 0, 393 | "qty": 1, 394 | "id": "spiderman-ps4-goty", 395 | "data": { 396 | "collections": [ 397 | "ps4-games" 398 | ], 399 | "search": [ 400 | "spiderman-ps4-goty", 401 | "spiderman goty", 402 | "spiderman", 403 | "goty", 404 | "NTSC", 405 | "like-new", 406 | "complete", 407 | "game", 408 | "action", 409 | "ps4", 410 | "used", 411 | "sony", 412 | "filter:region_NTSC", 413 | "filter:condition_like-new", 414 | "filter:condition_complete", 415 | "filter:type_game", 416 | "filter:genre_action", 417 | "filter:console_ps4", 418 | "filter:condition_used", 419 | "company_sony", 420 | "tag:filter:region_NTSC", 421 | "tag:filter:condition_like-new", 422 | "tag:filter:condition_complete", 423 | "tag:filter:type_game", 424 | "tag:filter:genre_action", 425 | "tag:filter:console_ps4", 426 | "tag:filter:condition_used", 427 | "tag:company_sony", 428 | "ps4-games", 429 | "col:ps4-games", 430 | "price:85" 431 | ], 432 | "video": "https://www.youtube.com/watch?v=eEPG4TMhwtk", 433 | "qty": 1, 434 | "title": "Spiderman GOTY", 435 | "tags": [ 436 | "filter:region_NTSC", 437 | "filter:condition_like-new", 438 | "filter:condition_complete", 439 | "filter:type_game", 440 | "filter:genre_action", 441 | "filter:console_ps4", 442 | "filter:condition_used", 443 | "company_sony" 444 | ], 445 | "updatedAt": 1681936495618, 446 | "handle": "spiderman-ps4-goty", 447 | "price": 85, 448 | "related_products": [], 449 | "createdAt": 1677243099192, 450 | "media": [ 451 | "https://m.media-amazon.com/images/I/815aKWcEkEL._AC_UF1000,1000_QL80_.jpg" 452 | ] 453 | } 454 | } 455 | ] 456 | } 457 | ], 458 | "quantity_undiscounted": 1, 459 | "uid": "5yNi0BhdZMXhlv4g2jxO65ax7bN2", 460 | "quantity_total": 1, 461 | "errors": [], 462 | "shipping_method": { 463 | "search": [ 464 | "fc1221b8-d12d-476d-9d6e-dba712dc057d", 465 | "fc1221b8", 466 | "israel", 467 | "post", 468 | "regular", 469 | "israel post regular", 470 | "30" 471 | ], 472 | "media": [], 473 | "attributes": [ 474 | { 475 | "createdAt": 1680426621729, 476 | "key": "heb_title", 477 | "val": "דואר ישראל - רגיל" 478 | } 479 | ], 480 | "name": "israel post regular", 481 | "id": "fc1221b8-d12d-476d-9d6e-dba712dc057d", 482 | "updatedAt": 1681060497395, 483 | "price": 30 484 | } 485 | }, 486 | "contact": { 487 | "email": "tomer.shalev@gmail.com", 488 | "uid": "5yNi0BhdZMXhlv4g2jxO65ax7bN2" 489 | }, 490 | "status": { 491 | "payment": { 492 | "name2": "authorized", 493 | "name": "Authorized", 494 | "id": 1 495 | }, 496 | "fulfillment": { 497 | "id": 0, 498 | "name2": "drafts", 499 | "name": "Draft" 500 | }, 501 | "checkout": { 502 | "id": 0, 503 | "name": "created" 504 | } 505 | }, 506 | "payment_gateway": { 507 | "gateway_id": "paypal-standard-payments", 508 | "latest_status": { 509 | "messages": [ 510 | "**115.00ILS** were tried to be `REFUNDED` at `Sun, 21 May 2023 05:39:32 GMT`", 511 | "The status is `COMPLETED`, updated at `Sun, 21 May 2023 05:40:02 GMT`", 512 | "Refund ID is `6FD547345J7998152`." 513 | ] 514 | }, 515 | "on_checkout_complete": { 516 | "payer": { 517 | "payer_id": "2F5RA277J6R88", 518 | "email_address": "tomer.shalev@gmail.com", 519 | "address": { 520 | "country_code": "IL" 521 | }, 522 | "name": { 523 | "given_name": "tomer", 524 | "surname": "shalev" 525 | } 526 | }, 527 | "purchase_units": [ 528 | { 529 | "reference_id": "default", 530 | "payments": { 531 | "authorizations": [ 532 | { 533 | "expiration_time": "2023-06-15T15:33:19Z", 534 | "id": "9J9788183Y866043A", 535 | "links": [ 536 | { 537 | "rel": "self", 538 | "method": "GET", 539 | "href": "https://api.sandbox.paypal.com/v2/payments/authorizations/9J9788183Y866043A" 540 | }, 541 | { 542 | "rel": "capture", 543 | "href": "https://api.sandbox.paypal.com/v2/payments/authorizations/9J9788183Y866043A/capture", 544 | "method": "POST" 545 | }, 546 | { 547 | "rel": "void", 548 | "href": "https://api.sandbox.paypal.com/v2/payments/authorizations/9J9788183Y866043A/void", 549 | "method": "POST" 550 | }, 551 | { 552 | "href": "https://api.sandbox.paypal.com/v2/payments/authorizations/9J9788183Y866043A/reauthorize", 553 | "method": "POST", 554 | "rel": "reauthorize" 555 | }, 556 | { 557 | "method": "GET", 558 | "href": "https://api.sandbox.paypal.com/v2/checkout/orders/4C501278LS069333G", 559 | "rel": "up" 560 | } 561 | ], 562 | "create_time": "2023-05-17T15:33:19Z", 563 | "amount": { 564 | "currency_code": "ILS", 565 | "value": "115.00" 566 | }, 567 | "status": "CREATED", 568 | "invoice_id": "b41239e6-b5e3-47a5-a992-ea2253c692fd_1684337530077", 569 | "update_time": "2023-05-17T15:33:19Z", 570 | "seller_protection": { 571 | "status": "NOT_ELIGIBLE" 572 | } 573 | } 574 | ] 575 | }, 576 | "shipping": { 577 | "address": { 578 | "admin_area_2": "Haifa", 579 | "country_code": "IL", 580 | "address_line_1": "haifa", 581 | "postal_code": "3556710" 582 | }, 583 | "name": { 584 | "full_name": "tomer shalev" 585 | } 586 | } 587 | } 588 | ], 589 | "payment_source": { 590 | "paypal": { 591 | "account_id": "2F5RA277J6R88", 592 | "address": { 593 | "country_code": "IL" 594 | }, 595 | "email_address": "tomer.shalev@gmail.com", 596 | "name": { 597 | "given_name": "tomer", 598 | "surname": "shalev" 599 | } 600 | } 601 | }, 602 | "id": "4C501278LS069333G", 603 | "links": [ 604 | { 605 | "href": "https://api.sandbox.paypal.com/v2/checkout/orders/4C501278LS069333G", 606 | "method": "GET", 607 | "rel": "self" 608 | } 609 | ], 610 | "status": "COMPLETED" 611 | }, 612 | "on_checkout_create": { 613 | "id": "4C501278LS069333G", 614 | "links": [ 615 | { 616 | "href": "https://api.sandbox.paypal.com/v2/checkout/orders/4C501278LS069333G", 617 | "rel": "self", 618 | "method": "GET" 619 | }, 620 | { 621 | "method": "GET", 622 | "href": "https://www.sandbox.paypal.com/checkoutnow?token=4C501278LS069333G", 623 | "rel": "approve" 624 | }, 625 | { 626 | "rel": "update", 627 | "href": "https://api.sandbox.paypal.com/v2/checkout/orders/4C501278LS069333G", 628 | "method": "PATCH" 629 | }, 630 | { 631 | "method": "POST", 632 | "href": "https://api.sandbox.paypal.com/v2/checkout/orders/4C501278LS069333G/authorize", 633 | "rel": "authorize" 634 | } 635 | ], 636 | "status": "CREATED" 637 | } 638 | }, 639 | "search": [ 640 | "b41239e6-b5e3-47a5-a992-ea2253c692fd", 641 | "b41239e6", 642 | "uid:5yNi0BhdZMXhlv4g2jxO65ax7bN2", 643 | "5yNi0BhdZMXhlv4g2jxO65ax7bN2", 644 | "tomer.shalev@gmail.com", 645 | "21/5/23", 646 | "authorized", 647 | "Authorized", 648 | "payment:authorized", 649 | "payment:1", 650 | "draft", 651 | "Draft", 652 | "fulfill:draft", 653 | "fulfill:0", 654 | "115" 655 | ], 656 | "address": { 657 | "firstname": "תומר", 658 | "lastname": "שלו", 659 | "street1": "רחוב בלה בלה", 660 | "city": "bגגsdsd", 661 | "postal_code": "32323" 662 | }, 663 | "updatedAt": 1684937820487, 664 | "coupons": [] 665 | } -------------------------------------------------------------------------------- /functions/src/actions/send-mail.js: -------------------------------------------------------------------------------- 1 | import { Firestore } from "firebase-admin/firestore"; 2 | import { OrderData, UserData } from "../js-docs-types.js"; 3 | import Mailgen from 'mailgen' 4 | import nodemailer from "nodemailer" 5 | 6 | const STORE_NAME = 'YOUR_STORE_NAME' 7 | const STORE_WEBSITE = 'website.com' 8 | const YOUR_MAIL = 'support@your-domain.com' 9 | const SEND_GRID_SECRET = 'SEND_GRID_SECRET' 10 | 11 | const send = async (subject, html, text, to) => { 12 | let transporter = nodemailer.createTransport({ 13 | host: "smtp.sendgrid.net", 14 | port: 465, 15 | secure: true, // true for 465, false for other ports 16 | auth: { 17 | user: 'apikey', // generated ethereal user 18 | pass: SEND_GRID_SECRET, // generated ethereal password 19 | }, 20 | }); 21 | 22 | // send mail with defined transport object 23 | let info = await transporter.sendMail({ 24 | from: `shelf 👻" <${YOUR_MAIL}>`, // sender address 25 | to: to, // list of receivers 26 | subject: subject, // Subject line 27 | text: text, // plain text body 28 | html: html, // html body 29 | }); 30 | 31 | } 32 | 33 | /** 34 | * @param {Firestore} db 35 | * @param {OrderData} order 36 | */ 37 | export const send_checkout_mail = async (db, order) => { 38 | var mailGenerator = new Mailgen({ 39 | theme: 'default', 40 | product: { 41 | // Appears in header & footer of e-mails 42 | name: STORE_NAME, 43 | link: STORE_WEBSITE 44 | // Optional product logo 45 | // logo: 'https://mailgen.js/img/logo.png' 46 | } 47 | }); 48 | 49 | var email = { 50 | body: { 51 | name: order.address.firstname, 52 | intro: [ 53 | `Your order ${order.id} was recieved.`, 54 | 'You will be billed after we prepare to send your order.' 55 | ], 56 | action: { 57 | instructions: 'To view your order, please click here:', 58 | button: { 59 | color: '#973cff', // Optional action button color 60 | text: 'View Order', 61 | link: `${STORE_WEBSITE}/orders?order=${order.id}` 62 | } 63 | }, 64 | outro: 'Need help, or have questions? Just reply to this email, we\'d love to help.' 65 | } 66 | }; 67 | 68 | // Generate an HTML email with the provided contents 69 | var html = mailGenerator.generate(email); 70 | var text = mailGenerator.generatePlaintext(email); 71 | var subject = `${STORE_NAME} Order received`; 72 | 73 | await send(subject, html, text, order.contact.email) 74 | } 75 | 76 | /** 77 | * @param {Firestore} db 78 | * @param {OrderData} order 79 | */ 80 | export const send_cancel_mail = async (db, order) => { 81 | var mailGenerator = new Mailgen({ 82 | theme: 'default', 83 | product: { 84 | // Appears in header & footer of e-mails 85 | name: STORE_NAME, 86 | link: STORE_WEBSITE, 87 | // Optional product logo 88 | // logo: 'https://mailgen.js/img/logo.png' 89 | } 90 | }); 91 | 92 | var email = { 93 | body: { 94 | name: order.address.firstname, 95 | intro: [ 96 | `Your order ${order.id} was cancelled.` 97 | ], 98 | action: { 99 | instructions: 'To view your cancelled order, please click here:', 100 | button: { 101 | color: '#973cff', // Optional action button color 102 | text: 'View Cancelled Order', 103 | link: `${STORE_WEBSITE}/orders?order=${order.id}` 104 | } 105 | }, 106 | outro: 'Need help, or have questions? Just reply to this email, we\'d love to help.' 107 | } 108 | }; 109 | 110 | // Generate an HTML email with the provided contents 111 | var html = mailGenerator.generate(email); 112 | var text = mailGenerator.generatePlaintext(email); 113 | var subject = `${STORE_NAME} order cancelled`; 114 | 115 | await send(subject, html, text, order.contact.email) 116 | } 117 | 118 | /** 119 | * @param {Firestore} db 120 | * @param {OrderData} order 121 | */ 122 | export const send_shipping_mail = async (db, order) => { 123 | var mailGenerator = new Mailgen({ 124 | theme: 'default', 125 | product: { 126 | // Appears in header & footer of e-mails 127 | name: STORE_NAME, 128 | link: STORE_WEBSITE, 129 | // Optional product logo 130 | // logo: 'https://mailgen.js/img/logo.png' 131 | } 132 | }); 133 | 134 | var email = { 135 | body: { 136 | name: order.address.firstname, 137 | intro: [ 138 | `Your order ${order.id} was shipped.`, 139 | order.notes ?? '' 140 | ], 141 | action: { 142 | instructions: 'To view your order, please click here:', 143 | button: { 144 | color: '#973cff', // Optional action button color 145 | text: 'View Order', 146 | link: `${STORE_WEBSITE}/orders?order=${order.id}` 147 | } 148 | }, 149 | outro: 'Need help, or have questions? Just reply to this email, we\'d love to help.' 150 | } 151 | }; 152 | 153 | // Generate an HTML email with the provided contents 154 | var html = mailGenerator.generate(email); 155 | var text = mailGenerator.generatePlaintext(email); 156 | var subject = `order shipping`; 157 | 158 | await send(subject, html, text, order.contact.email) 159 | } 160 | 161 | /** 162 | * 163 | * @param {Firestore} db 164 | * @param {UserData} user 165 | */ 166 | export const send_welcome_mail = async (db, user) => { 167 | var mailGenerator = new Mailgen({ 168 | theme: 'default', 169 | product: { 170 | // Appears in header & footer of e-mails 171 | name: STORE_NAME, 172 | link: STORE_WEBSITE, 173 | // Optional product logo 174 | // logo: 'https://mailgen.js/img/logo.png' 175 | } 176 | }); 177 | 178 | var email = { 179 | body: { 180 | name: user.firstname, 181 | intro: [ 182 | `Welcome to ${STORE_NAME}.` 183 | ], 184 | action: { 185 | instructions: 'To login your account, please click here:', 186 | button: { 187 | color: '#973cff', // Optional action button color 188 | text: 'Login', 189 | link: `${STORE_WEBSITE}/account` 190 | } 191 | }, 192 | outro: 'Need help, or have questions? Just reply to this email, we\'d love to help.' 193 | } 194 | }; 195 | 196 | // Generate an HTML email with the provided contents 197 | var html = mailGenerator.generate(email); 198 | var text = mailGenerator.generatePlaintext(email); 199 | var subject = `Welcome to ${STORE_NAME}`; 200 | 201 | await send(subject, html, text, user.email) 202 | } 203 | -------------------------------------------------------------------------------- /functions/src/actions/test-send.js: -------------------------------------------------------------------------------- 1 | import order from './order.json' assert { type: 'json' } 2 | import { send_checkout_mail, send_cancel_mail } from './send-mail.js' 3 | 4 | const test = async () => { 5 | await send_cancel_mail({}, order) 6 | } 7 | 8 | await test() -------------------------------------------------------------------------------- /functions/src/actions/v1_firestore_triggers.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | export const onordermodifiedV1 = functionsv1.region('us-central1').firestore.document( 4 | 'orders/{orderId}' 5 | ).onWrite( 6 | async (change, context) => { 7 | console.log('onordermodifiedV1') 8 | /**@type {OrderData} */ 9 | const order_after = change.after.data() 10 | /**@type {OrderData} */ 11 | const order_before = change.before.data() 12 | 13 | const has_checkout_complete = ( 14 | order_after.status.checkout.id===CheckoutStatusEnum.complete.id && 15 | order_before?.status?.checkout?.id!==order_after.status.checkout.id 16 | ) 17 | 18 | if(has_checkout_complete) { 19 | // send mail 20 | await send_checkout_mail(db, order_after) 21 | return 22 | } 23 | 24 | const is_order_cancelled = ( 25 | order_after.status.fulfillment.id===FulfillOptionsEnum.cancelled.id && 26 | order_before?.status?.fulfillment?.id!==order_after.status.fulfillment.id 27 | ) 28 | 29 | if(is_order_cancelled) { 30 | await send_cancel_mail(db, order_after) 31 | return 32 | } 33 | 34 | const is_order_shipped = ( 35 | order_after.status.fulfillment.id===FulfillOptionsEnum.shipped.id && 36 | order_before?.status?.fulfillment?.id!==order_after.status.fulfillment.id 37 | ) 38 | 39 | if(is_order_shipped) { 40 | await send_shipping_mail(db, order_after) 41 | return 42 | } 43 | 44 | } 45 | ) 46 | 47 | 48 | export const onusercreatedV1 = functionsv1.region('us-central1').firestore.document( 49 | 'users/{userId}' 50 | ).onCreate( 51 | async (snapshot, context) => { 52 | console.log('onusercreatedV1') 53 | /**@type {UserData} */ 54 | const user = snapshot.data() 55 | 56 | await send_welcome_mail(db, user) 57 | } 58 | ) 59 | -------------------------------------------------------------------------------- /functions/src/calculate-pricing/index.js: -------------------------------------------------------------------------------- 1 | import { ProductData, ShippingData, 2 | DiscountApplicationEnum, DiscountMetaEnum, 3 | FilterMetaEnum, Filter, 4 | DiscountData, DiscountDetails, DiscountMeta, 5 | BulkDiscountExtra, OrderDiscountExtra, 6 | RegularDiscountExtra, 7 | LineItem, FilterMeta, PricingData, 8 | BuyXGetYDiscountExtra, BundleDiscountExtra} from '../js-docs-types.js' 9 | 10 | 11 | /** 12 | * 13 | * @param {ProductData} product 14 | * @param {Filter} filter 15 | */ 16 | const test_product_filter_against_product = 17 | (filter, product) => { 18 | 19 | try { 20 | switch (filter.meta.op) { 21 | case FilterMetaEnum.p_all.op: 22 | return true 23 | 24 | case FilterMetaEnum.p_in_price_range.op: 25 | return (product.price>=(filter.value.from ?? 0)) && 26 | (product.price<=(filter.value.to ?? Number.POSITIVE_INFINITY)) 27 | 28 | case FilterMetaEnum.p_in_collections.op: 29 | return product.collections?.some( 30 | c => filter.value.includes(c) 31 | ) ?? false 32 | case FilterMetaEnum.p_not_in_collections.op: 33 | return product.collections?.every( 34 | c => !filter.value.includes(c) 35 | ) ?? true 36 | 37 | case FilterMetaEnum.p_in_handles.op: 38 | return filter.value.includes(product.handle) 39 | case FilterMetaEnum.p_not_in_handles.op: 40 | return !filter.value.includes(product.handle) 41 | 42 | case FilterMetaEnum.p_in_tags.op: 43 | return product.tags?.some( 44 | c => filter.value.includes(c) 45 | ) ?? false 46 | case FilterMetaEnum.p_not_in_tags.op: 47 | return product.tags?.every( 48 | c => !filter.value.includes(c) 49 | ) ?? true 50 | 51 | } 52 | 53 | } catch (e) { 54 | return false 55 | } 56 | 57 | } 58 | 59 | /** 60 | * 61 | * @param {ProductData} product 62 | * @param {Filter[]} filters 63 | */ 64 | const test_product_filters_against_product = 65 | (filters=[], product) => { 66 | 67 | filters = filters?.filter( 68 | f => f?.meta?.type==='product' 69 | ) 70 | return filters.length>0 && 71 | filters?.every( 72 | (filter) => test_product_filter_against_product( 73 | filter, product 74 | ) 75 | ) 76 | } 77 | 78 | /** 79 | * 80 | * @param {Filter} filter 81 | * @param {PricingData} context 82 | */ 83 | const test_order_filter = 84 | (filter, { uid, total, subtotal, quantity_total }) => { 85 | 86 | try { 87 | switch (filter.meta.op) { 88 | case FilterMetaEnum.o_date_in_range.op: 89 | const now = Date.now() 90 | return (now>=filter.value.from && now < filter.value.to) 91 | 92 | case FilterMetaEnum.o_has_customer.op: 93 | return filter.value.includes( 94 | uid 95 | ) 96 | 97 | case FilterMetaEnum.o_items_count_in_range.op: 98 | return quantity_total>=filter.value.from 99 | 100 | case FilterMetaEnum.o_subtotal_in_range.op: 101 | return subtotal>=filter.value.from 102 | 103 | default: 104 | return false 105 | } 106 | 107 | } catch (e) { 108 | return false 109 | } 110 | 111 | } 112 | 113 | /** 114 | * 115 | * @param {ProductData} product 116 | * @param {Filter[]} filters 117 | * @param {PricingData} context 118 | */ 119 | const test_order_filters = 120 | (filters, context) => { 121 | 122 | filters = filters?.filter( 123 | f => f.meta.type==='order' 124 | ) 125 | return filters.length>0 && 126 | filters.every( 127 | (filter) => test_order_filter( 128 | filter, context 129 | ) 130 | ) 131 | } 132 | 133 | /** 134 | * @param {number} v 135 | * @param {number} a 136 | * @param {number} b 137 | * @returns {number} 138 | */ 139 | const clamp = (v, a, b) => { 140 | return (typeof v==='number') ? 141 | Math.max(Math.min(v, b), a) : 142 | a 143 | } 144 | 145 | /** 146 | * 147 | * @param {number} quantity integer >=0 148 | * @param {number} price 149 | * @param {number} percent_off a number between [0..100] 150 | * @param {number} fixed_off a positive number >=0 151 | * @returns {number} 152 | */ 153 | const apply_discount = 154 | (quantity=0, price=0, percent_off=0, fixed_off=0) => { 155 | 156 | quantity = Math.floor(Math.max(quantity, 0)) 157 | percent_off = clamp(percent_off, 0, 100) 158 | fixed_off = parseFloat(fixed_off) 159 | 160 | const total_price = price * quantity 161 | 162 | const total_off = (total_price * percent_off)/100 - (fixed_off * quantity) 163 | 164 | // console.log('quantity ', quantity) 165 | // console.log('price ', price) 166 | // console.log('percent_off ', percent_off) 167 | // console.log('fixed_off ', fixed_off) 168 | 169 | return Math.min(Math.max(total_off, 0), total_price) 170 | } 171 | 172 | /** 173 | * 174 | * @param {boolean} condition 175 | * @param {string} message 176 | * @throws {Error} 177 | */ 178 | const assert = (condition, message) => { 179 | if(Boolean(condition)) 180 | return 181 | throw new Error(message) 182 | } 183 | 184 | /** 185 | * 186 | * @param {LineItem[]} line_items 187 | * @returns {number} 188 | */ 189 | export const lineitems_to_quantity = (line_items) => { 190 | return line_items.reduce((p, c) => p + c.qty, 0) 191 | } 192 | 193 | 194 | /** 195 | * 196 | * @param {LineItem[]} line_items 197 | * @param {PricingData} context 198 | * @param {DiscountData} discount 199 | * @returns {CalcDiscountResult} 200 | */ 201 | export const calculate_line_items_discount_with_regular_discount = 202 | (line_items, discount, context) => { 203 | 204 | assert( 205 | discount.info.details.meta.type === DiscountMetaEnum.regular.type, 206 | 'error:: tried to discount a non regular discount' 207 | ) 208 | 209 | // mask 210 | const pass_mask = line_items.map( 211 | li => { 212 | return test_product_filters_against_product( 213 | discount?.info?.filters, li.data 214 | ) 215 | } 216 | ) 217 | 218 | // perform discount and compute new generation of line items 219 | const line_items_next = line_items.filter( 220 | (li, ix) => !pass_mask[ix] 221 | ) 222 | 223 | const discount_details = discount?.info?.details 224 | /**@type {RegularDiscountExtra} */ 225 | const discount_extra = discount_details?.extra 226 | 227 | const $percent = clamp(discount_extra?.percent, 0, 100) ?? 0 228 | const $fixed = discount_extra?.fixed ?? 0 229 | 230 | const report = line_items.filter( 231 | (li, ix) => pass_mask[ix] 232 | ).reduce( 233 | (p, c, ix) => { 234 | 235 | const qty = c.qty 236 | const price = c?.data?.price ?? c.price 237 | const curr = apply_discount( 238 | c.qty, price, $percent, $fixed 239 | ) 240 | 241 | p.total_discount += curr 242 | p.quantity_discounted += qty 243 | 244 | return p 245 | } 246 | , { 247 | total_discount: 0, 248 | quantity_discounted : 0, 249 | quantity_undiscounted: lineitems_to_quantity(line_items_next) 250 | } 251 | ) 252 | 253 | return { 254 | line_items_next, 255 | ...report 256 | } 257 | 258 | } 259 | 260 | /** 261 | * 262 | * @param {LineItem[]} line_items 263 | * @param {PricingData} context 264 | * @param {DiscountData} discount 265 | * @returns {CalcDiscountResult} 266 | */ 267 | export const calculate_line_items_discount_with_bulk_discount = 268 | (line_items, discount, context) => { 269 | 270 | assert( 271 | discount.info.details.meta.type === DiscountMetaEnum.bulk.type, 272 | 'error:: tried to discount a non bulk discount' 273 | ) 274 | 275 | const discount_details = discount?.info?.details 276 | /**@type {BulkDiscountExtra} */ 277 | const discount_extra = discount_details?.extra 278 | 279 | const $percent = clamp(discount_extra?.percent, 0, 100) ?? 0 280 | const $fixed = discount_extra?.fixed ?? 0 281 | const qty = discount_extra?.qty ?? 0 282 | const recursive = discount_extra?.recursive ?? false 283 | 284 | assert( 285 | qty > 0, 'bulk discount qty <= 0' 286 | ) 287 | 288 | const { pass_mask, pass_quantity } = compute_pass_mask( 289 | line_items, discount?.info?.filters 290 | ) 291 | 292 | // compute all the total quantity that is legable for the 293 | // discount 294 | const total_legal_qty = pass_quantity 295 | 296 | // how many legable groups/bulks do we have 297 | const max_bulks_can_fit = Math.floor(total_legal_qty / qty) 298 | 299 | const how_many_bulks_to_fit = recursive ? 300 | max_bulks_can_fit : 301 | Math.min(max_bulks_can_fit, 1) 302 | 303 | const how_many_to_reduce = how_many_bulks_to_fit * qty 304 | 305 | // remove how_many_fit and compute their total 306 | const { line_items_next, total } = reduce_from_line_items( 307 | line_items, how_many_to_reduce, pass_mask 308 | ) 309 | 310 | const total_discount = apply_discount( 311 | 1, total, $percent, $fixed * how_many_bulks_to_fit 312 | ) 313 | 314 | return { 315 | line_items_next, 316 | total_discount, 317 | quantity_discounted: how_many_to_reduce, 318 | quantity_undiscounted: lineitems_to_quantity(line_items_next) 319 | } 320 | 321 | } 322 | 323 | /** 324 | * @typedef {object} ReduceResult 325 | * @property {number} how_many_left_to_reduce 326 | * @property {number} total total price of reduced items 327 | * @property {LineItem[]} line_items_next reduced line items 328 | * 329 | * create new line items with reduced quantities 330 | * @param {LineItem[]} line_items 331 | * @param {number} how_many_to_reduce 332 | * @param {boolean[]} pass_mask 333 | * @returns {ReduceResult} 334 | */ 335 | const reduce_from_line_items = 336 | (line_items, how_many_to_reduce, pass_mask) => { 337 | 338 | const line_items_next = line_items.map( 339 | li => ({ ...li }) 340 | ) 341 | 342 | return line_items_next.reduce( 343 | (p, c, ix) => { 344 | if(!pass_mask[ix]) 345 | return p 346 | 347 | const reduce_count = Math.min( 348 | p.how_many_left_to_reduce, 349 | c.qty 350 | ) 351 | const reduced_total = reduce_count * (c.price ?? c.data.price) 352 | 353 | p.how_many_left_to_reduce -= reduce_count 354 | p.total += reduced_total 355 | 356 | // reduce 357 | c.qty -= reduce_count 358 | 359 | return p 360 | } 361 | , { 362 | how_many_left_to_reduce: how_many_to_reduce, 363 | total: 0, 364 | line_items_next 365 | } 366 | ) 367 | } 368 | 369 | /** 370 | * @typedef {object} PassResult 371 | * @property {boolean[]} pass_mask 372 | * @property {number} pass_quantity how many quantities pass the filters 373 | * @property {number} pass_total_quantity total quantities of line items 374 | * 375 | * @param {LineItem[]} line_items line items to inspect 376 | * @param {Filter[]} filters filters 377 | * @return {PassResult} 378 | */ 379 | const compute_pass_mask = (line_items=[], filters=[]) => { 380 | return line_items.reduce( 381 | (p, c, ix) => { 382 | const pass = test_product_filters_against_product( 383 | filters, c.data 384 | ) 385 | 386 | p.pass_mask.push(pass); 387 | p.pass_quantity += (pass ? c.qty : 0); 388 | p.pass_total_quantity += c.qty; 389 | 390 | return p 391 | }, { 392 | pass_mask: [], 393 | pass_quantity: 0, 394 | pass_total_quantity: 0 395 | } 396 | 397 | ) 398 | 399 | } 400 | 401 | /** 402 | * @typedef {object} CalcDiscountResult 403 | * @property {LineItem[]} line_items_next 404 | * @property {number} total_discount total discount given at this stage 405 | * @property {number} quantity_discounted quantity of items that were part of discount and may not be used again 406 | * @property {number} quantity_undiscounted quantity of remaining items 407 | */ 408 | 409 | /** 410 | * 411 | * @param {LineItem[]} line_items 412 | * @param {PricingData} context 413 | * @param {DiscountData} discount 414 | * @returns {CalcDiscountResult} 415 | */ 416 | export const calculate_line_items_discount_with_buy_x_get_y_discount = 417 | (line_items, discount, context) => { 418 | 419 | assert( 420 | discount.info.details.meta.type === DiscountMetaEnum.buy_x_get_y.type, 421 | 'error:: tried to discount a non buy_x_get_y discount' 422 | ) 423 | 424 | const discount_details = discount?.info?.details 425 | /**@type {BuyXGetYDiscountExtra} */ 426 | const discount_extra = discount_details?.extra 427 | 428 | const $percent = clamp(discount_extra?.percent, 0, 100) ?? 0 429 | const $fixed = discount_extra?.fixed ?? 0 430 | const qty_x = discount_extra?.qty_x ?? 0 431 | const qty_y = discount_extra?.qty_y ?? 0 432 | const recursive = discount_extra?.recursive ?? false 433 | 434 | /**@type {CalcDiscountResult} */ 435 | const result = { 436 | line_items_next: line_items, 437 | quantity_discounted: 0, 438 | total_discount: 0 439 | } 440 | 441 | assert( 442 | qty_x>0 && qty_y>0, 443 | 'buy_x_get_y: qty_x>0 && qty_y>0 fails' 444 | ) 445 | 446 | do { 447 | const { 448 | pass_mask: pass_mask_x, 449 | pass_quantity: pass_quantity_x 450 | } = compute_pass_mask( 451 | result.line_items_next, discount?.info?.filters 452 | ) 453 | 454 | // we don't have enough X quantities 455 | if(qty_x > pass_quantity_x) 456 | break; 457 | 458 | // evolve: remove qty_x from line items 459 | const { line_items_next: line_items_x_next } = reduce_from_line_items( 460 | result.line_items_next, qty_x, pass_mask_x 461 | ) 462 | 463 | // now let's see if we have Y items in what's left 464 | const { 465 | pass_mask: pass_mask_y, 466 | pass_quantity: pass_quantity_y 467 | } = compute_pass_mask( 468 | line_items_x_next, discount_extra?.filters_y 469 | ) 470 | 471 | // we don't have enough Y quantities 472 | if(qty_y > pass_quantity_y) 473 | break; 474 | 475 | // evolve: remove qty_y from line_items_x_next 476 | const { 477 | line_items_next: line_items_y_next, 478 | total: total_price_y 479 | } = reduce_from_line_items( 480 | line_items_x_next, qty_y, pass_mask_y 481 | ); 482 | 483 | result.line_items_next = line_items_y_next 484 | result.total_discount += apply_discount( 485 | 1, total_price_y, $percent, $fixed 486 | ) 487 | result.quantity_discounted += qty_x + qty_y 488 | } while (recursive) 489 | 490 | return { 491 | ...result, 492 | quantity_undiscounted: lineitems_to_quantity(result.line_items_next) 493 | } 494 | 495 | } 496 | 497 | /** 498 | * 499 | * @param {LineItem[]} line_items 500 | * @param {PricingData} context 501 | * @param {DiscountData} discount 502 | * @returns {CalcDiscountResult} 503 | */ 504 | export const calculate_line_items_discount_with_bundle_discount = 505 | (line_items, discount, context) => { 506 | 507 | assert( 508 | discount.info.details.meta.type === DiscountMetaEnum.bundle.type, 509 | 'error:: tried to discount a non bundle discount' 510 | ) 511 | 512 | const discount_details = discount?.info?.details 513 | /**@type {BundleDiscountExtra} */ 514 | const discount_extra = discount_details?.extra 515 | 516 | const $percent = clamp(discount_extra?.percent, 0, 100) ?? 0 517 | const $fixed = discount_extra?.fixed ?? 0 518 | const recursive = discount_extra?.recursive ?? false 519 | 520 | /**@type {CalcDiscountResult} */ 521 | const result = { 522 | line_items_next: line_items.map(li => ({ ...li })), 523 | quantity_discounted: 0, 524 | total_discount: 0 525 | } 526 | 527 | do { 528 | // Each filter is a product in the bundle 529 | const locations = discount?.info?.filters.reduce( 530 | /** 531 | * @param {number[]} p 532 | * @param {Filter} f 533 | */ 534 | (p, f, ix) => { 535 | const loc = result.line_items_next.findIndex( 536 | l => test_product_filters_against_product( 537 | [f], l.data 538 | ) && (l.qty > 0) 539 | ) 540 | 541 | p.push(loc) 542 | 543 | return p 544 | }, [] 545 | ) 546 | 547 | const valid = locations.every(loc => loc!=-1) 548 | 549 | if(!valid) 550 | break; 551 | 552 | // reduce quantities 553 | locations.forEach( 554 | loc => { 555 | result.line_items_next[loc].qty -= 1; 556 | } 557 | ) 558 | 559 | const sum_price = locations.reduce( 560 | (p, loc) => p + result.line_items_next[loc].price, 0 561 | ); 562 | 563 | 564 | result.quantity_discounted = locations.length; 565 | result.total_discount += apply_discount( 566 | 1, sum_price, $percent, $fixed 567 | ) 568 | 569 | } while (recursive) 570 | 571 | return { 572 | ...result, 573 | quantity_undiscounted: lineitems_to_quantity(result.line_items_next) 574 | } 575 | 576 | } 577 | 578 | /** 579 | * 580 | * @param {LineItem[]} line_items 581 | * @param {PricingData} context 582 | * @param {DiscountData} discount 583 | * @returns {CalcDiscountResult} 584 | */ 585 | export const calculate_line_items_discount_with_order_discount = 586 | (line_items, discount, context) => { 587 | 588 | const discount_details = discount.info.details 589 | 590 | assert( 591 | discount_details.meta.type === DiscountMetaEnum.order.type, 592 | 'error:: tried to discount a non bulk discount' 593 | ) 594 | 595 | const pass = test_order_filters( 596 | discount.info.filters, context 597 | ) 598 | let total_discount = 0 599 | 600 | if(pass) { 601 | /**@type {OrderDiscountExtra} */ 602 | const extra = discount_details.extra 603 | const $p = extra.percent 604 | const $f = extra.fixed 605 | const free_shipping = extra.free_shipping 606 | 607 | total_discount = apply_discount( 608 | 1, context.subtotal, $p, $f 609 | ) 610 | } 611 | 612 | return { 613 | line_items_next: line_items, 614 | total_discount 615 | } 616 | } 617 | 618 | /** 619 | * route a discount to it's handler 620 | * given: 621 | * - a line of products 622 | * - a discount 623 | * 624 | * Compute the: 625 | * - total price 626 | * - explain how discounts contribute 627 | * 628 | * @param {LineItem[]} line_items available line items 629 | * @param {PricingData} context context of discounts 630 | * @param {DiscountData} discount 631 | */ 632 | export const calculate_line_items_for_discount = 633 | (line_items, discount, context) => { 634 | 635 | const discount_type = discount.info.details.meta.type 636 | 637 | switch(discount_type) { 638 | case DiscountMetaEnum.regular.type: 639 | return calculate_line_items_discount_with_regular_discount( 640 | line_items, discount, context 641 | ) 642 | case DiscountMetaEnum.bulk.type: 643 | return calculate_line_items_discount_with_bulk_discount( 644 | line_items, discount, context 645 | ) 646 | case DiscountMetaEnum.order.type: 647 | return calculate_line_items_discount_with_order_discount( 648 | line_items, discount, context 649 | ) 650 | case DiscountMetaEnum.buy_x_get_y.type: 651 | return calculate_line_items_discount_with_buy_x_get_y_discount( 652 | line_items, discount, context 653 | ) 654 | case DiscountMetaEnum.bundle.type: 655 | return calculate_line_items_discount_with_bundle_discount( 656 | line_items, discount, context 657 | ) 658 | default: 659 | return { 660 | line_items_next: line_items, 661 | total_discount: 0 662 | } 663 | } 664 | 665 | } 666 | 667 | 668 | /** 669 | * given: 670 | * - a line of products 671 | * - a line of discounts 672 | * - a line of coupons 673 | * - shipping method 674 | * 675 | * Compute the: 676 | * - total price 677 | * - explain how discounts contribute 678 | * 679 | * @param {LineItem[]} line_items 680 | * @param {DiscountData[]} auto_discounts disabled discounted will be filtered out 681 | * @param {DiscountData[]} coupons disabled coupons will be filtered out 682 | * @param {ShippingData} shipping_method 683 | * @param {string} uid 684 | * @returns {PricingData} 685 | */ 686 | export const calculate_pricing = 687 | (line_items, auto_discounts=[], coupons=[], shipping_method, uid) => { 688 | 689 | auto_discounts = auto_discounts.filter( 690 | d => d.enabled && d.application.id==DiscountApplicationEnum.Auto.id 691 | ) 692 | auto_discounts.sort( 693 | (a, b) => a.order-b.order 694 | ) 695 | 696 | coupons = coupons.filter( 697 | d => d.enabled && d.application.id==DiscountApplicationEnum.Manual.id 698 | ) 699 | coupons.sort( 700 | (a, b) => a.order-b.order 701 | ) 702 | 703 | const discounts = [ 704 | ...auto_discounts, 705 | ...coupons 706 | ] 707 | 708 | // protections against strings 709 | shipping_method.price = parseFloat(shipping_method.price) 710 | 711 | line_items = line_items.map( 712 | li => ( 713 | { 714 | ...li, 715 | price: li.price, 716 | qty: parseInt(li.qty) 717 | } 718 | ) 719 | ).sort( 720 | (a, b) => -a.price + b.price 721 | ) 722 | 723 | const subtotal_undiscounted = line_items.reduce( 724 | (p, li) => p + li.qty * parseFloat(li.price ?? li.data?.price), 0 725 | ) 726 | const quantity_total = line_items.reduce( 727 | (p, li) => p + li.qty , 0 728 | ) 729 | 730 | /**@type {PricingData} */ 731 | const context = { 732 | evo: [{ 733 | quantity_discounted: 0, 734 | quantity_undiscounted: quantity_total, 735 | line_items, 736 | subtotal: subtotal_undiscounted, 737 | total: subtotal_undiscounted + shipping_method.price 738 | }], 739 | 740 | uid, 741 | shipping_method, 742 | 743 | subtotal_discount: 0, 744 | subtotal_undiscounted, 745 | subtotal: subtotal_undiscounted - 0, 746 | 747 | total_undiscounted: subtotal_undiscounted + shipping_method.price, 748 | total: subtotal_undiscounted + shipping_method.price, 749 | 750 | quantity_total, 751 | quantity_discounted: 0, 752 | quantity_undiscounted: quantity_total, 753 | 754 | errors: [] 755 | } 756 | 757 | const report = discounts.reduce( 758 | (ctx, discount, ix) => { 759 | 760 | try { 761 | const { 762 | line_items_next, total_discount, ...rest 763 | } = calculate_line_items_for_discount( 764 | ctx.evo.at(-1).line_items, discount, ctx 765 | ) 766 | 767 | ctx.subtotal_discount += total_discount 768 | ctx.subtotal -= total_discount 769 | ctx.total -= total_discount 770 | ctx.quantity_discounted = ctx.quantity_discounted + (rest?.quantity_discounted ?? 0) 771 | ctx.quantity_undiscounted = ctx.quantity_undiscounted - (rest?.quantity_discounted ?? 0) 772 | 773 | ctx.evo.push({ 774 | ...rest, 775 | discount, 776 | discount_code: discount.code, 777 | total_discount, 778 | subtotal: ctx.subtotal, 779 | total: ctx.total, 780 | line_items: line_items_next 781 | }) 782 | 783 | } catch (e) { 784 | console.log(e) 785 | ctx.errors.push({ 786 | discount_code: discount.code, 787 | message: e?.message ?? e 788 | }) 789 | } finally { 790 | ctx.total = parseFloat(ctx.total.toFixed(2)) 791 | return ctx 792 | } 793 | 794 | }, context 795 | ) 796 | 797 | return report 798 | } 799 | -------------------------------------------------------------------------------- /functions/src/calculate-pricing/test/index.js: -------------------------------------------------------------------------------- 1 | import { calculate_pricing, lineitems_to_quantity } from '../index.js' 2 | import { LineItem } from '../../../js-docs-types.js' 3 | 4 | const log = console.log 5 | 6 | import line_items from './line_items_1.json' assert { type: 'json' } 7 | import test_bulk_ds_games_3_for_100 from './test-bulk-ds-games-3-for-99.json' assert { type: 'json' } 8 | import test_order_10_percents_off_above_300 from './test-order-10-percents-off.json' assert { type: 'json' } 9 | import test_order_fixed_off_order from './test-order-fixed-off-order.json' assert { type: 'json' } 10 | import test_regular_all_games from './test-regular-all-games.json' assert { type: 'json' } 11 | import test_regular_ps4_games from './test-regular-ps4-games.json' assert { type: 'json' } 12 | import test_regular_wii_games from './test-regular-wii-games.json' assert { type: 'json' } 13 | import test_regular_switch_games from './test-regular-switch-games.json' assert { type: 'json' } 14 | 15 | const shipping = { 16 | price: 25 17 | } 18 | 19 | /** 20 | * 21 | * @param {LineItem[]} line_items 22 | */ 23 | const print_line_items = (line_items) => { 24 | log(`\n\nLine Items (${lineitems_to_quantity(line_items)})`) 25 | line_items.forEach( 26 | li => { 27 | log(`- ${li.id} x ${li.qty} (${li.data?.price ?? li.price})`) 28 | } 29 | ) 30 | 31 | const total = line_items.reduce( 32 | (p, c) => p + c.qty * (c?.data?.price ?? c.price), 0 33 | ) 34 | 35 | log('\nTotal: ' + total) 36 | } 37 | 38 | const test_1 = () => { 39 | print_line_items(line_items) 40 | log(`Applying discount`) 41 | const result = calculate_pricing( 42 | line_items, [ 43 | // test_regular_ps4_games, 44 | // test_regular_wii_games, 45 | // test_bulk_ds_games_3_for_100, 46 | // test_regular_all_games, 47 | test_order_fixed_off_order, 48 | // test_order_10_percents_off_above_300 49 | ], 50 | undefined, shipping 51 | ) 52 | log(result) 53 | } 54 | 55 | test_1() -------------------------------------------------------------------------------- /functions/src/calculate-pricing/test/line_items_1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "qty": 1, 4 | "id": "killzone-shadow-fall", 5 | "data": { 6 | "updatedAt": 1677150975170, 7 | "video": "https://youtu.be/hpP10AECt-c", 8 | "qty": 1, 9 | "createdAt": 1677150716225, 10 | "collections": [ 11 | "ps4-games" 12 | ], 13 | "price": 55, 14 | "handle": "killzone-shadow-fall", 15 | "tags": [ 16 | "condition_good", 17 | "filter:console_ps4", 18 | "filter:condition_complete", 19 | "filter:genre_fps", 20 | "filter:type_game" 21 | ], 22 | "title": "Killzone shadow fall" 23 | } 24 | }, 25 | { 26 | "qty": 1, 27 | "id": "shenmue-2-xboxog", 28 | "data": { 29 | "price": 70, 30 | "updatedAt": 1677839012476, 31 | "title": "Shenmue 2 XboxOG", 32 | "tags": [ 33 | "filter:region_PAL", 34 | "filter:condition_like-new", 35 | "filter:condition_complete", 36 | "filter:type_game", 37 | "filter:genre_action" 38 | ], 39 | "createdAt": 1677839012033, 40 | "video": "https://www.youtube.com/watch?v=ayOYawULIDI", 41 | "collections": [ 42 | "xbox-360-original-og" 43 | ], 44 | "handle": "shenmue-2-xboxog", 45 | "qty": 1 46 | } 47 | }, 48 | { 49 | "qty": 2, 50 | "id": "resident-evil-4", 51 | "data": { 52 | "price": 80, 53 | "tags": [ 54 | "filter:condition_like-new", 55 | "filter:condition_complete", 56 | "filter:genre_horror", 57 | "filter:console_wii", 58 | "filter:region_PAL", 59 | "filter:type_game" 60 | ], 61 | "handle": "resident-evil-4", 62 | "title": "Resident Evil 4", 63 | "video": "https://www.youtube.com/watch?v=gwui874YUik", 64 | "collections": [ 65 | "wii-games" 66 | ], 67 | "desc": "complete in box", 68 | "qty": 2, 69 | "createdAt": 1677149263804, 70 | "updatedAt": 1678052803903 71 | } 72 | }, 73 | { 74 | "qty": 1, 75 | "id": "ds-game-1", 76 | "data": { 77 | "price": 70, 78 | "updatedAt": 1677839012476, 79 | "title": "DS game 1", 80 | "tags": [ 81 | "filter:region_PAL", 82 | "filter:condition_like-new", 83 | "filter:condition_complete", 84 | "filter:type_game", 85 | "filter:genre_action" 86 | ], 87 | "createdAt": 1677839012033, 88 | "video": "https://www.youtube.com/watch?v=ayOYawULIDI", 89 | "collections": [ 90 | "nintendo-ds-games" 91 | ], 92 | "handle": "ds-game-1", 93 | "qty": 1 94 | } 95 | }, 96 | { 97 | "qty": 1, 98 | "id": "ds-game-2", 99 | "data": { 100 | "price": 70, 101 | "updatedAt": 1677839012476, 102 | "title": "DS game 2", 103 | "tags": [ 104 | "filter:region_PAL", 105 | "filter:condition_like-new", 106 | "filter:condition_complete", 107 | "filter:type_game", 108 | "filter:genre_action" 109 | ], 110 | "createdAt": 1677839012033, 111 | "video": "https://www.youtube.com/watch?v=ayOYawULIDI", 112 | "collections": [ 113 | "nintendo-ds-games" 114 | ], 115 | "handle": "ds-game-2", 116 | "qty": 1 117 | } 118 | }, 119 | { 120 | "qty": 10, 121 | "id": "ds-game-3", 122 | "data": { 123 | "price": 70, 124 | "updatedAt": 1677839012476, 125 | "title": "DS game 3", 126 | "tags": [ 127 | "filter:region_PAL", 128 | "filter:condition_like-new", 129 | "filter:condition_complete", 130 | "filter:type_game", 131 | "filter:genre_action" 132 | ], 133 | "createdAt": 1677839012033, 134 | "video": "https://www.youtube.com/watch?v=ayOYawULIDI", 135 | "collections": [ 136 | "nintendo-ds-games" 137 | ], 138 | "handle": "ds-game-3", 139 | "qty": 10 140 | } 141 | } 142 | 143 | ] -------------------------------------------------------------------------------- /functions/src/calculate-pricing/test/test-bulk-ds-games-3-for-99.json: -------------------------------------------------------------------------------- 1 | { 2 | "updatedAt": 1679477419118, 3 | "enabled": true, 4 | "media": [], 5 | "code": "test-bulk-ds-games-3-for-99", 6 | "title": "test-bulk-ds-games-3-for-99", 7 | "search": [ 8 | "test-bulk-ds-games-3-for-99", 9 | "test-bulk-ds-games-3-for-99", 10 | "automatic", 11 | "app:automatic", 12 | "app:0", 13 | "bulk", 14 | "1", 15 | "type:1", 16 | "type:bulk", 17 | "enabled:true" 18 | ], 19 | "order": 9, 20 | "application": { 21 | "name": "Automatic", 22 | "id": 0 23 | }, 24 | "createdAt": 1679413598698, 25 | "info": { 26 | "filters": [ 27 | { 28 | "meta": { 29 | "type": "product", 30 | "id": 0, 31 | "op": "p-in-collections" 32 | }, 33 | "value": [ 34 | "nintendo-ds-games" 35 | ] 36 | } 37 | ], 38 | "details": { 39 | "meta": { 40 | "id": 1, 41 | "type": "bulk", 42 | "name": "Bulk Discount" 43 | }, 44 | "extra": { 45 | "percent": 100, 46 | "fixed": 100, 47 | "recursive": true, 48 | "qty": 3 49 | } 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /functions/src/calculate-pricing/test/test-order-10-percents-off.json: -------------------------------------------------------------------------------- 1 | { 2 | "code": "test-order-10-percents-off", 3 | "title": "test-order-10-percents-off", 4 | "info": { 5 | "filters": [ 6 | { 7 | "meta": { 8 | "id": 100, 9 | "type": "order", 10 | "op": "o-subtotal-in-range" 11 | }, 12 | "value": { 13 | "from": 200, 14 | "to": null 15 | } 16 | } 17 | ], 18 | "details": { 19 | "meta": { 20 | "id": 3, 21 | "type": "order", 22 | "name": "Order Discount" 23 | }, 24 | "extra": { 25 | "percent": 10, 26 | "fixed": 0, 27 | "free_shipping": false 28 | } 29 | } 30 | }, 31 | "media": [], 32 | "enabled": true, 33 | "application": { 34 | "id": 0, 35 | "name": "Automatic" 36 | }, 37 | "order": 20, 38 | "createdAt": 1679413674344, 39 | "updatedAt": 1679413674908, 40 | "search": [ 41 | "test-order-10-percents-off", 42 | "test-order-10-percents-off", 43 | "automatic", 44 | "app:automatic", 45 | "app:0", 46 | "order", 47 | "3", 48 | "type:3", 49 | "type:order", 50 | "enabled:true" 51 | ] 52 | } -------------------------------------------------------------------------------- /functions/src/calculate-pricing/test/test-order-fixed-off-order.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "order above 299 discounted", 3 | "createdAt": 1680457002571, 4 | "order": 100, 5 | "desc": "הנחה של 25 שקל על כל הזמנה מעל 299 ש\"ח", 6 | "application": { 7 | "name": "Automatic", 8 | "id": 0 9 | }, 10 | "info": { 11 | "filters": [ 12 | { 13 | "meta": { 14 | "op": "o-subtotal-in-range", 15 | "id": 100, 16 | "type": "order" 17 | }, 18 | "value": { 19 | "from": 299, 20 | "to": null 21 | } 22 | } 23 | ], 24 | "details": { 25 | "meta": { 26 | "name": "Order Discount", 27 | "id": 3, 28 | "type": "order" 29 | }, 30 | "extra": { 31 | "free_shipping": false, 32 | "percent": 0, 33 | "fixed": -25 34 | } 35 | } 36 | }, 37 | "enabled": true, 38 | "updatedAt": 1681064080925, 39 | "search": [ 40 | "order-above-299", 41 | "order", 42 | "above", 43 | "299", 44 | "discounted", 45 | "automatic", 46 | "app:automatic", 47 | "app:0", 48 | "order", 49 | "3", 50 | "type:3", 51 | "type:order", 52 | "enabled:true" 53 | ], 54 | "code": "order-above-299", 55 | "media": [] 56 | } -------------------------------------------------------------------------------- /functions/src/calculate-pricing/test/test-regular-all-games.json: -------------------------------------------------------------------------------- 1 | { 2 | "enabled": true, 3 | "code": "test-regular-all-games", 4 | "media": [], 5 | "application": { 6 | "name": "Automatic", 7 | "id": 0 8 | }, 9 | "title": "test-regular-all-games", 10 | "order": 10, 11 | "createdAt": 1679413475474, 12 | "updatedAt": 1679413475736, 13 | "info": { 14 | "filters": [ 15 | { 16 | "meta": { 17 | "id": 6, 18 | "type": "product", 19 | "op": "p-all" 20 | } 21 | } 22 | ], 23 | "details": { 24 | "meta": { 25 | "name": "Regular Discount", 26 | "type": "regular", 27 | "id": 0 28 | }, 29 | "extra": { 30 | "fixed": 0, 31 | "percent": 5 32 | } 33 | } 34 | }, 35 | "search": [ 36 | "test-regular-all-games", 37 | "test-regular-all-games", 38 | "automatic", 39 | "app:automatic", 40 | "app:0", 41 | "regular", 42 | "0", 43 | "type:0", 44 | "type:regular", 45 | "enabled:true" 46 | ] 47 | } -------------------------------------------------------------------------------- /functions/src/calculate-pricing/test/test-regular-ps4-games.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "test regular ps4 games", 3 | "code": "test-regular-ps4-games", 4 | "info": { 5 | "filters": [ 6 | { 7 | "meta": { 8 | "type": "product", 9 | "id": 0, 10 | "op": "p-in-collections" 11 | }, 12 | "value": [ 13 | "ps4-games" 14 | ] 15 | } 16 | ], 17 | "details": { 18 | "meta": { 19 | "type": "regular", 20 | "name": "Regular Discount", 21 | "id": 0 22 | }, 23 | "extra": { 24 | "percent": 7, 25 | "fixed": 0 26 | } 27 | } 28 | }, 29 | "updatedAt": 1679413440840, 30 | "createdAt": 1679413440811, 31 | "media": [], 32 | "enabled": true, 33 | "application": { 34 | "id": 0, 35 | "name": "Automatic" 36 | }, 37 | "search": [ 38 | "test-regular-ps4-games", 39 | "test", 40 | "regular", 41 | "ps4", 42 | "games", 43 | "automatic", 44 | "app:automatic", 45 | "app:0", 46 | "regular", 47 | "0", 48 | "type:0", 49 | "type:regular", 50 | "enabled:true" 51 | ], 52 | "order": 10 53 | } -------------------------------------------------------------------------------- /functions/src/calculate-pricing/test/test-regular-switch-games.json: -------------------------------------------------------------------------------- 1 | { 2 | "createdAt": 1679413371702, 3 | "updatedAt": 1679413371736, 4 | "order": 10, 5 | "title": "Test Regular Switch Games", 6 | "media": [], 7 | "enabled": true, 8 | "info": { 9 | "filters": [ 10 | { 11 | "value": [ 12 | "nintendo-switch-games" 13 | ], 14 | "meta": { 15 | "op": "p-in-collections", 16 | "id": 0, 17 | "type": "product" 18 | } 19 | } 20 | ], 21 | "details": { 22 | "meta": { 23 | "id": 0, 24 | "type": "regular", 25 | "name": "Regular Discount" 26 | }, 27 | "extra": { 28 | "percent": 5, 29 | "fixed": 0 30 | } 31 | } 32 | }, 33 | "code": "test-regular-switch-games", 34 | "search": [ 35 | "test-regular-switch-games", 36 | "test", 37 | "regular", 38 | "switch", 39 | "games", 40 | "automatic", 41 | "app:automatic", 42 | "app:0", 43 | "regular", 44 | "0", 45 | "type:0", 46 | "type:regular", 47 | "enabled:true" 48 | ], 49 | "application": { 50 | "name": "Automatic", 51 | "id": 0 52 | } 53 | } -------------------------------------------------------------------------------- /functions/src/calculate-pricing/test/test-regular-wii-games.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "test regular wii games", 3 | "code": "test-regular-wii-games", 4 | "info": { 5 | "filters": [ 6 | { 7 | "meta": { 8 | "type": "product", 9 | "id": 0, 10 | "op": "p-in-collections" 11 | }, 12 | "value": [ 13 | "wii-games" 14 | ] 15 | } 16 | ], 17 | "details": { 18 | "meta": { 19 | "type": "regular", 20 | "name": "Regular Discount", 21 | "id": 0 22 | }, 23 | "extra": { 24 | "percent": 7, 25 | "fixed": 0 26 | } 27 | } 28 | }, 29 | "updatedAt": 1679413440840, 30 | "createdAt": 1679413440811, 31 | "media": [], 32 | "enabled": true, 33 | "application": { 34 | "id": 0, 35 | "name": "Automatic" 36 | }, 37 | "search": [ 38 | "test-regular-ps4-games", 39 | "test", 40 | "regular", 41 | "ps4", 42 | "games", 43 | "automatic", 44 | "app:automatic", 45 | "app:0", 46 | "regular", 47 | "0", 48 | "type:0", 49 | "type:regular", 50 | "enabled:true" 51 | ], 52 | "order": 10 53 | } -------------------------------------------------------------------------------- /functions/src/checkout/complete-checkout/index.js: -------------------------------------------------------------------------------- 1 | import { DocumentSnapshot, Firestore } from '@google-cloud/firestore' 2 | import { FieldValue } from 'firebase-admin/firestore' 3 | import { FulfillOptionsEnum, OrderData, PaymentOptionsEnum } from '../../js-docs-types.js' 4 | import { v4 as uuid } from 'uuid' 5 | import { assert } from '../../utils.js' 6 | import { create_search_index } from '../utils.js' 7 | 8 | /** 9 | * 10 | * @param {Firestore} db 11 | * @param {string} checkoutId 12 | * @param {object} client_payload 13 | */ 14 | export const complete_checkout = 15 | async (db, checkoutId, client_payload) => { 16 | 17 | const ref_order = db.collection('orders').doc(checkoutId) 18 | 19 | /**@type {DocumentSnapshot} */ 20 | const snap = await ref_order.get() 21 | const order = snap.data() 22 | 23 | assert(snap.exists, 'checkout-not-found') 24 | 25 | const gateway_id = order.payment_gateway.gateway_id 26 | const pg_config = await db.collection('payment_gateways').doc(gateway_id).get() 27 | const gateway_handler = await import(`../../gateways/${gateway_id}/index.js`) 28 | const onCheckoutComplete = gateway_handler.onCheckoutComplete 29 | const complete = await onCheckoutComplete( 30 | order, pg_config.data(), 31 | client_payload 32 | ) 33 | 34 | order.payment_gateway.on_checkout_complete = complete 35 | order.updatedAt = Date.now() 36 | order.search = create_search_index(order) 37 | 38 | await db.collection('orders').doc(order.id).set(order) 39 | 40 | return order 41 | } 42 | 43 | 44 | -------------------------------------------------------------------------------- /functions/src/checkout/create-checkout/index.js: -------------------------------------------------------------------------------- 1 | import * as functions from 'firebase-functions' 2 | const console = functions.logger 3 | 4 | import admin from 'firebase-admin' 5 | import { v4 as uuid } from 'uuid' 6 | import { 7 | DiscountData, DiscountApplicationEnum, 8 | OrderData, 9 | FulfillOptionsEnum, 10 | PaymentOptionsEnum, 11 | PaymentGatewayData, 12 | CheckoutStatusEnum} from '../../js-docs-types.js' 13 | // import { reserve_products } from '../../reserve-products/index.js' 14 | import { calculate_pricing } from '../../calculate-pricing/index.js' 15 | import { Firestore } from '@google-cloud/firestore' 16 | import { validate_checkout } from '../validate-checkout/index.js' 17 | import { create_search_index } from '../utils.js' 18 | // import { ReserveResult } from './reserve-products.js' 19 | // import { createOrder } from './gateways/paypal-standard-checkout.js' 20 | 21 | /** 22 | * calculate pricing 23 | * @param {Firestore} db 24 | * @param {OrderData} order 25 | * @returns {Promise} 26 | */ 27 | export const eval_pricing = 28 | async (db, order) => { 29 | 30 | const snaps = await db.collection('discounts') 31 | .where('enabled', '==', true) 32 | .get() 33 | 34 | /**@type {DiscountData[]} */ 35 | const discounts = snaps.docs.map( 36 | /**@type {admin.firestore.QueryDocumentSnapshot} */ 37 | snap => snap.data() 38 | ) 39 | 40 | const auto_discounts = discounts.filter( 41 | it => it.application.id===DiscountApplicationEnum.Auto.id 42 | ) 43 | const manual_discounts = discounts.filter( 44 | it => it.application.id===DiscountApplicationEnum.Manual.id 45 | ).filter( 46 | d => order.coupons.find(c => c.code===d.code)!==undefined 47 | ) 48 | 49 | const pricing = calculate_pricing( 50 | order.line_items, 51 | auto_discounts, 52 | manual_discounts, 53 | order.delivery, 54 | order?.contact?.uid 55 | ) 56 | 57 | return { 58 | ...order, 59 | pricing 60 | } 61 | } 62 | 63 | 64 | /** 65 | * @param {Firestore} db 66 | * @param {OrderData} checkout_req 67 | * @returns {Promise} 68 | */ 69 | export const create_checkout = 70 | async (db, checkout_req) => { 71 | 72 | let order = checkout_req 73 | 74 | // fetch correct data from backend. we dont trust client 75 | let t = Date.now() 76 | order = await validate_checkout( 77 | db, order 78 | ) 79 | 80 | // eval pricing with discounts 81 | order = await eval_pricing( 82 | db, order 83 | ) 84 | 85 | /**@type {OrderData} */ 86 | order = { 87 | ...order, 88 | id: order.id ?? uuid(), 89 | status : { 90 | fulfillment: FulfillOptionsEnum.draft, 91 | payment: PaymentOptionsEnum.unpaid 92 | }, 93 | createdAt: Date.now(), 94 | updatedAt: Date.now(), 95 | } 96 | 97 | const has_pending_errors = order.validation.length > 0 98 | // we had reserve errors, so publish it with pricing etc.. 99 | if(has_pending_errors) { 100 | return order 101 | } 102 | 103 | // payment gateway config, maybe cache it for 1 hour 104 | const gateway_id = order.payment_gateway.gateway_id 105 | /**@type {admin.firestore.DocumentSnapshot} */ 106 | const pg_config = await db.collection('payment_gateways').doc(gateway_id).get() 107 | 108 | const payment_gateway_handler = await import( 109 | `../../gateways/${gateway_id}/index.js` 110 | ) 111 | const { onCheckoutCreate } = payment_gateway_handler 112 | 113 | order.payment_gateway.on_checkout_create = await onCheckoutCreate( 114 | order, pg_config.data() 115 | ) 116 | order.status.checkout = CheckoutStatusEnum.created 117 | order.search = create_search_index(order) 118 | 119 | await db.collection('orders').doc(order.id).set(order) 120 | 121 | return order 122 | } -------------------------------------------------------------------------------- /functions/src/checkout/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | const router = express.Router() 3 | import { create_checkout } from './create-checkout/index.js' 4 | import { OrderData } from '../js-docs-types.js' 5 | import { complete_checkout } from './complete-checkout/index.js' 6 | import { assert } from '../utils.js' 7 | 8 | router.post('/create', 9 | async (req, res) => { 10 | try { 11 | 12 | /**@type {OrderData} */ 13 | const checkout_req = req.body 14 | 15 | assert(checkout_req, 'checkout-body-not-found') 16 | 17 | try { 18 | 19 | const checkout = await create_checkout( 20 | req.app.locals.shelf.db, checkout_req 21 | ) 22 | 23 | res.status(201).json(checkout) 24 | } catch (e) { 25 | console.error(e) 26 | throw Error('internal-checkout-error') 27 | } 28 | 29 | } catch (err) { 30 | console.error(err) 31 | res.status(400).send( 32 | { error: { message: err?.message ?? err} } 33 | ) 34 | } 35 | } 36 | ) 37 | 38 | /** 39 | * @typedef {any} CheckoutCompleteRequestBody 40 | */ 41 | 42 | // todo: chane name to confirm 43 | router.post('/:checkoutId/complete', 44 | async (req, res) => { 45 | try { 46 | 47 | /**@type {CheckoutCompleteRequestBody} */ 48 | const body = req.body 49 | const { checkoutId } = req.params 50 | 51 | // assert(checkoutId, 'pay-body-not-found') 52 | 53 | try { 54 | 55 | const order = await complete_checkout( 56 | req.app.locals.shelf.db, checkoutId, body 57 | ) 58 | 59 | // console.log('order ', order) 60 | res.status(201).json(order) 61 | 62 | } catch (e) { 63 | console.error(e) 64 | throw Error('internal-payment-error') 65 | } 66 | 67 | } catch (err) { 68 | console.error(err) 69 | res.status(400).send( 70 | { error: { message: err?.message ?? err} } 71 | ) 72 | } 73 | } 74 | ) 75 | 76 | export default router 77 | 78 | // e.post('/pricing', 79 | // async (req, res) => { 80 | // try { 81 | 82 | // /**@type {OrderData} */ 83 | // const checkout_req = req.body 84 | 85 | // assert(checkout_req, 'checkout-body-not-found') 86 | 87 | // const { 88 | // line_items, 89 | // delivery, 90 | // contact: { 91 | // uid 92 | // }, 93 | // coupons 94 | // } = checkout_req 95 | 96 | // try { 97 | 98 | // const pricing = await eval_pricing( 99 | // db, line_items, delivery, coupons, uid 100 | // ) 101 | // res.status(201).json(pricing) 102 | // } catch (e) { 103 | // console.error(e) 104 | // throw Error('internal-checkout-error') 105 | // } 106 | 107 | // } catch (err) { 108 | // console.error(err) 109 | // res.status(401).send( 110 | // { error: { message: err?.message ?? err} } 111 | // ) 112 | // } 113 | // } 114 | // ) 115 | 116 | -------------------------------------------------------------------------------- /functions/src/checkout/reserve-products/index.js: -------------------------------------------------------------------------------- 1 | import { FulfillOptionsEnum, PaymentOptionsEnum, 2 | ProductData, LineItem, ShippingData, UserData, 3 | OrderData } from '../js-docs-types.js' 4 | import { Transaction } from 'firebase-admin/firestore' 5 | import { DocumentSnapshot, Firestore } from '@google-cloud/firestore' 6 | 7 | /** 8 | * 9 | * @typedef {object} ReservesData 10 | * @property {object[]} items 11 | * @property {string} items.reserved_for 12 | * @property {string} items.id 13 | * @property {number} items.qty 14 | * @property {number} items.until 15 | * 16 | * @typedef {object} ADD_ReservesData 17 | * @property {ReservesData} reserves 18 | * 19 | * @typedef {ProductData & ADD_ReservesData} ProductWithReserves 20 | * 21 | * 22 | */ 23 | export const ProductWithReserves = {} 24 | export const ReservesData = {} 25 | 26 | 27 | /** 28 | * @typedef {object} ReserveResult 29 | * @property {number?} reserved_until 30 | * @property {string} checkoutId 31 | * @property {LineItem[]} line_items 32 | * @property {ReserveReport} report 33 | */ 34 | export const ReserveResult = {} 35 | 36 | /** 37 | * @typedef {object} ReportEntry 38 | * @property {string} id 39 | * @property {string} title 40 | * @property {'out-of-stock' | 'not-enough-stock' | 'some-stock-is-on-hold'} message 41 | * @property {number?} client_side_price 42 | * @property {number?} backend_side_price 43 | * 44 | * @typedef {ReportEntry[]} ReserveReport 45 | */ 46 | export const CheckoutReport = {} 47 | export const ReportEntry = {} 48 | 49 | /** 50 | * iterate over all reserves and compute available total by time 51 | * @param {ProductData} pd 52 | * @param {string} key 53 | */ 54 | export const compute_reserved_quantity_for_product = (pd, key) => { 55 | return pd?.reserves?.items?.reduce( 56 | (p, c) => { 57 | return p + ((Date.now() < c.until) && c.reserved_for!==key) ? c.qty : 0 58 | }, 0 59 | ) ?? 0 60 | } 61 | 62 | /** 63 | * Given backend products and corresponding front end line items, 64 | * Iterate every line-item and create a report: 65 | * 1. if stock quantity cannot be garanteed '' 66 | * @param {LineItem[]} line_items fresh list of corresponding backend products 67 | * @param {string} checkoutId 68 | * @returns {ReserveReport} 69 | */ 70 | export const compute_report = 71 | (line_items, checkoutId) => { 72 | 73 | return line_items.reduce( 74 | (p, li, idx) => { 75 | const pd = li.data 76 | const base_error = { 77 | id: li.id, 78 | title: li.data?.title 79 | } 80 | 81 | let message = undefined 82 | if(pd.qty==0) 83 | message = 'out-of-stock' 84 | else { 85 | // we have some stock 86 | const reserved_count = compute_reserved_quantity_for_product( 87 | pd, checkoutId 88 | ) 89 | const available_count = pd.qty - reserved_count 90 | if(li.qty > pd.qty) { 91 | message = 'not-enough-stock' 92 | } else if (li.qty > available_count) { 93 | message = 'some-stock-is-on-hold' 94 | } 95 | } 96 | 97 | if(message!==undefined) 98 | p.push( 99 | { 100 | ...base_error, 101 | message 102 | } 103 | ) 104 | 105 | return p 106 | }, [] 107 | ) 108 | } 109 | 110 | /** 111 | * @param {Firestore} db 112 | * @param {LineItem[]} line_items 113 | * @param {string} checkoutId 114 | * @returns {Promise} 115 | */ 116 | export const reserve_products = 117 | async (db, line_items, checkoutId) => { 118 | 119 | /**@type {import('@google-cloud/firestore').ReadWriteTransactionOptions} */ 120 | const options = { 121 | readOnly: false, 122 | maxAttempts: 5 123 | } 124 | const refs_products = line_items.map( 125 | li => db.collection('products').doc(li.id) 126 | ) 127 | 128 | return db.runTransaction( 129 | async (t) => { 130 | /**@type {DocumentSnapshot[]} */ 131 | let pd_snaps = await t.getAll(...refs_products) 132 | 133 | // - filter non-existant products (that were probably deleted) 134 | line_items = line_items.filter( 135 | (li, ix) => pd_snaps[ix].exists 136 | ) 137 | pd_snaps = pd_snaps.filter( 138 | snap => snap.exists 139 | ) 140 | 141 | // - map the correct price and data 142 | line_items = line_items.map( 143 | (li, ix) => ({ 144 | ...li, 145 | price: pd_snaps[ix].data().price, 146 | data: pd_snaps[ix].data() 147 | }) 148 | ) 149 | 150 | const report = compute_report(line_items, checkoutId) 151 | 152 | /**@type {ReserveResult} */ 153 | const base = { 154 | checkoutId, 155 | report, 156 | line_items, 157 | } 158 | 159 | if(report.length) 160 | return base 161 | 162 | // let's add a record 163 | const until = Date.now() + 1000 * 60 * 10 164 | 165 | // let's reserve temporal stocks (not reduce yet) 166 | pd_snaps.forEach( 167 | (snap, ix) => { 168 | // for each product, filter out outdated reserves 169 | // and add a new one for us 170 | /**@type {ReservesData} */ 171 | const reserves = {} 172 | const li = line_items[ix] 173 | /**@type {ProductWithReserves} */ 174 | const pd = li.data 175 | reserves.items = pd?.reserves?.items?.filter( 176 | ei => ei.until > Date.now() && ei.reserved_for!==checkoutId 177 | ) ?? [] 178 | const can_reserve = li.qty <= pd.qty - 179 | compute_reserved_quantity_for_product(pd, checkoutId) 180 | 181 | can_reserve && reserves.items.push( 182 | { 183 | id: li.id, 184 | qty: li.qty, 185 | reserved_for: checkoutId, 186 | until // 10 minutes from now 187 | } 188 | ) 189 | 190 | t.update(snap.ref, { 191 | reserves 192 | }) 193 | } 194 | ) 195 | 196 | // console.log('snaps ', snaps) 197 | return { 198 | ...base, 199 | reserved_until: until, 200 | } 201 | 202 | }, options 203 | ) 204 | 205 | } 206 | 207 | -------------------------------------------------------------------------------- /functions/src/checkout/utils.js: -------------------------------------------------------------------------------- 1 | import { OrderData } from '../js-docs-types.js' 2 | 3 | export const toUTCDateString = (utcMillis) => { 4 | const date = new Date(utcMillis); 5 | const d = date.getUTCDate(); 6 | const m = date.getUTCMonth()+1; 7 | const y = date.getUTCFullYear().toString().slice(2) 8 | return [d, m, y].join('/') 9 | } 10 | 11 | /** 12 | * @param {OrderData} data 13 | * @returns {string[]} 14 | */ 15 | export const create_search_index = (data) => { 16 | 17 | const { firstname, lastname } = data.contact 18 | const { uid, email } = data.contact 19 | const contact_vs = [] 20 | if(firstname) contact_vs.push(String(firstname).toLowerCase()) 21 | if(lastname) contact_vs.push(String(lastname).toLowerCase()) 22 | if(uid) { 23 | contact_vs.push(`uid:${uid}`) 24 | contact_vs.push(uid) 25 | } 26 | if(email) contact_vs.push(email) 27 | 28 | const readable_date = data?.updatedAt ? 29 | [toUTCDateString(data.updatedAt)] : [] 30 | 31 | const status_payment = data?.status?.payment?.name ? 32 | [ 33 | String(data.status.payment.name).toLowerCase(), 34 | String(data.status.payment.name), 35 | `payment:${String(data.status.payment.name).toLowerCase()}`, 36 | `payment:${String(data.status.payment.id)}` 37 | ] : [] 38 | const status_fulfill = data?.status?.fulfillment?.name ? 39 | [ 40 | String(data.status.fulfillment.name).toLowerCase(), 41 | String(data.status.fulfillment.name), 42 | `fulfill:${String(data.status.fulfillment.name).toLowerCase()}`, 43 | `fulfill:${String(data.status.fulfillment.id)}`, 44 | ] : [] 45 | 46 | const coupons = data.coupons ? 47 | [ 48 | ...data.coupons.map(d => `coupon:${d.code}`), 49 | ] : [] 50 | 51 | const price = data?.pricing?.total ? [ String(data?.pricing?.total) ] : [] 52 | 53 | const terms = [ 54 | data.id, 55 | data.id.split('-')[0], 56 | ...contact_vs, 57 | ...readable_date, 58 | ...status_payment, 59 | ...status_fulfill, 60 | ...price, 61 | ...coupons 62 | ] 63 | 64 | return terms 65 | } 66 | -------------------------------------------------------------------------------- /functions/src/checkout/validate-checkout/index.js: -------------------------------------------------------------------------------- 1 | import { ProductData, OrderData, ValidationEntry } from '../../js-docs-types.js' 2 | import { Firestore } from '@google-cloud/firestore' 3 | 4 | /** 5 | * @param {Firestore} db 6 | * @param {OrderData} checkout 7 | * @returns {Promise} 8 | */ 9 | export const validate_checkout = 10 | async (db, checkout) => { 11 | 12 | const refs_products = checkout.line_items.map( 13 | li => db.collection('products').doc(li.id) 14 | ) 15 | const ref_shipping = db.collection('shipping_methods').doc(checkout.delivery.id) 16 | 17 | const all_snaps = await db.getAll(...refs_products, ref_shipping) 18 | const snap_shipping = all_snaps.pop() 19 | const snaps_products = all_snaps 20 | 21 | /**@type {ValidationEntry[]} */ 22 | const errors = [] 23 | 24 | const errorWith = (id, message) => { 25 | errors.push( 26 | { id, message} 27 | ) 28 | } 29 | 30 | // assert shipping is valid 31 | if(!snap_shipping.exists) 32 | errorWith(snap_shipping.id, 'shipping-method-not-found') 33 | else { // else patch the latest 34 | checkout.delivery = snap_shipping.data() 35 | } 36 | 37 | // assert stock 38 | snaps_products.forEach( 39 | (it, ix) => { 40 | if(!it.exists) { 41 | errorWith(it.id, 'product-not-exists') 42 | } else { 43 | /**@type {ProductData} */ 44 | const pd = it.data() 45 | const li = checkout.line_items[ix] 46 | 47 | if(pd.qty==0) 48 | errorWith(it.id, 'product-out-of-stock') 49 | else if(li.qty>pd.qty) 50 | errorWith(it.id, 'product-not-enough-stock') 51 | 52 | // patch line items inline 53 | li.data = pd 54 | li.price = pd.price 55 | li.stock_reserved = 0 56 | } 57 | } 58 | ) 59 | 60 | return { 61 | ...checkout, 62 | validation: errors 63 | } 64 | 65 | } 66 | 67 | -------------------------------------------------------------------------------- /functions/src/gateways/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | const router = express.Router() 3 | import { Firestore } from 'firebase-admin/firestore' 4 | 5 | router.get( 6 | '/:gateway_id/status/:order_id', 7 | async (req, res) => { 8 | try { 9 | 10 | const { gateway_id, order_id } = req.params 11 | /**@type{{ db: Firestore }} */ 12 | const { db } = req.app.locals.shelf 13 | 14 | const snaps = await db.getAll( 15 | db.collection('orders').doc(order_id), 16 | db.collection('payment_gateways').doc(gateway_id), 17 | ) 18 | 19 | const { status } = await import(`./${gateway_id}/index.js`) 20 | const result = await status( 21 | snaps[0].data(), snaps[1].data() 22 | ) 23 | 24 | res.json(result) 25 | } catch (e) { 26 | console.log(e) 27 | res.status(501).json({ 28 | error: e 29 | }) 30 | } 31 | 32 | } 33 | 34 | ) 35 | 36 | router.post( 37 | '/:gateway_id/capture/:order_id', 38 | async (req, res) => { 39 | try { 40 | 41 | const { gateway_id, order_id } = req.params 42 | /**@type{{ db: Firestore }} */ 43 | const { db } = req.app.locals.shelf 44 | 45 | const snaps = await db.getAll( 46 | db.collection('orders').doc(order_id), 47 | db.collection('payment_gateways').doc(gateway_id), 48 | ) 49 | 50 | const { capture } = await import(`./${gateway_id}/index.js`) 51 | const result = await capture( 52 | snaps[0].data(), snaps[1].data() 53 | ) 54 | 55 | res.json(result) 56 | } catch (e) { 57 | console.log(e) 58 | res.status(501).json({ 59 | error: e 60 | }) 61 | } 62 | 63 | } 64 | 65 | ) 66 | 67 | router.post( 68 | '/:gateway_id/void/:order_id', 69 | async (req, res) => { 70 | try { 71 | 72 | const { gateway_id, order_id } = req.params 73 | /**@type{{ db: Firestore }} */ 74 | const { db } = req.app.locals.shelf 75 | 76 | const snaps = await db.getAll( 77 | db.collection('orders').doc(order_id), 78 | db.collection('payment_gateways').doc(gateway_id), 79 | ) 80 | 81 | const { void_authorized } = await import(`./${gateway_id}/index.js`) 82 | const result = await void_authorized( 83 | snaps[0].data(), snaps[1].data() 84 | ) 85 | 86 | res.json(result) 87 | } catch (e) { 88 | console.log(e) 89 | res.status(501).json({ 90 | error: e 91 | }) 92 | } 93 | 94 | } 95 | 96 | ) 97 | 98 | router.post( 99 | '/:gateway_id/refund/:order_id', 100 | async (req, res) => { 101 | try { 102 | 103 | const { gateway_id, order_id } = req.params 104 | /**@type{{ db: Firestore }} */ 105 | const { db } = req.app.locals.shelf 106 | 107 | const snaps = await db.getAll( 108 | db.collection('orders').doc(order_id), 109 | db.collection('payment_gateways').doc(gateway_id), 110 | ) 111 | 112 | const { refund } = await import(`./${gateway_id}/index.js`) 113 | const result = await refund( 114 | snaps[0].data(), snaps[1].data() 115 | ) 116 | 117 | res.json(result) 118 | } catch (e) { 119 | console.log(e) 120 | res.status(501).json({ 121 | error: e 122 | }) 123 | } 124 | 125 | } 126 | 127 | ) 128 | 129 | export default router 130 | 131 | /* 132 | import discovery from './discovery.json' assert { type: 'json' } 133 | router.get( 134 | '/list', 135 | async (req, res) => { 136 | try { 137 | let apps = discovery.apps.map( 138 | async app => { 139 | const { id } = app 140 | const { info } = await import(`./${id}/index.js`) 141 | return await info() 142 | } 143 | ) 144 | 145 | apps = await Promise.all(apps ) 146 | 147 | res.json(apps) 148 | } catch (e) { 149 | res.json({ 150 | error: e 151 | }).status(501) 152 | } 153 | 154 | } 155 | 156 | ) 157 | */ -------------------------------------------------------------------------------- /functions/src/gateways/paypal-standard-checkout.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | import { OrderData } from '../../js-docs-types.js'; 3 | 4 | const { PAYPAL_ENDPOINT, PAYPAL_CLIENT_ID, PAYPAL_APP_SECRET } = process.env; 5 | // const base = 'https://api-m.sandbox.paypal.com' 6 | // const base = 'https://api-m.paypal.com' 7 | 8 | export const create = async (amount, checkoutId) => { 9 | const accessToken = await generateAccessToken() 10 | const url = `${PAYPAL_ENDPOINT}/v2/checkout/orders` 11 | 12 | const response = await fetch( 13 | url, 14 | { 15 | method: 'post', 16 | headers: { 17 | 'Content-Type': 'application/json', 18 | Authorization: `Bearer ${accessToken}`, 19 | }, 20 | body: JSON.stringify( 21 | { 22 | intent: 'CAPTURE', 23 | purchase_units: [ 24 | { 25 | amount: { 26 | currency_code: 'ILS', 27 | value: amount, 28 | }, 29 | invoice_id: `${checkoutId}_${Date.now()}` 30 | }, 31 | ], 32 | } 33 | ), 34 | } 35 | ) 36 | 37 | return handleResponse(response) 38 | } 39 | 40 | /** 41 | * 42 | * @param {OrderData} checkout 43 | * @returns 44 | */ 45 | export const capture = async (checkout) => { 46 | const accessToken = await generateAccessToken() 47 | const url = `${PAYPAL_ENDPOINT}/v2/checkout/orders/${checkout.payment_gateway.create.id}/capture` 48 | 49 | const response = await fetch( 50 | url, 51 | { 52 | method: 'post', 53 | headers: { 54 | 'Content-Type': 'application/json', 55 | Authorization: `Bearer ${accessToken}`, 56 | }, 57 | } 58 | ) 59 | 60 | return handleResponse(response); 61 | } 62 | 63 | export const generateAccessToken = async () => { 64 | const auth = Buffer.from(PAYPAL_CLIENT_ID + ':' + PAYPAL_APP_SECRET).toString('base64') 65 | const response = await fetch( 66 | `${PAYPAL_ENDPOINT}/v1/oauth2/token`, 67 | { 68 | method: 'post', 69 | body: 'grant_type=client_credentials', 70 | headers: { 71 | Authorization: `Basic ${auth}`, 72 | }, 73 | } 74 | ) 75 | 76 | const jsonData = await handleResponse(response) 77 | return jsonData.access_token; 78 | } 79 | 80 | async function handleResponse(response) { 81 | if (response.status === 200 || response.status === 201) { 82 | return response.json() 83 | } 84 | 85 | const errorMessage = await response.text() 86 | throw new Error(errorMessage) 87 | } 88 | -------------------------------------------------------------------------------- /functions/src/gateways/paypal-standard-payments/index.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | import { 3 | CheckoutStatusEnum, OrderData, 4 | PaymentGatewayData, PaymentOptionsEnum, 5 | BackendPaymentGatewayStatus } from '../../js-docs-types.js' 6 | 7 | /** 8 | * @typedef {object} Config 9 | * @property {string} client_id 10 | * @property {string} secret_prod 11 | * @property {string} secret_test 12 | * @property {string} env 13 | * 14 | * @typedef {object} PayPalOAuthRsponse 15 | * @property {string[]} scope 16 | * @property {string} access_token 17 | * @property {string} token_type 18 | * @property {string} app_id 19 | * @property {string} nonce 20 | * @property {number} expires_in 21 | * 22 | * 23 | * @typedef {object} Auth 24 | * @property {PayPalOAuthRsponse} latest_auth_response 25 | * @property {number} expires_at 26 | */ 27 | 28 | 29 | 30 | /** 31 | * checkout is created hook 32 | * 33 | * @param {OrderData} order the order or checkout 34 | * @param {PaymentGatewayData} payment_gateway_config the config 35 | * @param {object} client_payload anything from the client 36 | * @returns 37 | */ 38 | export const onCheckoutCreate = async (order, payment_gateway_config, client_payload) => { 39 | const accessToken = await getAccessToken(payment_gateway_config) 40 | const { 41 | ENDPOINT, 42 | config: { 43 | currency_code='USD' 44 | } 45 | } = parseConfig(payment_gateway_config) 46 | 47 | const response = await fetch( 48 | `${ENDPOINT}/v2/checkout/orders`, 49 | { 50 | method: 'post', 51 | headers: { 52 | 'Content-Type': 'application/json', 53 | Authorization: `Bearer ${accessToken}`, 54 | }, 55 | body: JSON.stringify( 56 | { 57 | intent: 'AUTHORIZE', 58 | purchase_units: [ 59 | { 60 | custom_id: order.id, 61 | amount: { 62 | currency_code: currency_code, 63 | value: order.pricing.total, 64 | }, 65 | invoice_id: `${order.id}_${Date.now()}` 66 | }, 67 | ], 68 | } 69 | ), 70 | } 71 | ) 72 | 73 | if (!response.ok) 74 | throw new Error(await response.text()) 75 | 76 | return await response.json() 77 | } 78 | 79 | /** 80 | * checkout is confirmed hook 81 | * 82 | * @param {OrderData} order the order or checkout 83 | * @param {PaymentGatewayData} payment_gateway_config the config 84 | * @param {object} client_payload anything from the client 85 | * @returns 86 | */ 87 | export const onCheckoutComplete = async (order, payment_gateway_config, client_payload) => { 88 | const accessToken = await getAccessToken(payment_gateway_config) 89 | const { 90 | ENDPOINT, 91 | } = parseConfig(payment_gateway_config) 92 | 93 | const response = await fetch( 94 | `${ENDPOINT}/v2/checkout/orders/${order.payment_gateway.on_checkout_create.id}/authorize`, 95 | { 96 | method: 'post', 97 | headers: { 98 | 'Content-Type': 'application/json', 99 | Authorization: `Bearer ${accessToken}`, 100 | }, 101 | } 102 | ) 103 | if(!response.ok) { 104 | const errorMessage = await response.text() 105 | throw new Error(errorMessage) 106 | } 107 | 108 | const payload = await response.json() 109 | 110 | switch(payload.status) { 111 | case 'COMPLETED': 112 | order.status.payment = PaymentOptionsEnum.authorized 113 | order.status.checkout = CheckoutStatusEnum.complete 114 | break; 115 | case 'PAYER_ACTION_REQUIRED': 116 | order.status.checkout = CheckoutStatusEnum.requires_action 117 | break; 118 | default: 119 | order.checkout.status = CheckoutStatusEnum.failed 120 | } 121 | 122 | return payload 123 | } 124 | 125 | /** 126 | * @param {object} o paypal order api response 127 | * @returns {BackendPaymentGatewayStatus} 128 | */ 129 | const status_of_paypal_order = async (o) => { 130 | 131 | const result = { 132 | messages: [] 133 | } 134 | const purchase_unit = o.purchase_units?.[0] 135 | const authorizations = purchase_unit?.payments?.authorizations?.[0] 136 | const captures = purchase_unit?.payments?.captures?.[0] 137 | const refunds = purchase_unit?.payments?.refunds?.[0] 138 | if(refunds) { 139 | const currency_code = refunds.amount.currency_code 140 | const price = refunds.amount.value 141 | const reason = refunds?.status_details?.reason 142 | const create_time = new Date(captures?.create_time).toUTCString() 143 | const update_time = new Date(captures?.update_time).toUTCString() 144 | result.messages.push(`**${price}${currency_code}** were tried to be \`REFUNDED\` at \`${create_time}\``) 145 | result.messages.push(`The status is \`${refunds.status}\`, updated at \`${update_time}\``) 146 | if(reason) { 147 | result.messages.push(`The reason for this status is \`${reason}\``) 148 | } 149 | result.messages.push(`Refund ID is \`${refunds?.id}\`.`) 150 | 151 | } 152 | else if(captures) { 153 | const currency_code = captures.amount.currency_code 154 | const price = captures.amount.value 155 | const reason = captures?.status_details?.reason 156 | const create_time = new Date(captures?.create_time).toUTCString() 157 | const update_time = new Date(captures?.update_time).toUTCString() 158 | result.messages.push(`**${price}${currency_code}** were tried to be \`CAPTURED\` at \`${create_time}\``) 159 | result.messages.push(`The status is \`${captures.status}\`, updated at \`${update_time}\``) 160 | if(reason) { 161 | result.messages.push(`The reason for this status is \`${reason}\``) 162 | } 163 | result.messages.push(`Capture ID is \`${captures?.id}\`.`) 164 | 165 | } else if (authorizations) { 166 | const currency_code = authorizations.amount.currency_code 167 | const price = authorizations.amount.value 168 | const reason = authorizations?.status_details?.reason 169 | const create_time = new Date(authorizations?.create_time).toUTCString() 170 | const update_time = new Date(authorizations?.update_time).toUTCString() 171 | const expiration_time = new Date(authorizations?.expiration_time).toUTCString() 172 | result.messages.push(`**${price}${currency_code}** were tried to be \`AUTHORIZED\` at \`${create_time}\``) 173 | result.messages.push(`The status is \`${authorizations.status}\`, updated at \`${update_time}\``) 174 | result.messages.push(`The authorization will expire at \`${expiration_time}\``) 175 | if(reason) { 176 | result.messages.push(`The reason for this status is \`${reason}\``) 177 | } 178 | result.messages.push(`Authorization ID is \`${authorizations?.id}\`.`) 179 | 180 | } else { 181 | const currency_code = purchase_unit.amount.currency_code 182 | const price = purchase_unit.amount.value 183 | result.messages.push(`An intent to **${o.intent}** of **${price}${currency_code}** was initiated`) 184 | result.messages.push(`The status is \`${o.status}\``) 185 | } 186 | 187 | return result 188 | } 189 | 190 | /** 191 | * retrieve PayPal order 192 | * 193 | * @param {OrderData} order the order or checkout 194 | * @param {PaymentGatewayData} payment_gateway_config the config 195 | * @returns {any} 196 | */ 197 | const retrieve = async (order, payment_gateway_config) => { 198 | const accessToken = await getAccessToken(payment_gateway_config) 199 | const { 200 | ENDPOINT, 201 | } = parseConfig(payment_gateway_config) 202 | const url = `${ENDPOINT}/v2/checkout/orders/${order.payment_gateway.on_checkout_create.id}` 203 | 204 | const response = await fetch( 205 | url, 206 | { 207 | method: 'get', 208 | headers: { 209 | 'Content-Type': 'application/json', 210 | Authorization: `Bearer ${accessToken}`, 211 | }, 212 | } 213 | ) 214 | 215 | if (!response.ok) 216 | throw new Error(await response.text()) 217 | 218 | const jsonData = await response.json() 219 | return jsonData 220 | } 221 | 222 | /** 223 | * checkout is confirmed hook 224 | * 225 | * @param {OrderData} order the order or checkout 226 | * @param {PaymentGatewayData} payment_gateway_config the config 227 | * @returns {Promise} 228 | */ 229 | export const status = async (order, payment_gateway_config) => { 230 | const retrieved = await retrieve(order, payment_gateway_config) 231 | return status_of_paypal_order(retrieved) 232 | } 233 | 234 | /** 235 | * checkout is confirmed hook 236 | * 237 | * @param {OrderData} order the order or checkout 238 | * @param {PaymentGatewayData} payment_gateway_config the config 239 | * @returns 240 | */ 241 | export const capture = async (order, payment_gateway_config) => { 242 | const retrieved = await retrieve(order, payment_gateway_config) 243 | const accessToken = await getAccessToken(payment_gateway_config) 244 | const { 245 | ENDPOINT, 246 | } = parseConfig(payment_gateway_config) 247 | // console.log(retrieved) 248 | const authorization_id = retrieved?.purchase_units?.[0]?.payments?.authorizations?.[0]?.id 249 | const url = `${ENDPOINT}/v2/payments/authorizations/${authorization_id}/capture` 250 | 251 | const response = await fetch( 252 | url, 253 | { 254 | method: 'post', 255 | headers: { 256 | 'Content-Type': 'application/json', 257 | Authorization: `Bearer ${accessToken}`, 258 | }, 259 | } 260 | ) 261 | 262 | if (!response.ok) 263 | throw new Error(await response.text()) 264 | 265 | const jsonData = await response.json() 266 | const stat = await status(order, payment_gateway_config) 267 | return stat 268 | } 269 | 270 | /** 271 | * checkout is confirmed hook 272 | * 273 | * @param {OrderData} order the order or checkout 274 | * @param {PaymentGatewayData} payment_gateway_config the config 275 | * @returns 276 | */ 277 | export const void_authorized = async (order, payment_gateway_config) => { 278 | const retrieved = await retrieve(order, payment_gateway_config) 279 | const accessToken = await getAccessToken(payment_gateway_config) 280 | const { 281 | ENDPOINT, 282 | } = parseConfig(payment_gateway_config) 283 | const authorization_id = retrieved?.purchase_units?.[0]?.payments?.authorizations?.[0]?.id 284 | const url = `${ENDPOINT}/v2/payments/authorizations/${authorization_id}/void` 285 | 286 | const response = await fetch( 287 | url, 288 | { 289 | method: 'post', 290 | headers: { 291 | 'Content-Type': 'application/json', 292 | Authorization: `Bearer ${accessToken}`, 293 | }, 294 | } 295 | ) 296 | 297 | if (!response.ok) 298 | throw new Error(await response.text()) 299 | 300 | const stat = await status(order, payment_gateway_config) 301 | return stat 302 | } 303 | 304 | /** 305 | * checkout is confirmed hook 306 | * 307 | * @param {OrderData} order the order or checkout 308 | * @param {PaymentGatewayData} payment_gateway_config the config 309 | * @returns 310 | */ 311 | export const refund = async (order, payment_gateway_config) => { 312 | const retrieved = await retrieve(order, payment_gateway_config) 313 | const accessToken = await getAccessToken(payment_gateway_config) 314 | const { 315 | ENDPOINT, 316 | } = parseConfig(payment_gateway_config) 317 | const capture_id = retrieved?.purchase_units?.[0]?.payments?.captures?.[0]?.id 318 | const url = `${ENDPOINT}/v2/payments/captures/${capture_id}/refund` 319 | 320 | const response = await fetch( 321 | url, 322 | { 323 | method: 'post', 324 | headers: { 325 | 'Content-Type': 'application/json', 326 | Authorization: `Bearer ${accessToken}`, 327 | }, 328 | } 329 | ) 330 | 331 | if (!response.ok) 332 | throw new Error(await response.text()) 333 | 334 | const stat = await status(order, payment_gateway_config) 335 | return stat 336 | } 337 | 338 | /** 339 | * @type {Auth} 340 | */ 341 | let current_auth = { 342 | latest_auth_response: { 343 | }, 344 | expires_at: 0 345 | } 346 | 347 | /** 348 | * 349 | * @param {PaymentGatewayData} $config 350 | */ 351 | const parseConfig = ($config) => { 352 | /**@type {Config} */ 353 | const config = $config.attributes.reduce( 354 | (p, attr) => { 355 | p[attr.key] = attr.val 356 | return p 357 | }, {} 358 | ) 359 | const CLIENT_ID = config?.[`client_id_${config.env}`] 360 | const SECRET = config?.[`secret_${config.env}`] 361 | const ENDPOINT = config?.[`endpoint_${config.env}`] 362 | 363 | return { 364 | CLIENT_ID, SECRET, 365 | ENDPOINT, config 366 | } 367 | } 368 | 369 | /** 370 | * get access token if it has expired 371 | * 372 | * @param {PaymentGatewayData} $config 373 | * @returns 374 | */ 375 | export const getAccessToken = async ($config) => { 376 | const { 377 | CLIENT_ID, SECRET, ENDPOINT 378 | } = parseConfig($config) 379 | const auth = Buffer.from(CLIENT_ID + ':' + SECRET).toString('base64') 380 | const expired = current_auth.expires_at - Date.now() <= 10*1000 381 | 382 | if(!expired) 383 | return current_auth.latest_auth_response.access_token 384 | 385 | const response = await fetch( 386 | `${ENDPOINT}/v1/oauth2/token`, 387 | { 388 | method: 'post', 389 | body: 'grant_type=client_credentials', 390 | headers: { 391 | Authorization: `Basic ${auth}`, 392 | }, 393 | } 394 | ) 395 | 396 | if (!response.ok) 397 | throw new Error(await response.text()) 398 | 399 | const jsonData = await response.json() 400 | current_auth.latest_auth_response = jsonData 401 | return jsonData.access_token; 402 | } 403 | 404 | -------------------------------------------------------------------------------- /functions/src/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import cors from 'cors' 3 | import { OrderData, SettingsData } from './js-docs-types.js' 4 | import { assert } from './utils.js' 5 | import checkout_router from './checkout/index.js' 6 | import gateway_router from './gateways/index.js' 7 | import { Firestore } from 'firebase-admin/firestore' 8 | import { jwtVerify } from 'jose' 9 | import { TextEncoder } from 'util' 10 | // import { logger } from 'firebase-functions' 11 | // let console = logger 12 | 13 | /** 14 | * 15 | * @param {import('express').Request} req 16 | * @param {import('express').Response} res 17 | * @param {*} next 18 | */ 19 | const withAuthorization = async (req, res, next) => { 20 | const apiKey = req?.query?.apiKey 21 | const auth_header = req?.headers?.authorization 22 | /**@type {Firestore} */ 23 | const db = req.app.locals.shelf.db 24 | const settings_main_snap = await db.collection('settings').doc('main').get() 25 | /**@type {SettingsData} */ 26 | const settings_main = settings_main_snap.data() 27 | const be_apiKey = settings_main?.backend?.apiKey 28 | const be_secret = settings_main?.backend?.secret 29 | 30 | try { 31 | // if we have a secret, then we must have a JWT 32 | if(be_secret) { 33 | const encoded_be_secret = new TextEncoder().encode(be_secret) 34 | const jwt = auth_header?.split(' ').pop().trim() 35 | // console.log('req?.headers', req?.headers); 36 | // console.log('auth_header', auth_header); 37 | // console.log('jwt', jwt); 38 | try { 39 | await jwtVerify(jwt, encoded_be_secret) 40 | } catch(e) { 41 | console.log(e) 42 | throw new Error('auth/bad-jwt') 43 | } 44 | } else if (apiKey) { 45 | // otherwise, test api key 46 | assert( 47 | apiKey===be_apiKey, 48 | 'auth/bad-apikey' 49 | ) 50 | } 51 | } catch (e) { 52 | console.log(e) 53 | res.status(401).send({ 54 | message: 'unauthorized' 55 | }) 56 | return 57 | } 58 | next() 59 | } 60 | 61 | /** 62 | * @typedef {object} ShelfContext 63 | * @property {Firestore} db 64 | * 65 | * @param {ShelfContext} shelf 66 | */ 67 | export const create_backend = (shelf) => { 68 | const e = express() 69 | .use(express.json()) 70 | .use(cors({ origin: '*' })); 71 | 72 | e.locals.shelf = shelf 73 | 74 | e.get('/get', 75 | async (req, res) => { 76 | res.status(200).send('test get'); 77 | } 78 | ) 79 | 80 | e.get('/test/:col/:doc', 81 | async (req, res) => { 82 | try { 83 | const { col, doc } = req.params 84 | console.log(`test doc: ${col}/${doc}`) 85 | /**@type {Firestore} */ 86 | const db = req.app.locals.shelf.db 87 | await db.collection(col).doc(doc).update({ 88 | _a: 'a' 89 | }) 90 | res.status(200).send('test get'); 91 | 92 | } catch(e) { 93 | res.status(501).send(e) 94 | } 95 | 96 | } 97 | ) 98 | 99 | e.use( 100 | '/checkouts', 101 | checkout_router 102 | ) 103 | 104 | e.use( 105 | '/payments-gateways', 106 | withAuthorization, 107 | gateway_router 108 | ) 109 | 110 | return e 111 | } 112 | 113 | -------------------------------------------------------------------------------- /functions/src/js-docs-types.js: -------------------------------------------------------------------------------- 1 | 2 | // products 3 | 4 | /** 5 | * @typedef {object} ProductData Product scheme 6 | * @property {string[]} collections handles of collection this product belongs to 7 | * @property {string} video video media url 8 | * @property {number} price price 9 | * @property {string} handle a unique readable identifier 10 | * @property {string} parent_handle handle of parent product in case this product is a variant 11 | * @property {VariantOption[]} variants_options variants options info 12 | * @property {Object.} variants_products mapping of product variants handles to product data and variants options selection 13 | * @property {VariantOptionSelection[]} _variant_hint Internal usage, clarifies the variant projected options 14 | * @property {number} qty integer stock quantity 15 | * @property {number} compareAtPrice compare at price point 16 | * @property {Object.} discounts discounts we know were applied to this product 17 | * @property {AttributeData[]} attributes custom attributes 18 | * @property {string[]} tags list of tags , example ['genere_action', 'rated_M', ...] 19 | * @property {number} updatedAt update time millis UTC 20 | * @property {number} createdAt create time millis UTC 21 | * @property {string[]} media list of image urls 22 | * @property {string} title title of collection 23 | * @property {string[]} search simple search index 24 | * @property {string} desc description 25 | * @property {boolean} active The product is active or inactive (It won't be published in a collection) 26 | */ 27 | export const ProductData = {} 28 | 29 | /** 30 | * @typedef {string} Handle a string handle 31 | * 32 | * @typedef {object} VariantOptionSelection A tuple of option id and selected value id 33 | * @property {string} option_id Variant option id 34 | * @property {string} value_id Variant selected value id 35 | * 36 | * @typedef {object} VariantCombination A tuple of option id and selected value id 37 | * @property {VariantOptionSelection[]} selection a list of selection of option and value 38 | * @property {ProductData} product the product data associated with this variant 39 | * 40 | * @typedef {object} ProductVariantData The associated variants for the product 41 | * @property {VariantOption[]} options Variant options list 42 | * @property {Object.} variants Variant options list 43 | * 44 | * @typedef {object} TextEntity A tuple of text and unique ID 45 | * @property {string} id the id of the entity 46 | * @property {string} value the text value of the entity 47 | * 48 | * @typedef {object} VariantOption The data of a variant option 49 | * @property {string} name variant option name (for example 'Size') 50 | * @property {string} id variant option id 51 | * @property {TextEntity[]} values variant option values (for example 'Small' / 'Medium' / 'Large' ..) 52 | */ 53 | 54 | /**@type {VariantOptionSelection} */ 55 | export const VariantOptionSelection = {} 56 | 57 | /**@type {VariantCombination} */ 58 | export const VariantCombination = {} 59 | 60 | /**@type {TextEntity} */ 61 | export const TextEntity = {} 62 | 63 | /**@type {VariantOption} */ 64 | export const VariantOption = {} 65 | 66 | /**@type {Handle} */ 67 | export const Handle = {} 68 | 69 | // collections 70 | 71 | /** 72 | * @typedef {object} CollectionData Collection of products 73 | * @property {string} handle unique handle 74 | * @property {AttributeData[]} attributes custom attributes 75 | * @property {string[]} tags list of tags , example ['genere_action', 'rated_M', ...] 76 | * @property {number} updatedAt update time millis UTC 77 | * @property {number} createdAt create time millis UTC 78 | * @property {string[]} media list of image urls 79 | * @property {string} title title of collection 80 | * @property {string[]} search simple search index 81 | * @property {string} desc description 82 | * @property {boolean} active description 83 | * @property {string} _published published json url 84 | * 85 | */ 86 | export const CollectionData = {} 87 | 88 | 89 | /** 90 | * @typedef {object} CollectionExportedData Exported collection of products 91 | * @property {string} handle unique handle 92 | * @property {ProductData[]} products products in collection 93 | * @property {AttributeData[]} attributes custom attributes 94 | * @property {string[]} tags list of tags , example ['genere_action', 'rated_M', ...] 95 | * @property {number} updatedAt update time millis UTC 96 | * @property {number} createdAt create time millis UTC 97 | * @property {string[]} media list of image urls 98 | * @property {string} title title of collection 99 | * @property {string[]} search simple search index 100 | * @property {string} desc description 101 | * 102 | */ 103 | export const CollectionExportedData = {} 104 | 105 | // store-front 106 | 107 | /** 108 | * @typedef {object} StorefrontData Storefront scheme 109 | * @property {string} handle unique handle 110 | * @property {string[]} collections list of collections handles 111 | * @property {string[]} discounts list of discount codes 112 | * @property {string[]} products list of products handles 113 | * @property {string[]} posts list of posts handles 114 | * @property {string[]} shipping_methods list of shipping methods handles 115 | * @property {string} video video url 116 | * @property {string} _published exported storefront url 117 | * @property {AttributeData[]} attributes custom attributes 118 | * @property {string[]} tags list of tags , example ['genere_action', 'rated_M', ...] 119 | * @property {number} updatedAt update time millis UTC 120 | * @property {number} createdAt create time millis UTC 121 | * @property {string[]} media list of image urls 122 | * @property {string} title title of collection 123 | * @property {string[]} search simple search index 124 | * @property {string} desc description 125 | */ 126 | export const StorefrontData = {} 127 | 128 | /** 129 | * @typedef {object} StorefrontExportData Exported Storefront scheme 130 | * @property {string} handle unique handle 131 | * @property {CollectionData[]} collections list of collections 132 | * @property {DiscountData[]} discounts list of discount 133 | * @property {ProductData[]} products list of products 134 | * @property {PostData[]} posts list of posts 135 | * @property {ShippingData[]} shipping_methods list of shipping methods 136 | * @property {string} video video url 137 | * @property {AttributeData[]} attributes custom attributes 138 | * @property {string[]} tags list of tags , example ['genere_action', 'rated_M', ...] 139 | * @property {number} updatedAt update time millis UTC 140 | * @property {number} createdAt create time millis UTC 141 | * @property {string[]} media list of image urls 142 | * @property {string} title title of collection 143 | * @property {string[]} search simple search index 144 | * @property {string} desc description 145 | */ 146 | export const StorefrontExportData = {} 147 | 148 | 149 | // discounts 150 | 151 | /** 152 | * @typedef {object} DiscountData Discount scheme 153 | * @property {object} info details and filters of the discount 154 | * @property {DiscountDetails} info.details discount details 155 | * @property {Filter[]} info.filters list of discount filter 156 | * @property {DiscountApplication} application discount application (automatic and coupons) 157 | * @property {string} code a unique readable discount code 158 | * @property {boolean} enabled is the discount enabled 159 | * @property {number} order the order in which to apply the discounts stack (priority) 160 | * @property {string} _published the collection handle that contains the applicable discount products 161 | * @property {AttributeData[]} attributes custom attributes 162 | * @property {string[]} tags list of tags , example ['genere_action', 'rated_M', ...] 163 | * @property {number} updatedAt update time millis UTC 164 | * @property {number} createdAt create time millis UTC 165 | * @property {string[]} media list of image urls 166 | * @property {string} title title 167 | * @property {string[]} search simple search index 168 | * @property {string} desc description 169 | */ 170 | export const DiscountData = {} 171 | 172 | /** 173 | * @typedef {object} DiscountApplication Discounts can be manual(coupon) or automatic types, see #DiscountApplicationEnum 174 | * @property {number} id 0 = Automatic, 1 = Manual 175 | * @property {'Automatic' | 'Manual'} name printable name 176 | * @property {'automatic' | 'manual'} name2 id name 177 | * 178 | */ 179 | export const DiscountApplication = {} 180 | 181 | /** 182 | * 183 | * @typedef {object} Filter Discount filter scheme 184 | * @property {FilterMeta} meta meta data related to identifying the filter 185 | * @property {string[] | object} value the filter params 186 | * @property {number=} value.from 187 | * @property {number} value.to 188 | * 189 | */ 190 | export const Filter = {} 191 | 192 | /** 193 | * @typedef {object} FilterMeta Filter meta data, see #FilterMetaEnum 194 | * @property {number} id unique identifier for filter type 195 | * @property {'product' | 'order'} type product or order filter 196 | * @property {'p-in-collections' | 'p-not-in-collections' | 'p-in-handles' | 'p-not-in-handles' | 'p-in-tags' | 'p-not-in-tags' | 'p-all' | 'p_in_price_range' | 'o-subtotal-in-range' | 'o-items-count-in-range' | 'o-date-in-range' | 'o_has_customer'} op operation name id 197 | * @property {string} name printable name 198 | * 199 | */ 200 | export const FilterMeta = {} 201 | 202 | /** 203 | * @typedef {object} DiscountDetails The details of how to apply a discount. The type of discount and it's params 204 | * @property {DiscountMeta} meta metadata to identify the type of discount 205 | * @property {RegularDiscountExtra|OrderDiscountExtra|BulkDiscountExtra|BuyXGetYDiscountExtra|BundleDiscountExtra} extra extra parameters of the specific discount type 206 | */ 207 | export const DiscountDetails = {} 208 | 209 | 210 | /** 211 | * @typedef {object} DiscountMeta Discount meta data, see #DiscountMetaEnum 212 | * @property {0 | 1 | 2 | 3} id unique identifier of discount type (bulk, regular, order) 213 | * @property {'regular' | 'bulk' | 'buy_x_get_y' | 'order' | 'bundle'} type textual identifier 214 | * @property {string} name printable name 215 | * 216 | */ 217 | export const DiscountMeta = {} 218 | 219 | /** 220 | * @typedef {object} RegularDiscountExtra Parameters of a regular discount 221 | * @property {number} fixed fixed price addition 222 | * @property {number} percent percents off 223 | */ 224 | export const RegularDiscountExtra = {} 225 | 226 | /** 227 | * @typedef {object} OrderDiscountExtra Parameters of order discount 228 | * @property {number} fixed fixed price addition 229 | * @property {number} percent percents off 230 | * @property {boolean} free_shipping do we have free shipping ? 231 | */ 232 | export const OrderDiscountExtra = {} 233 | 234 | /** 235 | * @typedef {object} BulkDiscountExtra Parameters of bulk discount 236 | * @property {number} fixed fixed price addition 237 | * @property {number} percent percents off 238 | * @property {number} qty the integer quantity for which the discount is given 239 | * @property {boolean} recursive apply the discount as many times as possible 240 | */ 241 | export const BulkDiscountExtra = {} 242 | 243 | /** 244 | * @typedef {object} BuyXGetYDiscountExtra Parameters of bulk discount 245 | * @property {number} fixed fixed price addition for the given Y products 246 | * @property {number} percent percents off for the given Y products 247 | * @property {number} qty_x the integer quantity of BUY X 248 | * @property {number} qty_y the integer quantity of BUY Y 249 | * @property {Filter[]} filters_y The filters for what a customer gets (Y) 250 | * @property {boolean} [recursive] apply the discount as many times as possible 251 | */ 252 | export const BuyXGetYDiscountExtra = {} 253 | 254 | /** 255 | * @typedef {object} BundleDiscountExtra Parameters of bulk discount 256 | * @property {number} fixed fixed price addition for the given Y products 257 | * @property {number} percent percents off for the given Y products 258 | * @property {boolean} [recursive] apply the discount as many times as possible 259 | */ 260 | export const BundleDiscountExtra = {} 261 | 262 | 263 | /** 264 | * @enum {DiscountApplication} 265 | */ 266 | export const DiscountApplicationEnum = { 267 | Auto: { id: 0, name: 'Automatic', name2: 'automatic'}, 268 | Manual: { id: 1, name: 'Manual', name2: 'manual'}, 269 | } 270 | 271 | /** 272 | * @enum {FilterMeta} 273 | */ 274 | export const FilterMetaEnum = { 275 | p_in_collections: { 276 | id: 0, type:'product', 277 | op: 'p-in-collections', 278 | name: 'Product In Collection' 279 | }, 280 | p_not_in_collections: { 281 | id: 1, type:'product', 282 | op: 'p-not-in-collections', 283 | name: 'Product not in Collection' 284 | }, 285 | p_in_handles: { 286 | id: 2, type:'product', 287 | op: 'p-in-handles', 288 | name: 'Product has ID' 289 | }, 290 | p_not_in_handles: { 291 | id: 3, type:'product', 292 | op: 'p-not-in-handles', 293 | name: 'Product excludes ID' 294 | }, 295 | p_in_tags: { 296 | id: 4, type:'product', 297 | op: 'p-in-tags', 298 | name: 'Product has Tag' 299 | }, 300 | p_not_in_tags: { 301 | id: 5, type:'product', 302 | op: 'p-not-in-tags', 303 | name: 'Product excludes Tag' 304 | }, 305 | p_all: { 306 | id: 6, type:'product', 307 | op: 'p-all', name: 'All Products' 308 | }, 309 | p_in_price_range: { 310 | id: 7, type:'product', 311 | op: 'p_in_price_range', 312 | name: 'Product in Price range' 313 | }, 314 | o_subtotal_in_range: { 315 | id: 100, type:'order', 316 | op: 'o-subtotal-in-range', 317 | name: 'Order subtotal in range' 318 | }, 319 | o_items_count_in_range: { 320 | id: 101, type:'order', 321 | op: 'o-items-count-in-range', 322 | name: 'Order items count in range' 323 | }, 324 | o_date_in_range: { 325 | id: 102, type:'order', 326 | op: 'o-date-in-range', 327 | name: 'Order in dates' 328 | }, 329 | o_has_customer: { 330 | id: 103, type:'order', 331 | op: 'o-has-customer', 332 | name: 'Order has Customers' 333 | }, 334 | } 335 | 336 | /** 337 | * @enum {DiscountMeta} 338 | */ 339 | export const DiscountMetaEnum = { 340 | regular: { 341 | id: 0, 342 | type: 'regular', 343 | name : 'Regular Discount', 344 | }, 345 | bulk: { 346 | id: 1, type: 'bulk', 347 | name : 'Bulk Discount', 348 | }, 349 | buy_x_get_y: { 350 | id: 2, type: 'buy_x_get_y' , 351 | name : 'Buy X Get Y', 352 | }, 353 | order: { 354 | id: 3, type: 'order', 355 | name : 'Order Discount', 356 | }, 357 | bundle: { 358 | id: 4, type: 'bundle', 359 | name : 'Bundle Discount', 360 | }, 361 | } 362 | 363 | 364 | // images 365 | 366 | /** 367 | * @typedef {object} ImageData Image scheme 368 | * @property {string} provider storage provider 369 | * @property {string} handle unique handle 370 | * @property {string} name name 371 | * @property {string} url it's url 372 | * @property {string[]} usage which collections referenced this image 373 | * @property {string[]} search simple searcg index 374 | * @property {number} updatedAt update time millis UTC 375 | */ 376 | export const ImageData = {} 377 | 378 | /** 379 | * @typedef {object} AttributeData Attributes are key-value strings, that you can attach to almost any document. They allow you to customize things 380 | * @property {string} key key 381 | * @property {string} val value 382 | */ 383 | /**@type {AttributeData} */ 384 | export const AttributeData = {} 385 | 386 | // tags 387 | 388 | /** 389 | * @typedef {object} TagData A Tag is composed of a key and many values. This is helpful for creating client side filtering 390 | * @property {string[]} values list of values 391 | * @property {string} name the key 392 | * @property {string} desc rich description 393 | * @property {string[]} search simple search index 394 | * @property {number} createdAt create time millis UTC 395 | * @property {number} updatedAt update time millis UTC 396 | */ 397 | export const TagData = {} 398 | 399 | // users 400 | 401 | /** 402 | * @typedef {object} Address Addresses are used in customer info and orders 403 | * @property {string} firstname first name of recipient 404 | * @property {string} lastname last name of recipient 405 | * @property {string} company optional company name of recipient 406 | * @property {string} street1 street address 1 407 | * @property {string} street2 street address 2 408 | * @property {string} city city 409 | * @property {string} country country 410 | * @property {string} state state 411 | * @property {string} zip_code zip code 412 | * @property {string} postal_code postal code 413 | */ 414 | export const Address = {} 415 | 416 | /** 417 | * @typedef {object} UserData Customer info 418 | * @property {string} firstname first name 419 | * @property {string} lastname last name 420 | * @property {string} email email address 421 | * @property {string} phone_number phone number 422 | * @property {Address} address address info 423 | * @property {string} uid firebase authentication user id 424 | * @property {string[]} tags list of tags , example ['likes_games', 'subscribed_false', ...] 425 | * @property {string[]} search simple search index 426 | * @property {number} createdAt create time millis UTC 427 | * @property {number} updatedAt update time millis UTC 428 | */ 429 | export const UserData = {} 430 | 431 | 432 | // orders 433 | 434 | /** 435 | * 436 | * @typedef {object} OrderData Order scheme. 437 | * @property {string} id unique order id 438 | * @property {object} status status of checkout, fulfillment and payment 439 | * @property {CheckoutStatusOptions} status.checkout checkout status 440 | * @property {PaymentOptions} status.payment payment status 441 | * @property {FulfillOptions} status.fulfillment fulfillment status 442 | * @property {object} contact buyer info 443 | * @property {string} contact.phone buyer's phone number 444 | * @property {string} contact.email buyer's email 445 | * @property {string} contact.uid buyer's firebase authentication user id (optional) 446 | * @property {Address} address shipping address info 447 | * @property {LineItem[]} line_items line items is a list of the purchased products 448 | * @property {string} notes notes for the order 449 | * @property {ShippingData} delivery shipping method info 450 | * @property {DiscountData[]} coupons a list of manual coupons 451 | * @property {PricingData} pricing pricing information 452 | * @property {ValidationEntry[]} [validation] in case the order went through validation 453 | * @property {OrderPaymentGatewayData} payment_gateway payment gateway info and status 454 | * @property {AttributeData[]} attributes custom attributes 455 | * @property {string[]} tags list of tags , example ['genere_action', 'rated_M', ...] 456 | * @property {number} updatedAt update time millis UTC 457 | * @property {number} createdAt create time millis UTC 458 | * @property {string[]} search simple search index 459 | */ 460 | export const OrderData = {} 461 | 462 | 463 | /** 464 | * @typedef {object} FulfillOptions Fulfillment options encapsulate the current state, see #FulfillOptionsEnum 465 | * @property {number} id 0-draft, 1-processing, 2-shipped, 3-fulfilled, 4-cancelled 466 | * @property {string} name readable/printable name 467 | * @property {'draft' | 'processing' | 'shipped' | 'fulfilled' | 'cancelled'} name2 unique name (like id) 468 | */ 469 | export const FulfillOptions = {} 470 | 471 | /** 472 | * @typedef {object} PaymentOptions Payment options encapsulate the current state, see #PaymentOptionsEnum 473 | * @property {number} id 0-unpaid, 1-captured, 2-requires_auth, 3-requires_auth, 4-voided, 5-failed, 6-partially_paid, 7-refunded 474 | * @property {string} name readable/printable name 475 | * @property {'unpaid' | 'authorized' | 'captured' | 'requires_auth' | 'voided' | 'partially_paid' | 'refunded' | 'partially_refunded'} name2 unique name (like id) 476 | */ 477 | export const PaymentOptions = {} 478 | 479 | /** 480 | * @typedef {object} CheckoutStatusOptions Checkout status encapsulate the current state, see #CheckoutStatusEnum 481 | * @property {number} id 0-created, 1-requires_action, 2-failed, 3-complete 482 | * @property {string} name readable/printable name 483 | * @property {'created' | 'requires_action' | 'failed' | 'complete'} name2 unique name (like id) 484 | */ 485 | 486 | /** 487 | * @enum {CheckoutStatusOptions} 488 | */ 489 | export const CheckoutStatusEnum = { 490 | created: { 491 | id: 0, name2: 'created', name: 'Created' 492 | }, 493 | requires_action: { 494 | id: 1, name2: 'requires_action', name: 'Requires Action' 495 | }, 496 | failed: { 497 | id: 2, name2: 'failed', name: 'Failed' 498 | }, 499 | complete: { 500 | id: 3, name2: 'complete', name: 'Complete' 501 | }, 502 | } 503 | 504 | /** 505 | * @enum {FulfillOptions} 506 | */ 507 | export const FulfillOptionsEnum = { 508 | draft: { 509 | id: 0, name2: 'draft', name: 'Draft' 510 | }, 511 | processing: { 512 | id: 1, name2: 'processing' ,name: 'Processing (Stock Reserved)' 513 | }, 514 | shipped: { 515 | id: 2, name2: 'shipped' ,name: 'Shipped' 516 | }, 517 | fulfilled: { 518 | id: 3, name2: 'fulfilled', name: 'Fulfilled' 519 | }, 520 | cancelled: { 521 | id: 4, name2: 'cancelled', name: 'Cancelled (Stock returned)' 522 | } 523 | } 524 | 525 | /** 526 | * @enum {PaymentOptions} 527 | */ 528 | export const PaymentOptionsEnum = { 529 | unpaid: { 530 | id: 0, name: 'Unpaid', name2: 'unpaid' 531 | }, 532 | authorized: { 533 | id: 1, name: 'Authorized', name2: 'authorized' 534 | }, 535 | captured: { 536 | id: 2, name: 'Captured', name2: 'captured' 537 | }, 538 | requires_auth: { 539 | id: 3, name: 'Requires Authentication', name2: 'requires_auth' 540 | }, 541 | voided: { 542 | id: 4, name: 'Voided', name2: 'voided' 543 | }, 544 | failed: { 545 | id: 5, name: 'Failed', name2: 'failed' 546 | }, 547 | partially_paid: { 548 | id: 6, name: 'Partially paid', name2: 'partially_paid' 549 | }, 550 | refunded: { 551 | id: 7, name: 'Refunded', name2: 'refunded' 552 | }, 553 | partially_refunded: { 554 | id: 8, name: 'Partially Refunded', name2: 'partially_refunded' 555 | }, 556 | } 557 | 558 | /** 559 | * @typedef {object} PricingData Pricing object exaplins how the pricing of an order was calculated given a stack of automatic discounts, coupons, line items and shipping method 560 | * @property {EvoEntry[]} evo explanation of how discounts stack and change pricing 561 | * @property {ShippingData} shipping_method selected shipping method 562 | * @property {number} subtotal_undiscounted subtotal of items price before discounts 563 | * @property {number} subtotal_discount sum of all discounts at all stages 564 | * @property {number} subtotal subtotal_undiscounted - subtotal_discount 565 | * @property {number} total subtotal + shipping 566 | * @property {number} total_quantity how many items were discounted 567 | * @property {string} uid firebase authentication user id 568 | * @property {DiscountError[]} errors 569 | */ 570 | export const PricingData = {} 571 | 572 | 573 | /** 574 | * @typedef {object} LineItem A line item is a product, that appeared in an order 575 | * @property {string} id id or handle of product 576 | * @property {number} price it's known price 577 | * @property {number} qty integer quantity of how many such products were bought 578 | * @property {number} stock_reserved used by order to indicate it has reserved stock and it's amount 579 | * @property {ProductData} data (optional) the product data 580 | **/ 581 | export const LineItem = {} 582 | 583 | /** 584 | * @typedef {object} EvoEntry Explain how a specific discount was used to discount line items 585 | * @property {DiscountData} discount discount data 586 | * @property {string} discount_code the discount code 587 | * @property {number} total_discount the amount of money that was discounted by this discount 588 | * @property {number} quantity_undiscounted how many items are left to discount 589 | * @property {number} quantity_discounted how many items were discounted now 590 | * @property {number} subtotal running subtotal without shipping 591 | * @property {number} total running total 592 | * @property {LineItem[]} line_items available line items after discount 593 | * 594 | * @typedef {object} DiscountError 595 | * @property {string} discount_code 596 | * @property {string} message 597 | */ 598 | export const EvoEntry = {} 599 | 600 | /** 601 | * @typedef {object} ValidationEntry checkouts or draft orders might be validated in automatic systems 602 | * @property {string} id 603 | * @property {string} title 604 | * @property {'out-of-stock' | 'not-enough-stock' | 'some-stock-is-on-hold'} message 605 | */ 606 | 607 | /**@type {ValidationEntry} */ 608 | export const ValidationEntry = {} 609 | 610 | /** 611 | * @typedef {object} OrderPaymentGatewayData How did the order interacted with a payment gateway ? 612 | * @property {string} gateway_id the payment gateway identifier 613 | * @property {object} on_checkout_create result of gateway at checkout creation 614 | * @property {object} on_checkout_complete result of gateway at checkout completion 615 | * @property {object} latest_status latest status of payment 616 | */ 617 | /**@type {OrderPaymentGatewayData} */ 618 | export const OrderPaymentGateway = {} 619 | 620 | 621 | 622 | // posts 623 | 624 | /** 625 | * @typedef {object} PostData Post data is a rich text and media resource. Can be used for writing blog posts with markdown and html 626 | * @property {string} handle unique handle 627 | * @property {string[]} tags list of tags , example ['genere_action', 'rated_M', ...] 628 | * @property {number} updatedAt update time millis UTC 629 | * @property {number} createdAt create time millis UTC 630 | * @property {string[]} media list of image urls 631 | * @property {string} title title of collection 632 | * @property {string[]} search simple search index 633 | * @property {string} text rich text 634 | */ 635 | export const PostData = {} 636 | 637 | 638 | // shipping methods 639 | 640 | /** 641 | * @typedef {object} ShippingData Shipping method info is used to describe the shipping options you offer 642 | * @property {string} id unique identifier 643 | * @property {number} price the price of the shipping method 644 | * @property {string} name name or title of the method 645 | * @property {string} desc description of the method 646 | * @property {AttributeData[]} attributes custom attributes 647 | * @property {string[]} tags list of tags , example ['genere_action', 'rated_M', ...] 648 | * @property {number} updatedAt update time millis UTC 649 | * @property {number} createdAt create time millis UTC 650 | * @property {string[]} media list of image urls 651 | * @property {string[]} search simple search index 652 | * @property {boolean} active is the shipping method active ? 653 | */ 654 | export const ShippingData = {} 655 | 656 | // payment gateway config 657 | 658 | /** 659 | * @typedef {object} PaymentGatewayData Payment gateway configs which you can load at backend to define keys, secrets and specific behaviours 660 | * @property {string} title title of the gateway 661 | * @property {number} id it's identifier 662 | * @property {number} gateway_id same as id 663 | * @property {number} updatedAt update time millis UTC 664 | * @property {number} createdAt create time millis UTC 665 | * @property {AttributeData[]} attributes custom attributes 666 | */ 667 | export const PaymentGatewayData = {} 668 | 669 | // main settings 670 | 671 | /** 672 | * @enum {StorageType} 673 | */ 674 | export const StorageTypeEnum = { 675 | google_cloud_storage: 'google_cloud_storage', 676 | cloudflare_r2: 'cloudflare_r2', 677 | aws_s3: 'aws_s3', 678 | compatible_s3: 'compatible_s3', 679 | } 680 | 681 | /** 682 | * @typedef {'google_cloud_storage' | 'cloudflare_r2' | 'aws_s3' | 'compatible_s3'} StorageType internal id of provider 683 | * 684 | * @typedef {object} StorageSettings 685 | * @property {Object.} items storage configurations 686 | * @property {StorageType} selected selected configuration for uploads 687 | * 688 | * @typedef {object} FirebaseStorageSettings 689 | * @property {string} custom_domain Use a CDN domain/subdomain to rewrite the presented files 690 | * 691 | * @typedef {object} S3CompatibleStorageSettings 692 | * @property {string} bucket The bucket name of the storage 693 | * @property {string} access_key The access key 694 | * @property {string} secret The secret 695 | * @property {string} [endpoint] The S3 Endpoint 696 | * @property {boolean} force_path_style Whether to force path style URLs for S3 objects (e.g., https://s3.amazonaws.com// instead of https://.s3.amazonaws.com/ 697 | * @property {string} custom_domain Use a CDN domain/subdomain to rewrite the presented files 698 | * 699 | * @typedef {object} BackendSettings 700 | * @property {string} url permanant url of shelf app at backend 701 | * @property {string} apiKey api key security. Will be sent with every request to backend 702 | * @property {string} secret (soon) a secret that will be used to sign requests for improved 703 | * 704 | * @typedef {object} SettingsData Shelf's settings 705 | * @property {BackendSettings} backend backend settings 706 | * @property {StorageSettings} storage storage settings 707 | * @property {number} updatedAt update time millis UTC 708 | * @property {number} createdAt create time millis UTC 709 | * @property {AttributeData[]} attributes custom attributes 710 | */ 711 | export const SettingsData = {} 712 | 713 | /**@type {CloudflareR2Settings} */ 714 | export const CloudflareR2Settings = {} 715 | /**@type {S3CompatibleStorageSettings} */ 716 | export const S3CompatibleStorageSettings = {} 717 | /**@type {StorageSettings} */ 718 | export const StorageSettings = {} 719 | /**@type {FirebaseStorageSettings} */ 720 | export const FirebaseStorageSettings = {} 721 | 722 | // notifications 723 | /** 724 | * @typedef {object} NotificationData Notications are used to describe admin notifications, you can write these documents from backend to deliver notifications about your business 725 | * @property {number} updatedAt update time millis UTC 726 | * @property {string} message message of notification, can be markdown, markup or plain text 727 | * @property {string[]} search (required) search index 728 | * @property {string} [author] author of the notification 729 | * @property {NotificationAction[]} actions list of actions 730 | */ 731 | export const NotificationData = {} 732 | /**@type {NotificationActionType} */ 733 | 734 | /** 735 | * @typedef {object} NotificationAction each notification may have an actionable item associated with it. For example, clicking an order notification will route to the order page at Shelf 736 | * @property {string} name name of the action 737 | * @property {NotificationActionType} type the type of action 738 | * @property {NotificationActionRouteParams | NotificationActionUrlParams} params extra params for the actions type 739 | * 740 | */ 741 | 742 | /**@type {NotificationAction} */ 743 | export const NotificationAction = {} 744 | 745 | /** 746 | * @typedef {'route' | 'url'} NotificationActionType 'route' means routing inside shelfm 'url' is linking to a url 747 | */ 748 | /**@type {NotificationData} */ 749 | export const NotificationActionType = {} 750 | 751 | 752 | /** 753 | * @typedef {object} NotificationActionRouteParams route inside shelf action params 754 | * @property {string} collection which collection 755 | * @property {string} document which document 756 | */ 757 | /**@type {NotificationActionRouteParams} */ 758 | export const NotificationActionRouteParams = {} 759 | 760 | /** 761 | * @typedef {object} NotificationActionUrlParams Action params for actions of type 'url' 762 | * @property {boolean} new_window open the url in new window 763 | * @property {string} url the url to open 764 | */ 765 | /**@type {NotificationActionUrlParams} */ 766 | export const NotificationActionUrlParams = {} 767 | 768 | // stats 769 | 770 | /** 771 | * @typedef {object} MovingStatsProduct 772 | * @property {string} handle product handle 773 | * @property {string} title product title 774 | * @property {number} val count of product 775 | */ 776 | export const MovingStatsProduct = {} 777 | 778 | /** 779 | * @typedef {object} MovingStatsDay 780 | * @property {number} total total income in day 781 | * @property {number} orders total orders in day 782 | * @property {number} orders total orders in day 783 | * @property {number} day start of day in UTC millis 784 | * @property {Object.} discounts a map between discount code to count 785 | * @property {Object.} collections a map between collection handle to count 786 | * @property {Object.} tags a map between tag name to count 787 | * @property {Object.} products a map between product handle to product stat data 788 | */ 789 | export const MovingStatsDay = {} 790 | 791 | /** 792 | * @typedef {object} MovingStatsInfo 793 | * @property {number} maxOrderTime latest order time, that was recorded (used for optimization) 794 | * @property {Object.} days map start of days to stats 795 | */ 796 | export const MovingStatsInfo = {} 797 | 798 | 799 | /** 800 | * @typedef {object} MovingStatsData 801 | * @property {MovingStatsInfo} info 802 | * @property {number} fromDay from start of day (millis) 803 | * @property {number} toDay to an end of day (millis) 804 | * @property {number} updatedAt when updated (millis) 805 | */ 806 | export const MovingStatsData = {} 807 | 808 | /** 809 | * @typedef {object} CartData A client cart, used for line items 810 | * @property {string} id 811 | * @property {number} createdAt 812 | * @property {LineItem[]} line_items 813 | */ 814 | export const CartData = {} 815 | 816 | /** 817 | * @typedef {object} ShelfAdminConfig 818 | * @property {string} apiKey 819 | * @property {string} projectId 820 | * @property {string} storageBucket 821 | * @property {string} backend 822 | */ 823 | 824 | // backend 825 | 826 | /** 827 | * @typedef {object} BackendPaymentGatewayStatus 828 | * @property {string[]} messages array of message, support markdown, html and plain text 829 | */ 830 | /**@type {BackendPaymentGatewayStatus} */ 831 | export const BackendPaymentGatewayStatus = {} 832 | 833 | -------------------------------------------------------------------------------- /functions/src/utils.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | export const assert = (condition, message='unknown') => { 4 | if(!Boolean(condition)) 5 | throw Error(message) 6 | } 7 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shelf-cms/shelf-slim-backend/2b7e7e98b7317554435b4579543ab28ef9709e8c/public/logo.png -------------------------------------------------------------------------------- /storage.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service firebase.storage { 3 | match /b/{bucket}/o { 4 | match /{allPaths=**} { 5 | allow read: if true; 6 | allow write: if request.auth.uid in ['YOUR_UID_HERE']; 7 | } 8 | } 9 | } --------------------------------------------------------------------------------