backend-manager 5.0.104 → 5.0.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CLAUDE.md +114 -24
  3. package/README.md +42 -1
  4. package/TODO-PAYMENT-v2.md +5 -2
  5. package/package.json +1 -1
  6. package/src/cli/commands/deploy.js +2 -4
  7. package/src/cli/commands/emulator.js +30 -1
  8. package/src/cli/commands/test.js +33 -2
  9. package/src/manager/events/firestore/payments-webhooks/on-write.js +17 -3
  10. package/src/manager/events/firestore/payments-webhooks/transitions/index.js +6 -0
  11. package/src/manager/index.js +5 -2
  12. package/src/manager/libraries/payment/processors/paypal.js +588 -0
  13. package/src/manager/libraries/{payment-processors → payment/processors}/stripe.js +87 -18
  14. package/src/manager/libraries/{payment-processors → payment/processors}/test.js +15 -8
  15. package/src/manager/routes/payments/cancel/processors/paypal.js +30 -0
  16. package/src/manager/routes/payments/cancel/processors/stripe.js +1 -1
  17. package/src/manager/routes/payments/cancel/processors/test.js +4 -6
  18. package/src/manager/routes/payments/intent/post.js +3 -3
  19. package/src/manager/routes/payments/intent/processors/paypal.js +150 -0
  20. package/src/manager/routes/payments/intent/processors/stripe.js +3 -5
  21. package/src/manager/routes/payments/intent/processors/test.js +12 -13
  22. package/src/manager/routes/payments/portal/processors/paypal.js +24 -0
  23. package/src/manager/routes/payments/portal/processors/stripe.js +1 -1
  24. package/src/manager/routes/payments/refund/post.js +85 -0
  25. package/src/manager/routes/payments/refund/processors/paypal.js +117 -0
  26. package/src/manager/routes/payments/refund/processors/stripe.js +103 -0
  27. package/src/manager/routes/payments/refund/processors/test.js +98 -0
  28. package/src/manager/routes/payments/trial-eligibility/get.js +29 -0
  29. package/src/manager/routes/payments/webhook/processors/paypal.js +137 -0
  30. package/src/manager/schemas/payments/refund/post.js +18 -0
  31. package/src/manager/schemas/payments/trial-eligibility/get.js +5 -0
  32. package/src/test/test-accounts.js +46 -0
  33. package/templates/backend-manager-config.json +20 -24
  34. package/templates/firestore.rules +0 -2
  35. package/test/events/payments/journey-payments-cancel.js +3 -3
  36. package/test/events/payments/journey-payments-failure.js +1 -1
  37. package/test/events/payments/journey-payments-one-time.js +1 -1
  38. package/test/events/payments/journey-payments-plan-change.js +4 -4
  39. package/test/events/payments/journey-payments-suspend.js +3 -3
  40. package/test/events/payments/journey-payments-trial.js +2 -2
  41. package/test/fixtures/paypal/order-approved.json +62 -0
  42. package/test/fixtures/paypal/order-completed.json +110 -0
  43. package/test/fixtures/paypal/subscription-active.json +76 -0
  44. package/test/fixtures/paypal/subscription-cancelled.json +50 -0
  45. package/test/fixtures/paypal/subscription-suspended.json +65 -0
  46. package/test/helpers/payment/paypal/parse-webhook.js +539 -0
  47. package/test/helpers/payment/paypal/to-unified-one-time.js +382 -0
  48. package/test/helpers/payment/paypal/to-unified-subscription.js +820 -0
  49. package/test/helpers/{stripe-parse-webhook.js → payment/stripe/parse-webhook.js} +4 -4
  50. package/test/helpers/{stripe-to-unified-one-time.js → payment/stripe/to-unified-one-time.js} +8 -6
  51. package/test/helpers/{stripe-to-unified.js → payment/stripe/to-unified-subscription.js} +40 -33
  52. package/test/routes/payments/refund.js +174 -0
  53. package/test/routes/payments/trial-eligibility.js +71 -0
  54. package/src/manager/libraries/payment-processors/resolve-price-id.js +0 -19
  55. /package/src/manager/libraries/{payment-processors → payment}/order-id.js +0 -0
package/CHANGELOG.md CHANGED
@@ -14,6 +14,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ # [5.0.104] - 2026-03-02
18
+ ### Added
19
+ - `POST /payments/cancel`: cancels subscription at period end via processor abstraction (Stripe sets `cancel_at_period_end=true`; test processor writes webhook directly into the Firestore pipeline).
20
+ - `POST /payments/portal`: creates Stripe Billing Portal session with cancellation disabled (users must use the cancel endpoint).
21
+ - Payment transition pipeline: `transitions/index.js` detects all subscription state changes (new-subscription, payment-failed, payment-recovered, cancellation-requested, subscription-cancelled, plan-changed) and one-time transitions (purchase-completed, purchase-failed). Handlers fire-and-forget, send transactional emails.
22
+ - Payment analytics: `analytics.js` tracks GA4 payment events for all transitions (non-blocking, skipped in tests).
23
+ - Shared payment processor libraries: `payment/processors/stripe.js` (toUnifiedSubscription, toUnifiedOneTime, resolveCustomer, resolvePriceId, fetchResource), `payment/processors/paypal.js`, `payment/processors/test.js`, `payment/order-id.js`.
24
+ - `Email` library (`libraries/email.js`): shared transactional email via SendGrid, used by transition handlers and admin routes.
25
+ - `infer-contact.js` library: infers user name from payment processor data, auto-fills on first purchase.
26
+ - `routes/user/data-request/` (get/post/delete): GDPR data request endpoints.
27
+ - `cron/daily/data-requests.js`: daily cron to process pending GDPR data requests.
28
+ - CLI commands: `auth` (get/list/delete/set-claims), `firestore` (get/set/query/delete), `firebase-init`, `emulator` (renamed from `emulators`).
29
+ - `setup-tests/firestore-indexes-required.js`: validates required Firestore indexes exist before tests run.
30
+ - Comprehensive payment test suite: journey tests for one-time purchase, one-time failure, payment failure, plan change, cancel endpoint; route validation tests for cancel and portal; unit tests for `toUnifiedOneTime()`, `stripe-parse-webhook`, `infer-contact`, `email`; real Stripe CLI fixtures.
31
+ - Dedicated isolated test accounts for every mutating payment test (no shared state between tests).
32
+
33
+ ### Changed
34
+ - `admin/email/post.js`, `general/email/post.js`: refactored to delegate to shared Email library (~400 lines removed from each).
35
+ - `marketing/contact/post.js`, `api/general/add-marketing-contact.js`: delegate to infer-contact + marketing library.
36
+ - `user/signup/post.js`: rewritten with new middleware pattern.
37
+ - `auth/on-create.js`: simplified, inline logic moved to middleware.
38
+ - `api/admin/send-email.js`: removed `ensureUnique` and SendGrid contact name lookup (handled by Email library).
39
+ - All admin routes: middleware pattern cleanup.
40
+ - `config.payment.products` now supports `type: 'one-time'` products with `prices.once` key.
41
+ - Test runner: improved discovery, filtering, and output formatting.
42
+
43
+ ### Removed
44
+ - `src/manager/libraries/stripe.js`, `src/manager/libraries/test.js`: replaced by `payment/processors/` shared libs.
45
+ - `REFACTOR-BEM-API.md`, `REFACTOR-MIDDLEWARE.md`, `REFACTOR-PAYMENT.md`: work completed, files deleted.
46
+ - `bin/bem`: replaced by `bin/backend-manager`.
47
+
17
48
  # [5.0.84] - 2026-02-19
18
49
  ### BREAKING
19
50
  - Moved `config.products` to `config.payment.products`. All product lookups now use `config.payment.products`.
package/CLAUDE.md CHANGED
@@ -59,11 +59,12 @@ src/
59
59
  utilities.js # Batch operations
60
60
  metadata.js # Timestamps/tags
61
61
  libraries/
62
- payment-processors/ # Shared payment processor utilities
63
- stripe.js # Stripe SDK init, fetchResource, toUnified*
64
- test.js # Test processor (delegates to Stripe shapes)
62
+ payment/ # Shared payment utilities
65
63
  order-id.js # Order ID generation (XXXX-XXXX-XXXX)
66
- resolve-price-id.js # Shared price ID resolver from config
64
+ processors/ # Payment processor libraries
65
+ stripe.js # Stripe SDK init, fetchResource, toUnified*, resolvePriceId
66
+ paypal.js # PayPal fetchResource, toUnified* (custom_id parsing)
67
+ test.js # Test processor (delegates to Stripe shapes)
67
68
  functions/core/ # Built-in functions
68
69
  actions/
69
70
  api.js # Main bm_api handler
@@ -88,12 +89,28 @@ src/
88
89
  post.js # Intent creation orchestrator
89
90
  processors/ # Per-processor intent creators
90
91
  stripe.js # Stripe Checkout Session creation
92
+ paypal.js # PayPal subscription + one-time order creation
91
93
  test.js # Test processor (auto-fires webhooks)
92
94
  webhook/ # POST /payments/webhook
93
95
  post.js # Webhook ingestion + Firestore write
94
96
  processors/ # Per-processor webhook parsers
95
97
  stripe.js # Stripe event parsing + categorization
98
+ paypal.js # PayPal event parsing + categorization
96
99
  test.js # Test processor (delegates to Stripe)
100
+ cancel/ # POST /payments/cancel
101
+ processors/
102
+ stripe.js # Stripe cancel_at_period_end
103
+ paypal.js # PayPal subscription cancel
104
+ test.js # Test cancel (writes webhook doc)
105
+ refund/ # POST /payments/refund
106
+ processors/
107
+ stripe.js # Stripe refund + immediate cancel
108
+ paypal.js # PayPal refund + cancel
109
+ test.js # Test refund (writes webhook doc)
110
+ portal/ # POST /payments/portal
111
+ processors/
112
+ stripe.js # Stripe billing portal URL
113
+ paypal.js # PayPal management URL
97
114
  schemas/ # Built-in schemas
98
115
  cli/
99
116
  index.js # CLI entry point
@@ -421,6 +438,15 @@ npx bm test # Terminal 2 - runs tests
421
438
  npx bm test
422
439
  ```
423
440
 
441
+ ### Log Files
442
+ Both `npx bm emulator` and `npx bm test` automatically save all output to log files in the project directory while still streaming to the console:
443
+ - **`emulator.log`** — Full emulator output (Firebase emulator + Cloud Functions logs)
444
+ - **`test.log`** — Test runner output (when running against an existing emulator)
445
+
446
+ When `npx bm test` starts its own emulator, logs go to `emulator.log` (since it delegates to the emulator command). When running against an already-running emulator, logs go to `test.log`.
447
+
448
+ These files are overwritten on each run and are gitignored (`*.log`). Use them to search for errors, debug webhook pipelines, or review full function output after a test run.
449
+
424
450
  ### Filtering Tests
425
451
  ```bash
426
452
  npx bm test rules/ # Run rules tests (both BEM and project)
@@ -772,9 +798,24 @@ The payment system follows a linear pipeline: **Intent → Webhook → On-Write
772
798
 
773
799
  4. **Transitions** (fire-and-forget): Handler files run asynchronously after detection. Failures never block webhook processing. Skipped during tests unless `TEST_EXTENDED_MODE` is set.
774
800
 
801
+ ### 3-Layer Architecture
802
+
803
+ The payment system is cleanly separated into three independent layers:
804
+
805
+ | Layer | Purpose | Tests |
806
+ |-------|---------|-------|
807
+ | **Processor input** (Stripe, PayPal, Test) | Parse raw webhooks + transform to unified shape | Helper tests per processor (`payment/stripe/to-unified-subscription.js`, `payment/paypal/to-unified-one-time.js`, etc.) |
808
+ | **Unified pipeline** (processor-agnostic) | Transition detection, Firestore writes, analytics | Journey tests (`journey-payments-*.js`) |
809
+ | **Transition handlers** (fire-and-forget) | Emails, notifications, side effects | Skipped during tests unless `TEST_EXTENDED_MODE` |
810
+
811
+ Each processor transforms its raw data into the **same unified shape**. Once data enters the pipeline, the code doesn't know or care which processor it came from. This means:
812
+ - Adding a new processor = implement the processor interface (below). The pipeline handles the rest.
813
+ - Journey tests use the `test` processor but exercise the full unified pipeline end-to-end.
814
+ - Processor-specific tests only need to verify correct transformation to the unified shape.
815
+
775
816
  ### Processor Interface
776
817
 
777
- Each processor implements two modules:
818
+ Each processor implements three modules:
778
819
 
779
820
  **Intent processor** (`routes/payments/intent/processors/{processor}.js`):
780
821
  ```javascript
@@ -793,25 +834,73 @@ module.exports = {
793
834
  };
794
835
  ```
795
836
 
796
- **Shared library** (`libraries/payment-processors/{processor}.js`):
837
+ **Cancel processor** (`routes/payments/cancel/processors/{processor}.js`):
838
+ ```javascript
839
+ module.exports = {
840
+ async cancelAtPeriodEnd({ resourceId, uid, subscription, assistant }) { /* cancel at end of period */ },
841
+ };
842
+ ```
843
+
844
+ **Refund processor** (`routes/payments/refund/processors/{processor}.js`):
845
+ ```javascript
846
+ module.exports = {
847
+ async processRefund({ resourceId, uid, subscription, assistant }) {
848
+ return { amount, currency, full };
849
+ },
850
+ };
851
+ ```
852
+
853
+ **Portal processor** (`routes/payments/portal/processors/{processor}.js`):
854
+ ```javascript
855
+ module.exports = {
856
+ async createPortalSession({ resourceId, uid, returnUrl, assistant }) {
857
+ return { url };
858
+ },
859
+ };
860
+ ```
861
+
862
+ **Shared library** (`libraries/payment/processors/{processor}.js`):
797
863
  ```javascript
798
864
  module.exports = {
799
865
  init() { /* return SDK instance */ },
800
866
  async fetchResource(resourceType, resourceId, rawFallback, context) { /* return resource */ },
867
+ getOrderId(resource) { /* return orderId string or null */ },
801
868
  toUnifiedSubscription(rawSubscription, options) { /* return unified object */ },
802
869
  toUnifiedOneTime(rawResource, options) { /* return unified object */ },
803
870
  };
804
871
  ```
805
872
 
873
+ ### Product Resolution
874
+
875
+ Products are resolved differently per processor, but always end up matching a product in `config.payment.products`:
876
+
877
+ | Processor | Resolution chain | Stable ID |
878
+ |-----------|-----------------|-----------|
879
+ | **Stripe** | `sub.items.data[0].price.product` or `raw.plan.product` → match `product.stripe.productId` or `legacyProductIds` | `prod_xxx` |
880
+ | **PayPal** | `sub → plan_id → plan → product_id` → match `product.paypal.productId` | PayPal catalog product ID |
881
+ | **Test** | Uses `product.stripe.productId` in Stripe-shaped data | Same as Stripe |
882
+
883
+ Falls back to `{ id: 'basic' }` if no match found.
884
+
885
+ ### Processor-Specific Details
886
+
887
+ **Stripe:** Uses `metadata.uid` and `metadata.orderId` on subscriptions for UID/order resolution.
888
+
889
+ **PayPal:** Uses `custom_id` field on subscriptions with format `uid:{uid},orderId:{orderId}`. Product resolution fetches the plan from the subscription, then gets `product_id` from the plan. Plans are scoped by `product_id` query param to avoid cross-brand matches on shared PayPal accounts.
890
+
806
891
  ### Product Configuration
807
892
 
808
893
  Products are defined in `backend-manager-config.json` under `payment.products`:
809
894
 
810
895
  ```javascript
811
896
  payment: {
897
+ processors: {
898
+ stripe: { publishableKey: 'pk_live_...' },
899
+ paypal: { clientId: 'ARvf...' },
900
+ },
812
901
  products: [
813
902
  {
814
- id: 'basic', // Free tier (no prices)
903
+ id: 'basic', // Free tier (no prices, no processor keys)
815
904
  name: 'Basic',
816
905
  type: 'subscription',
817
906
  limits: { requests: 100 },
@@ -821,30 +910,31 @@ payment: {
821
910
  name: 'Premium',
822
911
  type: 'subscription',
823
912
  limits: { requests: 1000 },
824
- trial: { days: 14 }, // Optional trial period
825
- prices: {
826
- monthly: { amount: 4.99, stripe: 'price_xxx', paypal: null },
827
- annually: { amount: 49.99, stripe: 'price_yyy', paypal: null },
828
- },
913
+ trial: { days: 14 },
914
+ prices: { monthly: 4.99, annually: 49.99 }, // Flat numbers only
915
+ stripe: { productId: 'prod_xxx', legacyProductIds: ['prod_OLD'] },
916
+ paypal: { productId: 'PROD-abc123' },
829
917
  },
830
918
  {
831
919
  id: 'credits-100', // One-time purchase
832
920
  name: '100 Credits',
833
921
  type: 'one-time',
834
- prices: {
835
- once: { amount: 9.99, stripe: 'price_zzz' },
836
- },
922
+ prices: { once: 9.99 },
923
+ stripe: { productId: 'prod_yyy' },
924
+ paypal: { productId: null },
837
925
  },
838
926
  ],
839
927
  }
840
928
  ```
841
929
 
842
930
  Key rules:
843
- - `type` is `'subscription'` (default) or `'one-time'`
844
- - Subscription prices are keyed by frequency: `monthly`, `annually`
845
- - One-time prices are keyed as `once`
846
- - Each price object has processor-specific IDs (`stripe`, `paypal`, etc.)
847
- - `basic` product has no `prices` it's the free tier
931
+ - `prices` contains **flat numbers only** — no processor-specific IDs
932
+ - Processor IDs live at the product level: `stripe: { productId }`, `paypal: { productId }`
933
+ - `stripe.productId` is stable never changes even when prices change
934
+ - `stripe.legacyProductIds` maps old pre-migration Stripe products to this product
935
+ - Price IDs (Stripe `price_xxx`, PayPal plan IDs) are **resolved at runtime** by matching amount + interval against active prices on the processor's product
936
+ - `basic` product has no `prices` and no processor keys — it's the free tier
937
+ - `archived: true` stops offering a product to new subscribers while keeping it resolvable for existing ones
848
938
 
849
939
  ### Firestore Collections
850
940
 
@@ -900,10 +990,10 @@ The `test` processor generates Stripe-shaped data and auto-fires webhooks to the
900
990
  | Webhook processing (on-write) | `src/manager/events/firestore/payments-webhooks/on-write.js` |
901
991
  | Payment analytics | `src/manager/events/firestore/payments-webhooks/analytics.js` |
902
992
  | Transition detection | `src/manager/events/firestore/payments-webhooks/transitions/index.js` |
903
- | Payment processor libraries | `src/manager/libraries/payment-processors/` |
904
- | Stripe library | `src/manager/libraries/payment-processors/stripe.js` |
905
- | Price ID resolver | `src/manager/libraries/payment-processors/resolve-price-id.js` |
906
- | Order ID generator | `src/manager/libraries/payment-processors/order-id.js` |
993
+ | Payment processor libraries | `src/manager/libraries/payment/processors/` |
994
+ | Stripe library | `src/manager/libraries/payment/processors/stripe.js` |
995
+ | PayPal library | `src/manager/libraries/payment/processors/paypal.js` |
996
+ | Order ID generator | `src/manager/libraries/payment/order-id.js` |
907
997
  | Test accounts | `src/test/test-accounts.js` |
908
998
 
909
999
  ## Environment Detection
package/README.md CHANGED
@@ -793,6 +793,14 @@ npx bm test project:rules/ # Only project's rules tests
793
793
  npx bm test user/ admin/ # Multiple paths
