@xuda.io/account_module 1.2.2312 → 1.2.2313

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 (2) hide show
  1. package/index.mjs +376 -37
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -144,9 +144,40 @@ export const _sync_account_project_name = async function (account_obj, ref_info)
144
144
  // stat 1 is the unverified state (stat 2 is the password-reset temp state,
145
145
  // stat 3 verified), matching the write-gate in http_module — which now lets
146
146
  // update_account_info and upload_drive_file_user through at stat 1 precisely so
147
- // the picture can be saved. verify_account starts any deferred generation the
148
- // moment the account flips to verified, so nothing is lost by waiting here.
149
- const can_generate_avatar = (account_obj) => account_obj?.stat !== 1;
147
+ // the picture can be saved.
148
+ //
149
+ // The bar is LEVEL 2, not a confirmed email. A Xuda avatar is a portrait shown
150
+ // beside a name across the platform, and level 2 is the first level that checks
151
+ // a face against a photo ID — so it is the first point at which the portrait can
152
+ // be said to be of the person it claims to be. Below it the account keeps its
153
+ // uploaded picture and simply has no generated avatar. verify_module fires
154
+ // ensure_profile_avatar the moment an account crosses into level 2, so nothing
155
+ // is lost by waiting here.
156
+ //
157
+ // verify_level is the projection _recompute writes onto the account doc, so this
158
+ // reads the same number every other surface reads and needs no round trip.
159
+ const AVATAR_MIN_VERIFY_LEVEL = 2;
160
+ const can_generate_avatar = (account_obj) => account_obj?.stat !== 1 && Number(account_obj?.account_info?.verify_level || 0) >= AVATAR_MIN_VERIFY_LEVEL;
161
+
162
+ // Is an avatar owed for the picture currently on the account?
163
+ //
164
+ // The test is against the SOURCE, not against profile_avatar. Several client
165
+ // paths mirror the uploaded photo into profile_avatar so that every surface
166
+ // shows the new face immediately instead of a placeholder while generation
167
+ // runs, which makes "has an avatar" useless as a question — the field is
168
+ // populated either way. profile_avatar_from records the picture the generated
169
+ // avatar was actually made from, and it is written by the generator alone (it is
170
+ // deliberately absent from account_info_properties, so no client can set it).
171
+ //
172
+ // That is what makes this safe to evaluate on every save: replacing the photo
173
+ // makes an avatar due exactly once, and nothing the client writes into
174
+ // profile_avatar can make it due a second time.
175
+ const avatar_is_due = (account_info) => {
176
+ const info = account_info || {};
177
+ if (!info.profile_picture) return false;
178
+ if (info.profile_avatar_stat === 2) return false; // one is already generating
179
+ return info.profile_avatar_from !== info.profile_picture;
180
+ };
150
181
 
