@@ -75,6 +75,68 @@ func TestOnPromotionalCreditPurchase_BacksAdvanceBeforeTopUp(t *testing.T) {
7575 require .True (t , env .sumBalance (t , env .washSubAccount (t , alpacadecimal .Zero )).Equal (alpacadecimal .NewFromInt (- 100 )))
7676}
7777
78+ func TestOnCreditPurchaseInitiated_PastEffectiveGrantBackdatesAdvanceAttribution (t * testing.T ) {
79+ env := newCreditPurchaseHandlerTestEnv (t )
80+
81+ // given:
82+ // - advance exposure that predates a credit purchase's effective time
83+ // - the purchase is materialized after that effective time
84+ // when:
85+ // - the purchase is initiated
86+ // then:
87+ // - recording time stays current while every purchase posting is effective in the past
88+ recordedAt := env .Now ()
89+ effectiveAt := recordedAt .Add (- 2 * time .Hour )
90+ func () {
91+ clock .FreezeTime (effectiveAt .Add (- time .Hour ))
92+ defer clock .UnFreeze ()
93+ env .createAdvanceExposure (t , alpacadecimal .NewFromInt (40 ))
94+ }()
95+ clock .FreezeTime (recordedAt )
96+ defer clock .UnFreeze ()
97+
98+ costBasis := mustDecimal (t , "0.5" )
99+ charge := env .newExternalCharge (alpacadecimal .NewFromInt (100 ), costBasis )
100+ effectivePeriod := timeutil.ClosedPeriod {From : effectiveAt , To : effectiveAt }
101+ charge .Intent .ServicePeriod = effectivePeriod
102+ charge .Intent .FullServicePeriod = effectivePeriod
103+ charge .Intent .BillingPeriod = effectivePeriod
104+
105+ ref , err := env .handler .OnCreditPurchaseInitiated (t .Context (), charge )
106+ require .NoError (t , err )
107+ require .NotEmpty (t , ref .TransactionGroupID )
108+
109+ bookedAtByTemplate := env .transactionBookedAtByTemplateCode (t , ref .TransactionGroupID )
110+ for _ , template := range []transactions.TransactionTemplate {
111+ transactions.AttributeCustomerAdvanceReceivableCostBasisTemplate {},
112+ transactions.TranslateCustomerAccruedCostBasisTemplate {},
113+ transactions.IssueCustomerReceivableTemplate {},
114+ } {
115+ bookedAt := bookedAtByTemplate [transactions .TemplateCode (template )]
116+ require .Len (t , bookedAt , 1 )
117+ requireLedgerBookedAtEqual (t , effectiveAt , bookedAt [0 ])
118+ }
119+
120+ transactionRows , err := env .DB .LedgerTransaction .Query ().
121+ Where (
122+ ledgertransactiondb .Namespace (env .Namespace ),
123+ ledgertransactiondb .GroupID (ref .TransactionGroupID ),
124+ ).
125+ All (t .Context ())
126+ require .NoError (t , err )
127+ require .NotEmpty (t , transactionRows )
128+ for _ , transactionRow := range transactionRows {
129+ require .False (t , transactionRow .CreatedAt .Before (recordedAt ))
130+ }
131+
132+ require .Equal (t , float64 (0 ), env .sumBalanceAsOf (t , env .unknownReceivableSubAccount (t ), effectiveAt ).InexactFloat64 ())
133+ require .Equal (t , float64 (- 100 ), env .sumBalanceAsOf (t , env .receivableSubAccount (t , costBasis ), effectiveAt ).InexactFloat64 ())
134+ require .Equal (t , float64 (0 ), env .sumBalanceAsOf (t , env .authorizedReceivableSubAccount (t , costBasis ), effectiveAt ).InexactFloat64 ())
135+ require .Equal (t , float64 (40 ), env .sumBalanceAsOf (t , env .accruedSubAccount (t , costBasis ), effectiveAt ).InexactFloat64 ())
136+ require .Equal (t , float64 (60 ), env .sumBalanceAsOf (t , env .fboSubAccount (t , costBasis ), effectiveAt ).InexactFloat64 ())
137+ require .Equal (t , float64 (0 ), env .sumBalanceAsOf (t , env .washSubAccount (t , costBasis ), effectiveAt ).InexactFloat64 ())
138+ }
139+
78140func TestOnCreditPurchaseInitiated_BackfillsOnlyMatchingFeatureAdvances (t * testing.T ) {
79141 env := newCreditPurchaseHandlerTestEnv (t )
80142 env .createAdvanceExposureWithFeatures (t , alpacadecimal .NewFromInt (40 ), []string {"api-calls" })
@@ -140,12 +202,21 @@ func TestOnCreditPurchaseInitiated(t *testing.T) {
140202 require .True (t , env .sumBalance (t , env .receivableSubAccount (t , costBasis )).Equal (alpacadecimal .NewFromInt (- 100 )))
141203}
142204
143- func TestOnCreditPurchaseInitiated_UsesServicePeriodEndAsBookedAt (t * testing.T ) {
205+ func TestOnCreditPurchaseInitiated_FutureEffectiveGrantBackfillsAdvanceAtPurchaseTime (t * testing.T ) {
144206 env := newCreditPurchaseHandlerTestEnv (t )
145207
208+ // given:
209+ // - existing advance and a credit purchase whose remainder becomes effective later
210+ // when:
211+ // - the materialized charge initiates the purchase now
212+ // then:
213+ // - advance attribution is booked now while only the remainder is issued later
214+ purchasedAt := env .Now ()
215+ env .createAdvanceExposure (t , alpacadecimal .NewFromInt (40 ))
216+
146217 costBasis := mustDecimal (t , "0.5" )
147218 charge := env .newExternalCharge (alpacadecimal .NewFromInt (100 ), costBasis )
148- effectiveAt := charge . CreatedAt .Add (2 * time .Hour )
219+ effectiveAt := purchasedAt .Add (2 * time .Hour )
149220 effectivePeriod := timeutil.ClosedPeriod {From : effectiveAt , To : effectiveAt }
150221 charge .Intent .ServicePeriod = effectivePeriod
151222 charge .Intent .FullServicePeriod = effectivePeriod
@@ -155,10 +226,71 @@ func TestOnCreditPurchaseInitiated_UsesServicePeriodEndAsBookedAt(t *testing.T)
155226 require .NoError (t , err )
156227 require .NotEmpty (t , ref .TransactionGroupID )
157228
158- for _ , bookedAt := range env .transactionBookedAtTimes (t , ref .TransactionGroupID ) {
159- requireLedgerBookedAtEqual (t , effectiveAt , bookedAt )
160- requireLedgerBookedAtNotEqual (t , charge .CreatedAt , bookedAt )
229+ bookedAtByTemplate := env .transactionBookedAtByTemplateCode (t , ref .TransactionGroupID )
230+ for _ , template := range []transactions.TransactionTemplate {
231+ transactions.AttributeCustomerAdvanceReceivableCostBasisTemplate {},
232+ transactions.TranslateCustomerAccruedCostBasisTemplate {},
233+ } {
234+ bookedAt := bookedAtByTemplate [transactions .TemplateCode (template )]
235+ require .Len (t , bookedAt , 1 )
236+ requireLedgerBookedAtEqual (t , purchasedAt , bookedAt [0 ])
161237 }
238+
239+ issuanceBookedAt := bookedAtByTemplate [transactions .TemplateCode (transactions.IssueCustomerReceivableTemplate {})]
240+ require .Len (t , issuanceBookedAt , 1 )
241+ requireLedgerBookedAtEqual (t , effectiveAt , issuanceBookedAt [0 ])
242+
243+ // The backfilled amount is reflected now, but the future remainder is not spendable yet.
244+ require .Equal (t , float64 (40 ), env .sumBalanceAsOf (t , env .accruedSubAccount (t , costBasis ), purchasedAt ).InexactFloat64 ())
245+ require .Equal (t , float64 (0 ), env .sumBalanceAsOf (t , env .fboSubAccount (t , costBasis ), purchasedAt ).InexactFloat64 ())
246+ require .Equal (t , float64 (60 ), env .sumBalanceAsOf (t , env .fboSubAccount (t , costBasis ), effectiveAt ).InexactFloat64 ())
247+ }
248+
249+ func TestOnCreditPurchaseInitiated_SubsequentFuturePurchaseCannotOverAttributeAdvance (t * testing.T ) {
250+ env := newCreditPurchaseHandlerTestEnv (t )
251+
252+ // given:
253+ // - 100 of existing advance and two future-effective purchases of 60 each
254+ // when:
255+ // - both purchases are initiated now
256+ // then:
257+ // - the first attributes 60, the second attributes only the remaining 40, and 20 stays future issuance
258+ purchasedAt := env .Now ()
259+ effectiveAt := purchasedAt .Add (2 * time .Hour )
260+ effectivePeriod := timeutil.ClosedPeriod {From : effectiveAt , To : effectiveAt }
261+ env .createAdvanceExposure (t , alpacadecimal .NewFromInt (100 ))
262+
263+ costBasis := mustDecimal (t , "0.5" )
264+ firstCharge := env .newExternalCharge (alpacadecimal .NewFromInt (60 ), costBasis )
265+ firstCharge .ID = "01JABCDEF0123456789ABCDEFG"
266+ firstCharge .Intent .ServicePeriod = effectivePeriod
267+ firstCharge .Intent .FullServicePeriod = effectivePeriod
268+ firstCharge .Intent .BillingPeriod = effectivePeriod
269+
270+ secondCharge := env .newExternalCharge (alpacadecimal .NewFromInt (60 ), costBasis )
271+ secondCharge .ID = "01JBCDEF0123456789ABCDEFGH"
272+ secondCharge .Intent .ServicePeriod = effectivePeriod
273+ secondCharge .Intent .FullServicePeriod = effectivePeriod
274+ secondCharge .Intent .BillingPeriod = effectivePeriod
275+
276+ firstRef , err := env .handler .OnCreditPurchaseInitiated (t .Context (), firstCharge )
277+ require .NoError (t , err )
278+ secondRef , err := env .handler .OnCreditPurchaseInitiated (t .Context (), secondCharge )
279+ require .NoError (t , err )
280+
281+ require .NotContains (t , env .transactionTemplateCodes (t , firstRef .TransactionGroupID ), transactions .TemplateCode (transactions.IssueCustomerReceivableTemplate {}))
282+ secondBookedAtByTemplate := env .transactionBookedAtByTemplateCode (t , secondRef .TransactionGroupID )
283+ secondAttributionBookedAt := secondBookedAtByTemplate [transactions .TemplateCode (transactions.AttributeCustomerAdvanceReceivableCostBasisTemplate {})]
284+ require .Len (t , secondAttributionBookedAt , 1 )
285+ requireLedgerBookedAtEqual (t , purchasedAt , secondAttributionBookedAt [0 ])
286+ secondIssuanceBookedAt := secondBookedAtByTemplate [transactions .TemplateCode (transactions.IssueCustomerReceivableTemplate {})]
287+ require .Len (t , secondIssuanceBookedAt , 1 )
288+ requireLedgerBookedAtEqual (t , effectiveAt , secondIssuanceBookedAt [0 ])
289+
290+ require .Equal (t , float64 (0 ), env .sumBalanceAsOf (t , env .unknownReceivableSubAccount (t ), purchasedAt ).InexactFloat64 ())
291+ require .Equal (t , float64 (100 ), env .sumBalanceAsOf (t , env .accruedSubAccount (t , costBasis ), purchasedAt ).InexactFloat64 ())
292+ require .Equal (t , float64 (0 ), env .sumBalanceAsOf (t , env .fboSubAccount (t , costBasis ), purchasedAt ).InexactFloat64 ())
293+ require .Equal (t , float64 (20 ), env .sumBalanceAsOf (t , env .fboSubAccount (t , costBasis ), effectiveAt ).InexactFloat64 ())
162294}
163295
164296func TestOnCreditPurchaseInitiated_SeparatesSourceChargeBuckets (t * testing.T ) {
@@ -876,6 +1008,32 @@ func (e *creditPurchaseHandlerTestEnv) transactionBookedAtTimes(t *testing.T, gr
8761008 return out
8771009}
8781010
1011+ func (e * creditPurchaseHandlerTestEnv ) transactionBookedAtByTemplateCode (t * testing.T , groupID string ) map [string ][]time.Time {
1012+ t .Helper ()
1013+
1014+ transactionRows , err := e .DB .LedgerTransaction .Query ().
1015+ Where (
1016+ ledgertransactiondb .Namespace (e .Namespace ),
1017+ ledgertransactiondb .GroupID (groupID ),
1018+ ).
1019+ Order (
1020+ ledgertransactiondb .ByCreatedAt (),
1021+ ledgertransactiondb .ByID (),
1022+ ).
1023+ All (t .Context ())
1024+ require .NoError (t , err )
1025+ require .NotEmpty (t , transactionRows , "expected at least one ledger transaction for group" )
1026+
1027+ out := make (map [string ][]time.Time , len (transactionRows ))
1028+ for _ , transactionRow := range transactionRows {
1029+ code , err := ledger .TransactionTemplateCodeFromAnnotations (transactionRow .Annotations )
1030+ require .NoError (t , err )
1031+ out [code ] = append (out [code ], transactionRow .BookedAt )
1032+ }
1033+
1034+ return out
1035+ }
1036+
8791037func (e * creditPurchaseHandlerTestEnv ) transactionTemplateCodes (t * testing.T , groupID string ) []string {
8801038 t .Helper ()
8811039
0 commit comments