794
794
  ```
795
795
 
796
+ ### Log Files
797
+
798
+ Both `npx bm emulator` and `npx bm test` automatically save all output to log files in the project directory:
799
+ - **`emulator.log`** — Full emulator + Cloud Functions output
800
+ - **`test.log`** — Test runner output (when running against an existing emulator)
801
+
802
+ Logs are overwritten on each run. Use them to debug failing tests or review function output.
803
+
796
804
  ### Test Locations
797
805
 
798
806
  - **BEM core tests:** `test/`
@@ -871,7 +879,7 @@ See `CLAUDE.md` for complete test API documentation.
871
879
 
872
880
  ## Subscription System
873
881
 
874
- BEM includes a built-in payment/subscription system with Stripe integration (extensible to other providers).
882
+ BEM includes a built-in payment/subscription system with Stripe and PayPal integration.
875
883
 
876
884
  ### Subscription Statuses
877
885
 
@@ -894,6 +902,39 @@ BEM includes a built-in payment/subscription system with Stripe integration (ext
894
902
  | `incomplete_expired` | `cancelled` | Expired before completion |
895
903
  | `active` + `cancel_at_period_end` | `active` | `cancellation.pending = true` |
896
904
 
905
+ ### PayPal Status Mapping
906
+
907
+ | PayPal Status | `subscription.status` | Notes |
908
+ |---|---|---|
909
+ | `ACTIVE` | `active` | Normal active subscription |
910
+ | `SUSPENDED` | `suspended` | Payment failed or manually suspended |
911
+ | `CANCELLED` | `cancelled` | Subscription terminated |
912
+ | `EXPIRED` | `cancelled` | Billing cycles completed |
913
+
914
+ ### Product Configuration
915
+
916
+ Products are defined in `config.payment.products` with flat prices and per-processor IDs:
917
+
918
+ ```javascript
919
+ payment: {
920
+ products: [
921
+ { id: 'basic', name: 'Basic', type: 'subscription', limits: { requests: 10 } },
922
+ {
923
+ id: 'plus', name: 'Plus', type: 'subscription',
924
+ limits: { requests: 100 }, trial: { days: 14 },
925
+ prices: { monthly: 28, annually: 276 },
926
+ stripe: { productId: 'prod_xxx' },
927
+ paypal: { productId: 'PROD-abc123' },
928
+ },
929
+ {
930
+ id: 'boost', name: 'Boost Pack', type: 'one-time',
931
+ prices: { once: 9.99 },
932
+ stripe: { productId: 'prod_yyy' },
933
+ },
934
+ ],
935
+ }
936
+ ```
937
+
897
938
  ### Unified Subscription Object
898
939
 
899
940
  The same subscription shape is stored in `users/{uid}.subscription` and `payments-orders/{orderId}.subscription`:
@@ -24,9 +24,12 @@ Note: * for managing and cancelling, they should be under a sinlge button/dropdo
24
24
  * we need to make it hard to cancel, buried in some info, and make it more prominent to manage it, since we want to encourage users to manage their subscription rather than cancelling it.
25
25
 
26
26
  payments/refund
27
- * takes a subscription id and requests a refund.
27
+ * takes a subscription id and requests a refund to the payment processor
28
28
  * we can only refund the most recent payment and we can only refund if the subscription is cancelled.
29
- * we can only refund if the most recent payment in FULL was made less than 7 days ago, otherwise we can only refund a prorated amount.
29
+ * we can only refund if the most recent payment in FULL was made less than 7 days ago, otherwise we can only refund a prorated amount based on how much time is left
30
+ * i want the subscription to be immediately revoked upon refund request
31
+ * generally, we dont modify the subscription DURING the http endpoint (such as payment intent, or cancel), rather we WAIT FOR THE WEBHOOOK. Can we do that here???
32
+
30
33
  payments/reactivate
31
34
  * takes a subscription id and reactivates a cancelled subscription. this can only be done if the subscription is still within its billing period, otherwise the user would have to create a new subscription.
32
35
  payments/upgrade
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.104",
3
+ "version": "5.0.106",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -1,5 +1,4 @@
1
1
  const BaseCommand = require('./base-command');
2
- const chalk = require('chalk');
3
2
  const powertools = require('node-powertools');
4
3
 
5
4
  class DeployCommand extends BaseCommand {
@@ -7,9 +6,8 @@ class DeployCommand extends BaseCommand {
7
6
  const self = this.main;
8
7
 
9
8
  // Quick check that not using local packages
10
- let deps = JSON.stringify(self.packageJSON.dependencies);
11
- let hasLocal = deps.includes('file:');
12
- if (hasLocal) {
9
+ const allDeps = JSON.stringify(self.packageJSON.dependencies || {}) + JSON.stringify(self.packageJSON.devDependencies || {});
10
+ if (allDeps.includes('file:')) {
13
11
  this.logError(`Please remove local packages before deploying!`);
14
12
  return;
15
13
  }
@@ -1,5 +1,6 @@
1
1
  const BaseCommand = require('./base-command');
2
2
  const path = require('path');
3
+ const fs = require('fs');
3
4
  const chalk = require('chalk');
4
5
  const jetpack = require('fs-jetpack');
5
6
  const JSON5 = require('json5');
@@ -60,9 +61,37 @@ class EmulatorCommand extends BaseCommand {
60
61
  // Use double quotes for command wrapper since the command may contain single quotes (JSON strings)
61
62
  const emulatorCommand = `BEM_TESTING=true firebase emulators:exec --only functions,firestore,auth,database,hosting,pubsub --ui "${command}"`;
62
63
 
64
+ // Set up log file in the project directory
65
+ const logPath = path.join(projectDir, 'emulator.log');
66
+ const logStream = fs.createWriteStream(logPath, { flags: 'w' });
67
+ const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
68
+
69
+ this.log(chalk.gray(` Logs saving to: ${logPath}\n`));
70
+
63
71
  await powertools.execute(emulatorCommand, {
64
- log: true,
72
+ log: false,
65
73
  cwd: projectDir,
74
+ config: {
75
+ stdio: ['inherit', 'pipe', 'pipe'],
76
+ env: { ...process.env, FORCE_COLOR: '1' },
77
+ },
78
+ }, (child) => {
79
+ // Tee stdout to both console and log file (strip ANSI codes for clean log)
80
+ child.stdout.on('data', (data) => {
81
+ process.stdout.write(data);
82
+ logStream.write(stripAnsi(data.toString()));
83
+ });
84
+
85
+ // Tee stderr to both console and log file (strip ANSI codes for clean log)
86
+ child.stderr.on('data', (data) => {
87
+ process.stderr.write(data);
88
+ logStream.write(stripAnsi(data.toString()));
89
+ });
90
+
91
+ // Clean up log stream when child exits
92
+ child.on('close', () => {
93
+ logStream.end();
94
+ });
66
95
  });
67
96
  }
68
97
 
@@ -1,5 +1,6 @@
1
1
  const BaseCommand = require('./base-command');
2
2
  const path = require('path');
3
+ const fs = require('fs');
3
4
  const chalk = require('chalk');
4
5
  const jetpack = require('fs-jetpack');
5
6
  const JSON5 = require('json5');
@@ -176,15 +177,45 @@ class TestCommand extends BaseCommand {
176
177
  * Run tests directly (emulator already running)
177
178
  */
178
179
  async runTestsDirectly(testCommand, functionsDir, emulatorPorts) {
180
+ const projectDir = this.main.firebaseProjectPath;
181
+
179
182
  this.log(chalk.gray(` Hosting: http://127.0.0.1:${emulatorPorts.hosting}`));
180
183
  this.log(chalk.gray(` Firestore: 127.0.0.1:${emulatorPorts.firestore}`));
181
184
  this.log(chalk.gray(` Auth: 127.0.0.1:${emulatorPorts.auth}`));
182
- this.log(chalk.gray(` UI: http://127.0.0.1:${emulatorPorts.ui}\n`));
185
+ this.log(chalk.gray(` UI: http://127.0.0.1:${emulatorPorts.ui}`));
186
+
187
+ // Set up log file in the project directory
188
+ const logPath = path.join(projectDir, 'test.log');
189
+ const logStream = fs.createWriteStream(logPath, { flags: 'w' });
190
+ const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
191
+
192
+ this.log(chalk.gray(` Logs saving to: ${logPath}\n`));
183
193
 
184
194
  try {
185
195
  await powertools.execute(testCommand, {
186
- log: true,
196
+ log: false,
187
197
  cwd: functionsDir,
198
+ config: {
199
+ stdio: ['inherit', 'pipe', 'pipe'],
200
+ env: { ...process.env, FORCE_COLOR: '1' },
201
+ },
202
+ }, (child) => {
203
+ // Tee stdout to both console and log file (strip ANSI codes for clean log)
204
+ child.stdout.on('data', (data) => {
205
+ process.stdout.write(data);
206
+ logStream.write(stripAnsi(data.toString()));
207
+ });
208
+
209
+ // Tee stderr to both console and log file (strip ANSI codes for clean log)
210
+ child.stderr.on('data', (data) => {
211
+ process.stderr.write(data);
212
+ logStream.write(stripAnsi(data.toString()));
213
+ });
214
+
215
+ // Clean up log stream when child exits
216
+ child.on('close', () => {
217
+ logStream.end();
218
+ });
188
219
  });
189
220
  } catch (error) {
190
221
  process.exit(1);
@@ -55,7 +55,7 @@ module.exports = async ({ assistant, change, context }) => {
55
55
  // Load the shared library for this processor
56
56
  let library;
57
57
  try {
58
- library = require(`../../../libraries/payment-processors/${processor}.js`);
58
+ library = require(`../../../libraries/payment/processors/${processor}.js`);
59
59
  } catch (e) {
60
60
  throw new Error(`Unknown processor library: ${processor}`);
61
61
  }
@@ -72,8 +72,8 @@ module.exports = async ({ assistant, change, context }) => {
72
72
  const nowUNIX = powertools.timestamp(now, { output: 'unix' });
73
73
  const webhookReceivedUNIX = dataAfter.metadata?.received?.timestampUNIX || nowUNIX;
74
74
 
75
- // Extract orderId from resource metadata (set at intent creation)
76
- const orderId = resource.metadata?.orderId || null;
75
+ // Extract orderId from resource (processor-agnostic)
76
+ const orderId = library.getOrderId(resource);
77
77
 
78
78
  // Process the payment event (subscription or one-time)
79
79
  if (category !== 'subscription' && category !== 'one-time') {
@@ -243,6 +243,20 @@ function extractCustomerName(resource, resourceType) {
243
243
  fullName = resource.customer_name;
244
244
  }
245
245
 
246
+ // PayPal orders have payer.name
247
+ if (resourceType === 'order') {
248
+ const givenName = resource.payer?.name?.given_name;
249
+ const surname = resource.payer?.name?.surname;
250
+
251
+ if (givenName) {
252
+ const { capitalize } = require('../../../libraries/infer-contact.js');
253
+ return {
254
+ first: capitalize(givenName) || null,
255
+ last: capitalize(surname) || null,
256
+ };
257
+ }
258
+ }
259
+
246
260
  // Subscriptions only have customer ID, no name
247
261
 
248
262
  if (!fullName) {
@@ -92,6 +92,7 @@ function detectSubscriptionTransition(before, after) {
92
92
  * @returns {string|null} Transition name
93
93
  */
94
94
  function detectOneTimeTransition(eventType) {
95
+ // Stripe
95
96
  if (eventType === 'checkout.session.completed') {
96
97
  return 'purchase-completed';
97
98
  }
@@ -100,6 +101,11 @@ function detectOneTimeTransition(eventType) {
100
101
  return 'purchase-failed';
101
102
  }
102
103
 
104
+ // PayPal
105
+ if (eventType === 'CHECKOUT.ORDER.APPROVED') {
106
+ return 'purchase-completed';
107
+ }
108
+
103
109
  return null;
104
110
  }
105
111
 
@@ -1,6 +1,6 @@
1
1
  // Libraries
2
2
  const path = require('path');
3
- const { merge } = require('lodash');
3
+ const { mergeWith, isArray } = require('lodash');
4
4
  const jetpack = require('fs-jetpack');
5
5
  const JSON5 = require('json5');
6
6
  const EventEmitter = require('events');
@@ -129,10 +129,13 @@ Manager.prototype.init = function (exporter, options) {
129
129
  }
130
130
 
131
131
  // Load config
132
- self.config = merge(
132
+ // Use mergeWith to replace arrays instead of merging by index
133
+ // (lodash merge merges arrays positionally, causing template defaults to bleed into project values)
134
+ self.config = mergeWith(
133
135
  {},
134
136
  requireJSON5(BEM_CONFIG_TEMPLATE_PATH, true),
135
137
  requireJSON5(self.project.backendManagerConfigPath, true),
138
+ (_objValue, srcValue) => isArray(srcValue) ? srcValue : undefined,
136
139
  );
137
140
 
138
141
  // Resolve legacy paths