151
182
  export const update_account_info = async function (req, job_id, headers) {
152
183
  const { uid } = req;
@@ -184,6 +215,19 @@ export const update_account_info = async function (req, job_id, headers) {
184
215
  var account_info_changes_arr = [];
185
216
  var error = {};
186
217
 
218
+ // The client sends active_account_profile_id straight from its own store, and that store can
219
+ // hold a profile belonging to a DIFFERENT account (e.g. after switching accounts in the
220
+ // dashboard). Writing a foreign id here used to brick the account: every later read of the
221
+ // profile looked it up in THIS account's project db, got "missing", and threw. Only accept an
222
+ // id that actually resolves to an account_profile in this account's own project.
223
+ if (typeof data.active_account_profile_id !== 'undefined' && data.active_account_profile_id !== account_obj.account_info?.active_account_profile_id) {
224
+ const candidate_profile = await _try_get_profile_doc(account_obj.account_project_id, data.active_account_profile_id);
225
+ if (!candidate_profile) {
226
+ console.warn(`[update_account_info] acc ${uid}: rejected active_account_profile_id ${data.active_account_profile_id}, not an account_profile in project ${account_obj.account_project_id}`);
227
+ delete data.active_account_profile_id;
228
+ }
229
+ }
230
+
187
231
  await marketplace_ms.marketplace_save_user({ uid });
188
232
 
189
233
  for (const key of account_info_properties) {
@@ -287,12 +331,31 @@ export const update_account_info = async function (req, job_id, headers) {
287
331
  return { code: -1310, data: error };
288
332
  }
289
333
  if (!change) {
290
- if (can_generate_avatar(account_obj) && account_obj.account_info?.profile_picture && !account_obj.account_info?.profile_avatar && account_obj.account_info.profile_avatar_stat !== 2) {
334
+ // A no-op save still gets to start a generation that is owed. This is the
335
+ // path a verified account takes when it reopens the picture window and
336
+ // presses through without changing anything, and it is how an account that
337
+ // reached level 2 before this rule existed picks up its avatar.
338
+ if (can_generate_avatar(account_obj) && avatar_is_due(account_obj.account_info)) {
291
339
  set_account_profile_picture(uid, uid, account_obj.account_info, job_id, headers, account_profile_info);
292
340
  }
293
341
  return { code: 1300, data: 'no change' };
294
342
  }
295
343
 
344
+ // A client that sets profile_avatar to something OTHER than the photo itself
345
+ // is recording a GENERATED avatar — the create_avatar route the picture window
346
+ // and the onboarding step drive, which hands the file back to the browser and
347
+ // saves nothing itself. Stamping the source here is what stops that avatar
348
+ // reading as still owed, which would have the server generate a second one
349
+ // over the top of it on the next save, at the cost of the AI credits twice.
350
+ //
351
+ // Mirroring the photo INTO profile_avatar is the opposite case: a placeholder
352
+ // so surfaces show the new face immediately, with a real avatar still owed.
353
+ // The two are told apart by exactly that — whether the avatar is the photo.
354
+ if (account_info_changes_arr.includes('profile_avatar') && account_obj.account_info.profile_avatar && account_obj.account_info.profile_avatar !== account_obj.account_info.profile_picture) {
355
+ account_obj.account_info.profile_avatar_from = account_obj.account_info.profile_picture;
356
+ account_obj.account_info.avatar_source = account_obj.account_info.profile_avatar_obj?.avatar_source || account_obj.account_info.avatar_source;
357
+ }
358
+
296
359
  account_obj.account_info.full_name = `${account_obj.account_info.first_name} ${account_obj.account_info.last_name}`;
297
360
 
298
361
  //clean up received from req
@@ -326,10 +389,11 @@ export const update_account_info = async function (req, job_id, headers) {
326
389
  await _sync_account_project_name(account_obj, _old_name_info);
327
390
  }
328
391
 
329
- if (can_generate_avatar(account_obj) && account_obj.account_info?.profile_picture) {
330
- if (!account_obj.account_info?.profile_avatar && account_obj.account_info.profile_avatar_stat !== 2) {
331
- set_account_profile_picture(uid, uid, account_obj.account_info, job_id, headers, account_profile_info);
332
- }
392
+ // A new photo makes the avatar on file stale, so this covers the ordinary
393
+ // "changed my picture" case as well as the first-ever generation: both are
394
+ // just "the avatar was not made from the picture that is there now".
395
+ if (can_generate_avatar(account_obj) && avatar_is_due(account_obj.account_info)) {
396
+ set_account_profile_picture(uid, uid, account_obj.account_info, job_id, headers, account_profile_info);
333
397
  }
334
398
 
335
399
  // Opportunistic Stripe consolidation. Any time the user updates
@@ -3410,6 +3474,20 @@ export const did_you_know_tips = async function (req, job_id, headers) {
3410
3474
 
3411
3475
  const _warned_no_account_project_id = new Set();
3412
3476
  const _warned_no_profile_id = new Set();
3477
+ const _warned_dangling_profile_id = new Set();
3478
+
3479
+ // Resolve a profile doc that is supposed to live in this account's own project db, without
3480
+ // throwing when it does not. A dangling id is not an exceptional condition here: it happens
3481
+ // whenever a profile was deleted, or when an id belonging to ANOTHER account leaked into
3482
+ // account_info.active_account_profile_id (update_account_info used to accept any id the client
3483
+ // sent). Returning null lets the caller fall back instead of hard-failing every read.
3484
+ const _try_get_profile_doc = async function (app_id, doc_id) {
3485
+ if (!app_id || !doc_id) return null;
3486
+ const ret = await db_module.get_app_couch_doc(app_id, doc_id, true);
3487
+ if (ret.code < 0) return null;
3488
+ const doc = ret.data;
3489
+ return doc && doc.docType === 'account_profile' ? doc : null;
3490
+ };
3413
3491
 
3414
3492
  export const get_active_account_profile_info = async function (uid, profile_id) {
3415
3493
  try {
@@ -3468,7 +3546,65 @@ export const get_active_account_profile_info = async function (uid, profile_id)
3468
3546
  return { uid, account_profile_id: null, app_id: acc_obj.account_project_id, is_main: false, account_profile_obj: null };
3469
3547
  }
3470
3548
 
3471
- const account_profile_obj = await db_module.get_app_couch_doc_native(acc_obj.account_project_id, active_account_profile_id);
3549
+ let account_profile_obj = await _try_get_profile_doc(acc_obj.account_project_id, active_account_profile_id);
3550
+
3551
+ if (!account_profile_obj) {
3552
+ // The stored id points at nothing in this account's project db (deleted profile, or a
3553
+ // foreign id written by an older, unvalidated update_account_info). Previously this threw
3554
+ // "missing" and every caller downstream, i.e. the whole AI workspace, died with it. Walk the
3555
+ // rest of the fallback chain instead and repair the account doc so the next read is clean.
3556
+ const candidates = [acc_obj.account_info?.active_account_profile_id, acc_obj.account_profile_id].filter((id) => id && id !== active_account_profile_id);
3557
+
3558
+ for (const candidate of candidates) {
3559
+ account_profile_obj = await _try_get_profile_doc(acc_obj.account_project_id, candidate);
3560
+ if (account_profile_obj) {
3561
+ active_account_profile_id = candidate;
3562
+ break;
3563
+ }
3564
+ }
3565
+
3566
+ if (!account_profile_obj) {
3567
+ // Nothing on the account doc resolves. Adopt this account's own main (or newest) profile.
3568
+ try {
3569
+ const existing = await db_module.find_app_couch_query(acc_obj.account_project_id, {
3570
+ selector: { docType: 'account_profile', uid },
3571
+ limit: 50,
3572
+ });
3573
+ const docs = (existing && existing.docs) || [];
3574
+ const chosen = docs.find((d) => d.main) || docs.slice().sort((a, b) => (b.date_created_ts || 0) - (a.date_created_ts || 0))[0];
3575
+ if (chosen) {
3576
+ account_profile_obj = chosen;
3577
+ active_account_profile_id = chosen._id;
3578
+ }
3579
+ } catch (find_err) {
3580
+ /* fall through to the soft-fail below */
3581
+ }
3582
+ }
3583
+
3584
+ if (!_warned_dangling_profile_id.has(acc_obj._id)) {
3585
+ _warned_dangling_profile_id.add(acc_obj._id);
3586
+ console.warn(`[get_active_account_profile_info] acc ${acc_obj._id}: profile not found in project ${acc_obj.account_project_id}; fell back to ${active_account_profile_id || 'null'}`);
3587
+ }
3588
+
3589
+ if (!account_profile_obj) {
3590
+ return { uid, account_profile_id: null, app_id: acc_obj.account_project_id, is_main: false, account_profile_obj: null };
3591
+ }
3592
+
3593
+ // Persist the repair only when the bad id came from the account's own stored state. A bad
3594
+ // explicit profile_id argument is the caller's problem, not something to write back.
3595
+ if (!profile_id && acc_obj.account_info?.active_account_profile_id !== active_account_profile_id) {
3596
+ try {
3597
+ acc_obj.account_info = acc_obj.account_info || {};
3598
+ acc_obj.account_info.active_account_profile_id = active_account_profile_id;
3599
+ if (!acc_obj.account_profile_id) acc_obj.account_profile_id = active_account_profile_id;
3600
+ await db_module.save_couch_doc('xuda_accounts', acc_obj); // conflict-safe (retry loop)
3601
+ console.log(`[get_active_account_profile_info] self-healed acc ${acc_obj._id}: active profile reset to ${active_account_profile_id}`);
3602
+ } catch (heal_err) {
3603
+ /* the in-memory fallback above still serves this request */
3604
+ }
3605
+ }
3606
+ }
3607
+
3472
3608
  if (account_profile_obj.share_item_id) {
3473
3609
  // set the original profile id if shared
3474
3610
  active_account_profile_id = account_profile_obj.share_item_id;
@@ -3645,13 +3781,14 @@ export const verify_account = async function (req) {
3645
3781
  console.warn('[verify_account] confirm_email banner cleanup failed:', e.message);
3646
3782
  }
3647
3783
 
3648
- // A picture uploaded before verification deliberately did NOT generate an
3649
- // avatar (see can_generate_avatar). The account is verified as of the save
3650
- // above, so start the generation that was deferred. Fire-and-forget:
3651
- // generation is heavy (vision + image ops) and must not hold up the verify
3652
- // response, and ensure_profile_avatar re-reads the doc and does its own
3653
- // guarding picture present, no avatar yet, not already generating so a
3654
- // user who never uploaded one is a no-op.
3784
+ // Confirming an email no longer earns an avatar on its own — level 2 does,
3785
+ // and verify_module starts the generation when the account crosses it. This
3786
+ // call stays because it costs nothing and closes the one ordering it would
3787
+ // otherwise miss: an account that already holds level 2 while still sitting at
3788
+ // stat 1, where can_generate_avatar refused up to the save above. For everyone
3789
+ // else ensure_profile_avatar re-reads the doc, finds the level bar unmet, and
3790
+ // returns. Fire-and-forget either way: generation is heavy (vision + image
3791
+ // ops) and must not hold up the verify response.
3655
3792
  try {
3656
3793
  ensure_profile_avatar({ uid: account_id }).catch((e) => console.warn('[verify_account] deferred avatar start failed:', e.message));
3657
3794
  } catch (e) {
@@ -4311,15 +4448,23 @@ const level_pattern = function (doc) {
4311
4448
  return `level-${level}-pattern.png`;
4312
4449
  };
4313
4450
 
4314
- // The level tile is the DEFAULT for every contact card, not a fallback. It used
4315
- // to be computed first and then overwritten by a tile keyed off where the
4316
- // contact came from (xu / authentic / fictional / email / web), so a card for a
4317
- // verified person showed the generic XU tile and the level never appeared on any
4318
- // card that had a contact_uid, which is every card for a real account. Only the
4319
- // states that say the card cannot show a level at all still win: a shared card
4320
- // wears its owner's avatar, an avatar being processed says so, and spam says so.
4451
+ // Where a contact comes from decides which language its tile speaks, and the two
4452
+ // are not interchangeable.
4453
+ //
4454
+ // A contact with a `contact_uid` is a real Xuda account, so its tile is that
4455
+ // account's verification level. This used to be computed and then thrown away by
4456
+ // a switch on `avatar_source` that returned the generic xu tile, which is why a
4457
+ // card for a verified person showed no level at all.
4458
+ //
4459
+ // A contact with no account behind it has no level and must never be given one,
4460
+ // not even L0: nobody scored that person, and a card that says otherwise is a
4461
+ // claim we cannot back. Those keep the tile for where they walked in from, the
4462
+ // mailbox, the web, or an incoming call.
4463
+ //
4464
+ // Ahead of both, the states that mean the card cannot show either: a shared card
4465
+ // wears its owner's avatar, an avatar mid-processing says so, spam says spam.
4321
4466
  const get_contact_pattern = async function (doc) {
4322
- let ret = level_pattern(doc);
4467
+ let ret = `default-pattern.png`;
4323
4468
 
4324
4469
  if (doc?.shared_from_uid) {
4325
4470
  const shared_from_uid_ret = await get_account_name({ uid_query: doc.shared_from_uid });
@@ -4338,12 +4483,34 @@ const get_contact_pattern = async function (doc) {
4338
4483
  }
4339
4484
  } else if (doc.is_spam) {
4340
4485
  ret = `spam-pattern.png`;
4486
+ } else if (doc.contact_uid) {
4487
+ ret = level_pattern(doc);
4488
+ } else {
4489
+ switch (doc.source) {
4490
+ case 'read emails': {
4491
+ ret = `email-pattern.png`;
4492
+ break;
4493
+ }
4494
+ // Every way a stranger reaches us through a web surface reads as web: the
4495
+ // chat widget, a contact form, and the older plain 'web'. They are the
4496
+ // same story to whoever is looking at the card.
4497
+ case 'web':
4498
+ case 'widget':
4499
+ case 'contact_form': {
4500
+ ret = `web-pattern.png`;
4501
+ break;
4502
+ }
4503
+ // A contact the phone system created for an unknown caller (voice_module
4504
+ // writes this when an inbound call comes from a number we do not hold).
4505
+ case 'inbound_call': {
4506
+ ret = `phone-pattern.png`;
4507
+ break;
4508
+ }
4509
+
4510
+ default:
4511
+ break;
4512
+ }
4341
4513
  }
4342
- // Everything else keeps the level tile. The old where-it-came-from tiles
4343
- // (xu / authentic / fictional for an account, email / web for a scraped
4344
- // contact) are gone on purpose: they occupied the one surface that now
4345
- // carries the verification level, and a contact with no account behind it
4346
- // has no level to show, which is exactly what L0 says.
4347
4514
  return ret;
4348
4515
  };
4349
4516
 
@@ -5421,6 +5588,16 @@ export const ts_contact = async function (uid, contact_id) {
5421
5588
  };
5422
5589
 
5423
5590
  const set_account_profile_picture = async function (uid, account_uid, metadata, job_id, headers, account_profile_info) {
5591
+ // Normalised HERE rather than at each call site because every background
5592
+ // trigger — verify_account, the L2 crossing, the widget signup — has no
5593
+ // request to take headers from, and an omitted argument arrives as null over
5594
+ // the queue rather than undefined, so a default parameter would not catch it.
5595
+ // drive_module reads headers['cf-connecting-ip'] unguarded when it records an
5596
+ // upload, so a null threw there and the whole generation was lost. It failed
5597
+ // quietly: the throw was caught below and written to profile_avatar_error,
5598
+ // which nothing displays, leaving accounts sitting at stat 1 with no avatar
5599
+ // and no visible reason.
5600
+ headers = headers || {};
5424
5601
  await update_account_profile_picture_status(account_uid, 1);
5425
5602
  try {
5426
5603
  let profile_picture;
@@ -5451,7 +5628,7 @@ const set_account_profile_picture = async function (uid, account_uid, metadata,
5451
5628
  profile_picture = account_info.profile_picture;
5452
5629
  }
5453
5630
 
5454
- if (!account_info.profile_avatar) {
5631
+ if (avatar_is_due(account_info)) {
5455
5632
  let business_size;
5456
5633
 
5457
5634
  switch (account_obj.membership_plan) {
@@ -5499,6 +5676,28 @@ const set_account_profile_picture = async function (uid, account_uid, metadata,
5499
5676
  if (!_.isObject(file_ret.data)) throw new Error('file_ret not an object');
5500
5677
  account_info.profile_avatar_obj = file_ret.data;
5501
5678
  account_info.profile_avatar = account_info.profile_avatar_obj.file_url;
5679
+ // Which photo this avatar was made from. Saved in the same write as the
5680
+ // avatar so the two can never disagree, and read back by avatar_is_due —
5681
+ // it is what stops a generation repeating and what makes the next photo
5682
+ // change ask for a new one.
5683
+ account_info.profile_avatar_from = account_info.profile_picture;
5684
+ // WHICH of the two things ai_module produced. get_profile_avatar makes an
5685
+ // 'authentic profile' — the person's own face, restored if needed,
5686
+ // background removed, centred on the detected face box — when the photo
5687
+ // can carry one, and silently falls back to a 'fictional' likeness
5688
+ // generated from the account's metadata when it cannot.
5689
+ //
5690
+ // That distinction is the whole point of holding this behind level 2: the
5691
+ // avatar is meant to BE the verified person, and an invented face
5692
+ // presented as theirs would say something untrue about an account that
5693
+ // has just proved who it belongs to. The picture window reads this and
5694
+ // says so rather than letting the substitution pass unremarked.
5695
+ //
5696
+ // Nothing had ever written avatar_source on the ACCOUNT path, though the
5697
+ // contact and profile builders set it and every card builder copies
5698
+ // account_info.avatar_source onto the doc it makes — so they were all
5699
+ // copying undefined.
5700
+ account_info.avatar_source = file_ret.data.avatar_source;
5502
5701
 
5503
5702
  const account_save_ret = await db_module.save_couch_doc('xuda_accounts', account_obj);
5504
5703
  await update_account_profile_picture_status(account_uid, 3);
@@ -5511,12 +5710,18 @@ const set_account_profile_picture = async function (uid, account_uid, metadata,
5511
5710
  }
5512
5711
  };
5513
5712
 
5514
- // Internal trigger used by the widget Google-signup flow: when a freshly-created
5515
- // visitor account already has a profile_picture (their Google photo) but no
5516
- // generated avatar yet, kick off avatar generation. set_account_profile_picture
5517
- // maintains profile_avatar_stat (1 2 3); the caller polls that. Mirrors the
5518
- // opportunistic trigger in update_account_info (profile_avatar_stat !== 2 guards
5519
- // against re-triggering while a generation is mid-flight).
5713
+ // The one internal entry point for "start this account's avatar if one is
5714
+ // owed". Called by the widget Google-signup flow (a fresh visitor arriving with
5715
+ // their Google photo) and by verify_module the moment an account crosses into
5716
+ // level 2, which is the trigger that matters: reaching level 2 is what earns the
5717
+ // avatar, and it can be reached without anyone touching this account's profile.
5718
+ //
5719
+ // Every rule lives in the two helpers rather than here, so a caller cannot get
5720
+ // an avatar for an account that has not earned one by picking this door:
5721
+ // can_generate_avatar holds the level bar, avatar_is_due holds "not already
5722
+ // made from this photo, and not already running". A caller with nothing owing
5723
+ // is a cheap no-op. set_account_profile_picture maintains profile_avatar_stat
5724
+ // (1 → 2 → 3), which is what the dashboard polls.
5520
5725
  export const ensure_profile_avatar = async function (req, job_id, headers) {
5521
5726
  try {
5522
5727
  const { uid } = req || {};
@@ -5524,7 +5729,7 @@ export const ensure_profile_avatar = async function (req, job_id, headers) {
5524
5729
  const { code, data: account_obj } = await db_module.get_couch_doc('xuda_accounts', uid);
5525
5730
  if (code < 0 || !account_obj) return { code: -1, data: 'account not found' };
5526
5731
  const info = account_obj.account_info || {};
5527
- if (info.profile_picture && !info.profile_avatar && info.profile_avatar_stat !== 2) {
5732
+ if (can_generate_avatar(account_obj) && avatar_is_due(info)) {
5528
5733
  // Best-effort profile context for AI-usage attribution. A freshly-created
5529
5734
  // widget visitor may have no profile/project yet — tolerate that.
5530
5735
  let account_profile_info;
@@ -7503,6 +7708,96 @@ const _current_credit_period = function (account_doc) {
7503
7708
 
7504
7709
  // Single source of truth for "how many credits does this account have left".
7505
7710
  // Everything that gates, meters or displays credits must go through here.
7711
+ // The membership plans SELL a monthly AI allowance, but the only thing that has
7712
+ // ever minted it into the ledger is the Stripe invoice.paid webhook
7713
+ // (stripe_module `_grant_period_credits_for_invoice`). Miss that webhook once
7714
+ // and there is no second chance: `archive_expire_ai_credits` retires the last
7715
+ // grant at its 30-day mark, the balance silently falls back to the free daily
7716
+ // drip, and the account is hard-blocked by the gate while the plans page and
7717
+ // `get_effective_entitlements` both still promise the full allowance.
7718
+ //
7719
+ // That is not hypothetical. A Team account on dev sat at 1,600 granted until
7720
+ // 2026-08-11 09:00 UTC, when the cron expired a grant written on 2026-07-12.
7721
+ // Nothing replaced it, so the account dropped to 31 credits (30 daily drips
7722
+ // plus a friendship bonus) against 219 already spent, and every AI call was
7723
+ // refused. The old safety net for this, add_ai_credits_to_active_accounts, is
7724
+ // commented out (see the call site above).
7725
+ //
7726
+ // So the allowance heals itself: a paid membership with no live membership
7727
+ // grant gets THIS period's allowance minted here. Minting twice is what would
7728
+ // actually cost money, so three independent guards stand in the way: a live
7729
+ // grant from any membership plan wins, the account carries a per-period marker,
7730
+ // and record_ai_credit refuses a duplicate (credited_uid, details) outright.
7731
+ // The grant carries period_end_ts, so the expiry cron leaves it alone until the
7732
+ // period it belongs to is genuinely over.
7733
+ const _membership_plan_of = function (account_doc) {
7734
+ const plan = _conf.PLAN_OBJ?.[account_doc?.membership_plan];
7735
+ if (!plan || plan.category !== 'membership') return null;
7736
+ // The free tier's allowance is the daily drip, which already writes its own
7737
+ // ledger docs. Minting on top of it would hand every free account a second
7738
+ // month's worth.
7739
+ if (!(Number(plan.price) > 0)) return null;
7740
+ const credits = Number(plan.features?.ai_credits);
7741
+ if (!Number.isFinite(credits) || credits <= 0) return null;
7742
+ return { plan, credits };
7743
+ };
7744
+
7745
+ const _self_heal_period_grant = async function (uid, doc, period, by_source) {
7746
+ try {
7747
+ if (doc?.stat !== 3) return 0;
7748
+ // Past due is exactly when an allowance should NOT be minted: the webhook
7749
+ // only ever granted on a PAID invoice.
7750
+ if (doc?.account_billing_hold_status) return 0;
7751
+
7752
+ const found = _membership_plan_of(doc);
7753
+ if (!found) return 0;
7754
+
7755
+ // A live grant from any membership plan means the normal path worked (or a
7756
+ // downgrade left the old one running). Either way this period is covered,
7757
+ // and a plan change must not be a way to mint a second allowance.
7758
+ for (const [source, amount] of Object.entries(by_source || {})) {
7759
+ if (!(amount > 0)) continue;
7760
+ const p = _conf.PLAN_OBJ?.[source];
7761
+ if (p && p.category === 'membership') return 0;
7762
+ }
7763
+
7764
+ // Already healed this period. A grant retired mid-period (an expiry, a
7765
+ // roll) must not come back, so this is checked before minting, not after.
7766
+ if (Number(doc.credits_selfheal_period_ts) === period.start) return 0;
7767
+
7768
+ const day = new Date(period.start).toISOString().slice(0, 10);
7769
+ const ret = await record_ai_credit('system', found.credits, found.plan.id, `plan allowance ${found.plan.id} ${day}`, uid, {
7770
+ kind: CREDIT_KIND.PERIOD,
7771
+ period_start_ts: period.start,
7772
+ period_end_ts: period.end,
7773
+ });
7774
+ if (!(ret?.code > -1)) return 0;
7775
+
7776
+ // Fresh read for the write: the caller's copy of the account doc may be
7777
+ // held across a long request, and this marker must not ride on top of
7778
+ // whatever else it has since changed.
7779
+ try {
7780
+ const fresh = await db_module.get_couch_doc_native('xuda_accounts', uid);
7781
+ if (fresh) {
7782
+ fresh.credits_selfheal_period_ts = period.start;
7783
+ await db_module.save_couch_doc('xuda_accounts', fresh);
7784
+ }
7785
+ } catch (e) {
7786
+ // The marker is an optimisation, not the guard: record_ai_credit's own
7787
+ // duplicate check still makes a second mint impossible.
7788
+ console.error('[credit selfheal marker]', e?.message || e);
7789
+ }
7790
+ if (doc && typeof doc === 'object') doc.credits_selfheal_period_ts = period.start;
7791
+
7792
+ console.log(`[credit selfheal] ${uid} +${found.credits} (${found.plan.id}) for period starting ${day}`);
7793
+ return found.credits;
7794
+ } catch (err) {
7795
+ // Never let this break a balance read: the gate calls it on every AI call.
7796
+ console.error('[credit selfheal]', err?.message || err);
7797
+ return 0;
7798
+ }
7799
+ };
7800
+
7506
7801
  export const get_credit_balance = async function (uid, account_doc) {
7507
7802
  const doc = account_doc || (await db_module.get_couch_doc_native('xuda_accounts', uid));
7508
7803
  const period = _current_credit_period(doc);
@@ -7523,6 +7818,15 @@ export const get_credit_balance = async function (uid, account_doc) {
7523
7818
  else period_grant += credits;
7524
7819
  }
7525
7820
 
7821
+ // Mint this period's plan allowance if nothing else did (see above). Costs
7822
+ // nothing on the hot path when there is a live grant: every guard but the
7823
+ // last reads state that is already in hand.
7824
+ const healed = await _self_heal_period_grant(uid, doc, period, by_source);
7825
+ if (healed > 0) {
7826
+ period_grant += healed;
7827
+ by_source[doc.membership_plan] = (by_source[doc.membership_plan] || 0) + healed;
7828
+ }
7829
+
7526
7830
  const usage_ret = await get_account_ai_usage({ uid, ts_from: period.start, ts_to: Date.now() });
7527
7831
  const used = _usage_total(usage_ret?.data?.usage);
7528
7832
 
@@ -8401,6 +8705,41 @@ export const archive_expire_ai_credits = async function () {
8401
8705
  }
8402
8706
  };
8403
8707
 
8708
+ // Of the three ai_credit queries in this file, two were already indexed and one
8709
+ // was not:
8710
+ //
8711
+ // record_ai_credit's duplicate check { docType, credited_uid, details }
8712
+ // -> idx_billing_credit_lookup, fine
8713
+ // archive_expire_ai_credits { docType, stat, date_created_ts: { $lt } }
8714
+ // -> stat_docType_date_created_ts-json-index, fine
8715
+ // roll_credit_period's grant retirement { docType, credited_uid, stat }, limit 9999
8716
+ // -> _all_docs, i.e. a FULL SCAN of the whole ledger on every period roll
8717
+ //
8718
+ // master's xuda_billing holds ~27.5k docs, so that last one read all of them to
8719
+ // return one account's handful of grants, and Couch logged "No matching index
8720
+ // found ... the number of documents examined is high in proportion to the number
8721
+ // of results returned" every time.
8722
+ //
8723
+ // credited_uid leads because it is by far the most selective field; all three
8724
+ // clauses are equalities, so there is no range field to keep last.
8725
+ //
8726
+ // Not host-gated: xuda_billing is reachable wherever this module runs, and the PUT
8727
+ // no-ops once the design doc is already there (including when it arrived by
8728
+ // replication rather than from this node's own run).
8729
+ (async () => {
8730
+ try {
8731
+ const ret = await db_module.create_couch_index('xuda_billing', {
8732
+ index: { fields: ['credited_uid', 'docType', 'stat'] },
8733
+ name: 'idx_credit_uid_type_stat',
8734
+ ddoc: 'idx_credit_uid_type_stat',
8735
+ type: 'json',
8736
+ });
8737
+ if (ret?.error) console.error('[credits] xuda_billing credited_uid/docType/stat index create failed:', ret.error);
8738
+ } catch (err) {
8739
+ console.error('[credits] xuda_billing credited_uid/docType/stat index init failed:', err?.message || err);
8740
+ }
8741
+ })();
8742
+
8404
8743
  export const read_accounts_emails = async function () {
8405
8744
  const active_accounts = await db_module.find_couch_query('xuda_accounts', { selector: { stat: 3, docType: 'account' }, limit: 99999 });
8406
8745
  for await (let account_doc of active_accounts.docs) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/account_module",
3
- "version": "1.2.2312",
3
+ "version": "1.2.2313",
4
4
  "description": "Xuda Account Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {